ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

微信小程序城市多选分组组件优化实践:从卡顿到流畅

微信小程序城市多选分组组件优化实践:从卡顿到流畅

微信小程序里的城市多选分组组件,说实话一开始我并没觉得有多难。无非就是左侧省份列表、右侧城市列表,加个多选和全选。直到真实需求砸过来——要支持省市区三级,数据量三千多条,还要按字母索引快速定位、支持搜索、多选结果要能回显。我上手第一版的时候还信心满满,结果真机一测,滚动掉帧、内存飙升、选中状态还会错乱,整个人都被这个组件按在地上摩擦。这篇文章就把我优化这个"城市多选分组组件"的完整过程、踩过的坑和最终沉淀下来的方案,一次性讲清楚。

这个组件适合谁看?凡是小程序项目里要做城市多选、省市区联动、或者任何"分组+长列表+多选"组合需求的前端开发,都能直接抄思路。不管是数据量几百条还是几千条,核心的优化思想完全一致。我会把数据设计、渲染优化、状态管理、搜索联动这些环节拆开讲,配合代码说明,方便你直接搬到自己的项目里。

1. 这个组件为什么会卡:先定位问题再谈优化

1.1 初版实现的三宗罪

我当时的第一版实现非常朴素:把省-市-区三层数据一次性塞给setData,WXML 里用wx:for嵌套渲染,左侧是省份分组,右侧是当前省份的城市列表,选中的城市用selectedCodes数组记录。界面倒是能跑起来,但问题立刻暴露。

第一宗罪是数据量。全国地级行政区三百多个,再加上区县,总条目超过三千。我把这三千条完整塞进页面数据后,WXML 上的节点数几乎翻倍——每个城市都要渲染"复选框 + 文字"。早期开发者工具上看着还行,真机上滚动列表时,每一帧都要重排重绘,明显能感觉到掉帧。

第二宗罪是选中状态的全量更新。我用了this.setData({ selectedCodes: newList })这种最简单的方式,选中一个城市就把整个数组重新赋值一遍。在三千条数据的背景下,一次点击触发全量setData,页面卡顿半秒,逻辑层和渲染层都在来回传数据。这在小程序里是大忌,因为逻辑层和渲染层之间是异步通信,数据一多,性能指数级下降。

第三宗罪是搜索功能形同虚设。初版只支持按汉字匹配,用户输入"南京",列表要遍历整个树,还要递归查找,耗时不说,匹配后的分组结构也乱了。用户搜完再清空,界面状态经常恢复不完整。

注意:如果你的组件只是单城市选择,几十条数据,随便怎么写都不会有大问题。但一旦进入"多选+分组+大数据量"的组合场景,性能和状态管理就必须单独设计。

1.2 优化目标与评判标准

动手优化之前,我先定了几个硬指标,不然改起来容易跑偏。

第一个目标是滚动流畅度。在三千条数据全量存在的前提下,滚动列表不出现持续掉帧,帧率肉眼感觉不到卡顿。这个指标不用工具测也能判断,快速上下滑动的时候,城市文字不能有"拖影感"。

第二个目标是交互响应速度。点击任何一个城市的复选框,从手指抬起界面到状态更新完成,整个过程不能超过 100 毫秒,也就是用户觉得"点了就变了"。这要求setData的数据增量必须控制在极小范围。

第三个目标是状态一致性。多选结果、全选半选状态、搜索结果的选中状态,三者必须实时同步,任何情况下不能出现"分组显示全选,但实际只选了一个"这种不一致。

第四个目标是代码可维护性。原有的嵌套渲染逻辑太绕,我自己隔两周再看都想骂人。优化后的结构必须层次分明,新增一个城市、修改一个分组都要容易。

定了这四条,我接下来做了一件很关键的事:先抛开渲染层,重做数据结构。很多前端同学习惯拿到接口数据直接往模板里套,其实对于复杂组件,数据结构设计的优先级远高于写代码。下一章我详细讲。

2. 数据结构先行:从嵌套树到扁平索引

2.1 原始数据结构的痛点

后端接口返回的数据长这样:

[ { regionId: "110000", regionName: "北京市", children: [ { regionId: "110100", regionName: "北京市", children: [ { regionId: "110101", regionName: "东城区" }, { regionId: "110102", regionName: "西城区" } ] } ] } ]

这种树形结构在展示"省份 -> 城市 -> 区县"的层级关系时很直观,但作为组件内部的数据源,它有三个很大的问题。

第一个问题:模板渲染必须递归。WXML 不像普通 HTML 那样可以直接递归组件,要么用template递归引用,要么手动拆成多层wx:for。拆层之后,每个节点的上下级关系要额外维护,代码可读性差。

第二个问题:每次查询都要递归。搜索一个城市,要for省份、for城市、for区县,三层嵌套,时间复杂度高。而且匹配到的节点散落在树的各个位置,很难重组成一个有序的搜索结果列表。

第三个问题:不利于构建分组索引。按字母索引"B"要看北京,还是得遍历整个树,把每个节点的首字母算出来再排序。每次组件启动都算一遍,浪费性能。

所以我的优化思路非常明确:在组件内部维护一份"面向渲染和查询"的扁平数据,树形数据只在初始化的时候用一次。这就是"数据预处理"的核心。

2.2 扁平化与分组索引构建

扁平化操作就是把树形结构打散成一条一条的独立记录,每条记录包含完整的展示信息和归属信息。我用一段初始化函数来完成:

function flattenRegions(regionTree) { const flatList = []; const groupMap = {}; // 分组名 -> 该分组下所有城市记录的索引 regionTree.forEach((province) => { const provinceCode = province.regionId; const provinceName = province.regionName; // 当前省份作为一个分组,分组 id 用省份 code if (!groupMap[provinceCode]) { groupMap[provinceCode] = []; } // 处理城市层级,我这里选择把省份作为分组层 province.children.forEach((city) => { const cityCode = city.regionId; const cityName = city.regionName; flatList.push({ id: cityCode, name: cityName, shortName: cityName.replace(/市$/, ""), letter: pinyin.getFirstLetter(cityName)[0], // 拼音首字母 groupId: provinceCode, groupName: provinceName, depth: 1 }); groupMap[provinceCode].push(flatList.length - 1); // 区县挂到对应城市下,这里只取一层做演示,实际逻辑可以递归 city.children.forEach((district) => { flatList.push({ id: district.regionId, name: district.regionName, letter: pinyin.getFirstLetter(district.regionName)[0], groupId: cityCode, groupName: cityName, parentGroupId: provinceCode, depth: 2 }); groupMap[cityCode].push(flatList.length - 1); }); }); }); return { flatList, groupMap }; }

这段代码有一个细节很多人会忽略:groupMap里存的不是完整数据,而是索引,也就是flatList的下标。这样做的意义在于,当flatList中某条记录的选中状态变化时,分组内不需要再存一份对象拷贝,只需要通过索引直接访问,避免数据冗余导致的同步问题。

我实际项目中有一个分组存两个索引列表的情况:一个是"省分组",一个是"市级分组"。因为产品要求先按省分组展示,展开省之后才看到市,再展开市才是区县。这个需求做成"两层级联"比单纯的扁平列表要复杂,但核心思想不变:分组索引管层级,扁平数组管渲染。

2.3 拼音首字母与检索字段预处理

按字母索引定位是城市选择器的标配功能。实现上,如果每次输入都现算拼音,效率很差。我选择在预处理阶段一次性把需要的字段算好:

const pinyin = require("tiny-pinyin"); // 或在构建时用后端算好返回 function buildLetterIndex(flatList) { const letterMap = {}; flatList.forEach((item, index) => { const letter = (item.letter || "#").toUpperCase(); if (!/^[A-Z]$/.test(letter)) { letter = "#"; } if (!letterMap[letter]) { letterMap[letter] = []; } letterMap[letter].push(index); }); return letterMap; }

这里我把无法识别为首字母的记录统一归到#分组。中文城市的拼音首字母,绝大部分是 A-Z,少数是多音字或者是非汉字开头的,统一放#避免程序报错。

预处理时还要多做一件小事:把shortName、searchText这样的检索字段提前拼好。比如searchText可以是"beijing|北京|BJ",搜索时直接indexOf匹配,比现转拼音再比对要快得多。

提示:如果后端接口能直接返回letter和pinyinFullName字段,前端就可以省掉引入拼音库的依赖。我当时因为是后端字段不齐,才在前端做了预处理。能用后端处理的一定让后端处理,前端只做展示层逻辑,性能更好。

3. 渲染层优化:分组长列表不再卡顿

3.1 将要渲染的数据量砍到十分之一

结构设计好之后,最大的挑战就是渲染层。小程序页面的 WXML 节点一旦超过一定量级,性能就绷不住。三千条记录全部渲染,节点数轻松过万。做过小程序性能优化的同学应该都知道,节点数超过 2000 之后,滚动和重绘都会有明显压力。

我的方案是"分页 + 可视区裁剪":不是把三千条数据一次性渲染,而是只渲染当前可视区域以及上下一段缓冲区域内的分组。滑动时通过滚动位置实时计算应该显示哪几个分组,然后增量更新。

具体做法是维护一个visibleGroupIds数组,里面只存放当前应该渲染的分组 id。滚动事件触发时,根据scrollTop和每个分组的高度,计算出当前视口覆盖到哪些分组。

onScroll(e) { const scrollTop = e.detail.scrollTop; // 节流,避免每帧触发 if (Math.abs(scrollTop - this.lastScrollTop) < 30) return; this.lastScrollTop = scrollTop; const newStart = this.findGroupIndexByOffset(scrollTop); const buffer = 2; // 缓冲分组数量 const start = Math.max(0, newStart - buffer); const end = Math.min(this.groupOrder.length - 1, newStart + buffer); if (start !== this.currentStart || end !== this.currentEnd) { this.currentStart = start; this.currentEnd = end; this.setData({ renderGroups: this.groupOrder.slice(start, end + 1) }); } }

这里一个关键点是findGroupIndexByOffset怎么实现。我维护了一个分组偏移量数组groupOffsets,每个分组的 top 和 bottom 在getBoundingClientRect数据变化时批量计算一次并缓存。滚动时用二分查找定位当前分组,避免线性遍历。

这个方案在三千条数据下,一次渲染的分组数量只有几个,页面节点数骤降,滚动流畅度得到了质的改善。要注意的是,分组高度不是固定的,因为每个分组的城市数量不同,所以必须结合城市数量来估算分组高度。我用的公式是:分组高度 = 分组标题高度(约 80rpx)+ 分组内城市数量 * 单个城市行高(约 96rpx),再乘一个适配系数。这个计算要放在数据预处理阶段同步完成。

3.2 用 field 路径精准 setData

小程序的setData有一个特性:它支持用字段路径做局部更新。很多人忽略了这个能力,习惯每次更新都重新setData一个完整对象,这是性能杀手。

我以前的做法是:

this.setData({ list: newList }); // 整个数组全量更新

优化后的做法是:

this.setData({ [`renderGroups[${index}].items[${itemIndex}].checked`]: true });

这样逻辑层只会把这条路径对应的变化传输给渲染层。虽然底层实现可能不会真的做到只传输一个字段,但相比全量数组,数据体积和序列化时间都小得多,实测多次点击的卡顿感明显降低。

这里有一个实际开发中容易踩的坑:field路径中的索引必须是数值索引,不能使用wx:for的item对象本身。你需要在 WXML 里给每个节点绑定好>// 用扁平对象模拟 Map,key 是 regionId,value 是 true selectedMap: { "110101": true, "310104": true }

在这个结构下,判断选中态只需要!!this.data.selectedMap[id],时间复杂度 O(1)。新增和取消选中只需要setData一个字段:

this.setData({ [`selectedMap.${regionId}`]: !currentValue });

有的同学会担心setData对 key 为数字的情况处理不好,实际上路径字符串直接用数字 key 是字段名字,小程序的规则是合法变量名,用["110101"]这种写法也可以,但我在项目中统一用>groupState: { "110000": { total: 16, selected: 0 }, "310000": { total: 21, selected: 3 } }

每次点击一个城市,只需要更新它所属分组的selected计数:

const groupId = currentItem.groupId; const nextSelected = currentValue ? 1 : -1; this.setData({ [`groupState.${groupId}.selected`]: this.data.groupState[groupId].selected + nextSelected });

然后根据这个分组selected与total的关系,实时推导出渲染状态:

function getGroupCheckedState(selected, total) { if (selected === 0) return 0; // 未选 if (selected === total) return 2; // 全选 return 1; // 半选 }

这套逻辑让全选半选的计算量从 O(n) 降到了 O(1),而且每次更新只传一个数字字段,性能优势非常明显。我强烈推荐用这种方式,不要在模板里用wx:if遍历判断,那是性能黑洞。

4.3 跨组件状态同步

在实际业务里,城市多选组件一般不是孤立的,下面通常有"已选城市标签区""清空按钮""确定按钮",还可能有一个专门展示已选城市的底部弹层。这些区域需要同步选中状态。

我是通过小程序自定义组件加一个轻量事件中心来做同步:

const citySelectBus = new EventBus(); // 简单的事件总线实现 // 点击选中后触发 citySelectBus.emit("city-selected-changed", { selectedMap, groupState }); // 已选城市标签区监听 citySelectBus.on("city-selected-changed", this.handleSelectedChange);

不过也要提醒一句,事件总线在小程序里不能滥用。如果只是页面内几个元素的联动,更推荐把选中状态提升到页面data中,通过properties传给各个子组件。只有当组件层级很深、兄弟组件平级很多的时候才用事件总线。我项目里是因为"弹层"是单独的自定义组件,和主列表同级,才引入了这个方案。

还有一种情况要考虑:用户从"已选城市标签区"点某个标签的删除按钮,需要反向取消主列表中对应城市的选中态。这时候标签区不能直接改主列表的数据,而是通过事件总线发出city-remove: { regionId },由主列表组件内部决定如何更新selectedMap和groupState。核心原则是:数据只有一份,所有修改都走同一个入口,不允许出现"列表里显示已选,但标签区已经不显示"的割裂状态。

5. 右侧字母索引与滚动联动实现

5.1 用 scroll-into-view 做分组跳转

字母索引的跳转逻辑相对简单,利用scroll-view的scroll-into-view属性,给每个分组设置一个id:

<scroll-view scroll-y scroll-into-view="{{scrollIntoViewId}}" scroll-with-animation bindscroll="onScroll"> <view wx:for="{{renderGroups}}" wx:key="id" id="group-{{item.id}}"> <view class="group-title">{{item.groupName}}</view> <view wx:for="{{item.items}}" wx:key="regionId" class="city-item"> ... </view> </view> </scroll-view>

点击右侧字母A时:

onLetterTap(e) { const letter = e.currentTarget.dataset.letter; const groupId = this.letterToGroupId[letter]; // 预处理时映射 if (groupId) { this.setData({ scrollIntoViewId: `group-${groupId}` }); } }

这里有个细节,第一次点击字母跳转后,scrollIntoViewId如果不变,第二次点击同一字母不会触发滚动。我处理的办法是在点击后延迟重置为空字符串:

setTimeout(() => { this.setData({ scrollIntoViewId: "" }); }, 300);

一个小坑,不处理的话用户会觉得"这个字母点了没反应"。

5.2 反向联动:滚动时高亮当前字母

光点击跳转还不够,用户滚动列表时,右侧字母索引要跟着高亮,这是提升体验的关键。实现方案是在bindscroll回调中,根据当前的scrollTop结合分组偏移量,计算出当前处于哪个分组,再映射到字母,更新高亮状态。

我遇到的问题是scroll事件触发频率非常高,如果每次触发都setData高亮字母,会造成额外的性能开销。解决办法有两个,二者我都用过:

一是加节流阀,只有当前字母变化时才更新:

const currentLetter = this.computeCurrentLetter(scrollTop); if (currentLetter !== this.data.activeLetter) { this.setData({ activeLetter: currentLetter }); }

二是把高亮字母做成动态 class,利用 CSS 减少渲染压力。实际上两者结合效果最好。这里没有太深的技术含量,关键是别在滚动回调里做重逻辑,只做比较和轻量赋值。

5.3 右侧字母触控的精度问题

字母索引区域在手机上宽度一般就 40-60 像素,手指去点很容易误触。我初版用的是tap事件,测试发现点击字母边缘时经常跳错分组,或者没触发。

改进方案是把字母索引区域改为touchstart+touchmove联动,在触摸移动过程中连续切换字母:

onLetterTouchMove(e) { const touch = e.touches[0]; const index = computeLetterIndexByPosition(touch.clientY); if (index !== this.currentTouchIndex) { this.currentTouchIndex = index; const letter = this.letterList[index]; this.jumpToLetter(letter); } }

computeLetterIndexByPosition需要拿到字母索引容器的高度和每个字母的平均高度,用clientY减去容器top再除以itemHeight得出索引。这种方法在连续滑动时体验非常顺滑,很多成熟 App 的城市选择器都是这么做的。

提示:字母索引区域的触摸事件要同时处理touchstart、touchmove、touchend三个生命周期,避免用户手指滑动到列表区域后还在持续触发跳转。我最终的方案是触摸开始后给容器加一个透明遮罩层,直到touchend才移除,防止误触到列表。

6. 搜索过滤的细节处理

6.1 防抖与检索时机

城市多选组件里的搜索框,输入时实时匹配。如果每次input都触发过滤逻辑,且数据量又大,输入过程中会出现明显卡顿。我给输入事件加了一个 150ms 的防抖:

onSearchInput(e) { const keyword = e.detail.value.trim(); clearTimeout(this.searchTimer); this.searchTimer = setTimeout(() => { this.performSearch(keyword); }, 150); }

防抖时间我试过 100ms、200ms,150ms 是体验和响应速度的相对平衡点。太短会频繁触发,太长用户感觉延迟。当然这也取决于你的数据量,三千条数据 150ms 是够的。

还有一个容易被忽略的点:搜索时要暂停滚动联动。因为在搜索状态下,列表的分组结构已经被改写成"匹配结果分组",这时候再滚动更新字母高亮,会出现字母和内容对不上的诡异现象。我的做法是在搜索时将isSearching置为true,滚动回调里判断到这个状态就跳过联动逻辑。

6.2 搜索结果的即时分组

搜索结果的呈现方式有两种常见方案。一种是完全平铺,所有匹配城市打到一个列表里,另一种是按城市原分组重新组织。我项目里产品要求保留分组层级,所以采取第二种。

实现思路其实很直接:用预先构建好的扁平数据,对每条记录做indexOf匹配,命中就把它所在的原始分组标记为"待显示",最后把命中的城市按原分组顺序输出。

performSearch(keyword) { const kw = keyword.toLowerCase(); const matchGroupMap = {}; const matchedItems = []; this.flatList.forEach((item, index) => { if (item.searchText.toLowerCase().indexOf(kw) > -1) { const groupId = item.depth === 2 ? item.parentGroupId : item.groupId; if (!matchGroupMap[groupId]) { matchGroupMap[groupId] = []; } matchGroupMap[groupId].push(index); } }); // 重组渲染数据 const renderGroups = []; Object.keys(matchGroupMap).forEach((groupId) => { const group = this.groupInfoMap[groupId]; renderGroups.push({ id: groupId, groupName: group.groupName, items: matchGroupMap[groupId].map(i => this.flatList[i]) }); }); this.setData({ renderGroups: renderGroups, isSearching: true }); }

这版代码有个性能点要注意:searchText字段我在预处理时已经拼好了,比如"北京|beijing|bj|110100",这样一次性indexOf匹配拼音全拼、拼音首字母、中文名、行政区代码。否则用户输入"bj"还要现场算拼音,完全来不及。

6.3 空状态与选中态回显

搜索没有结果时,显示一个空状态提示,这个很容易实现,但更重要的是:搜索状态下用户勾选城市的选中状态,要和正常列表共用同一份selectedMap。因为renderGroups里的checked并不是真实状态,而是根据selectedMap计算出来的展示值。

我采用的做法是禁用 WXML 里的属性绑定,让渲染层始终从数据层读取最新状态:

<view class="city-item {{selectedMap[item.regionId] ? 'checked' : ''}}">

selectedMap是全局唯一的数据源,正常列表渲染出来的checked也是通过它计算。这样搜索切回正常列表时,状态天然一致,不会出现"搜索时选的项,回到列表不见了"的闹心问题。

这个坑很多人跳过。我初版的时候,选中状态是存在renderGroups的item里的,搜索一触发,重组了renderGroups,之前存的选中状态被覆盖,选中的城市全丢了。改成全局selectedMap后这个问题彻底消失。

7. 组件交互细节与参数设计

7.1 对外暴露的参数配置

这个组件最终作为自定义组件对外提供,参数设计也很重要。我参考了常见城市选择器组件的设计,把参数收敛成几个 key:

参数名类型默认值说明
valueArray[]初始选中的 regionId 列表
maxCountNumber0最大可选数量,0 表示不限
showLetterSidebarBooleantrue是否显示右侧字母索引
showSearchBarBooleantrue是否显示搜索框
placeholderString"请输入城市名称"搜索框占位文案

maxCount是一个容易被忽略的限制。有的业务要求最多选 5 个城市,那么当选中数量达到上限时,未选中的城市行应该置灰、不可点击。这个逻辑放在组件内部处理,业务方只需关注数量限制本身。

还有一个参数是onlyCity,可以切换组件是"省市区三级选择"还是"只到城市级"。城市级数据量只有几百条,有些性能优化其实可以简化。但这种参数化设计的好处是:同一个组件,不同业务场景直接复用。

7.2 回显与初始值处理

多选组件的回显场景很常见:用户之前选过城市,重新打开页面时需要回显。组件内部在attached生命周期里根据value初始化selectedMap和groupState:

attached() { this.initFromValue(this.data.value); }

initFromValue需要做两件事:一是把传入的regionId数组转成selectedMap,二是根据选中的城市重新计算它所属分组的selected计数。因为groupState是组件内部的派生数据,必须由初始化逻辑同步生成,否则回显时全选半选状态就是错的。

这里有个容易踩的坑:value可能是异步从接口返回的,组件attached时value还是空数组。处理办法是观察value属性:

observers: { "value": function(newVal) { if (newVal && newVal.length > 0) { this.initFromValue(newVal); } } }

同时要注意initFromValue必须幂等,也就是多次调用结果一致。我因为在observers和attached里都调用了它,一开始没有做防重,导致重复初始化把用户的勾选状态清掉了,排查了半天。

7.3 清空与确定流程

组件底部通常有"已选城市"预览和操作按钮。清空操作最简单,直接重置selectedMap和groupState。确定操作则要把selectedMap的 keys 转为数组,通过事件抛出去:

confirmSelection() { const selectedIds = Object.keys(this.data.selectedMap).filter(key => this.data.selectedMap[key]); this.triggerEvent("confirm", { selectedIds, selectedItems: this.getSelectedItems(selectedIds) }); citySelectBus.emit("city-select-done"); }

我把"确认"这个动作放在了组件内部控制,但实际业务中确定按钮可能在父级页面,不在这个组件区域。这种情况下,不要依赖组件内部按钮,而是提供一个getSelection()方法供父级调用。组件内部按钮只作为可选的 UI 形态。

我见过不少项目把清空、确定按钮直接写死在组件里,后续产品需求一变,要改成"弹出层外部按钮触达",组件就只能推翻重写。参数和事件设计得灵活一点,能给未来省很多事。

8. 常见问题速查与排查思路

8.1 滚动卡顿的定位方法

如果优化后滚动还是卡,先别猜,重点是定位问题出在渲染层还是逻辑层。我常用的排查手段是在开发者工具的性能面板里录一段滚动操作,看"主线程"和"渲染线程"的时间分配。

如果主线程的setData调用时间很长,说明逻辑层传输数据量太大,去看setData的数据体积,是不是又全量更新了数组。如果渲染线程的样式计算和布局时间很长,说明 WXML 节点数还是太多,去看当前渲染的节点树,是不是把不可见的分组也渲染进来了。

一个非常有效的排查技巧:把renderGroups的数据用console.log打出来,看一次滚动后它变化了几次。正常优化后的组件,一次快速滑动到头,renderGroups的更新次数应该只有几次,而不是几百次。如果一次滑动触发几十次更新,说明节流和可视区判断逻辑有问题。

8.2 选中状态错乱与 key 冲突

选中状态错乱,十次有七次是wx:key的问题。用数组索引当wx:key的组件,一旦列表顺序变化,框架复用节点时会把旧节点的checked属性带到新节点上。我见过最离谱的一次,用户选了一个城市,结果下一个城市也自动勾上了,就是因为两个节点的 key 都重复。

解决方案很简单:wx:key用regionId,保证每个节点 key 唯一且稳定。如果数据结构里没有唯一字段,用item.id,再不行就在预处理时生成一个uid字段。

另外checked这个属性名要特别注意,它本身是原生表单组件(如checkbox)的属性,如果业务组件里的data也用checked,在事件处理里容易混淆。我最后统一改成了isSelected,语义也更清晰。

8.3 吸顶标题在 iOS 上的兼容问题

position: sticky在 iOS 的某些小程序 WebView 环境表现不稳定,具体症状是吸顶标题会闪动或者不跟随。如果遇到这个问题,我的替代方案是用position: fixed手动模拟吸顶。

具体做法是在scroll-view内不再依赖 sticky,而是额外渲染一个"当前分组标题"的固定层,滚动时根据当前分组渲染标题文字。这个固定层渲染在滚动区和字母索引之间,视觉上效果一致,但完全避开了 sticky 的兼容性坑。

8.4 内存与页面销毁

城市选择器如果作为自定义组件常驻在页面里,页面onUnload时要记得解除事件总线的绑定,否则会出现内存泄漏。尤其用了citySelectBus.on监听的地方,在组件detached生命周期里要对称地off掉。

这条提醒看似基础,但实际项目中我排查过不下三次因为事件监听未移除导致的状态错乱——组件已经销毁了,但事件总线里还挂着它的回调,新开页面时回调触发,去setData一个已经不存在的组件实例,页面直接报错。

8.5 常见问题速查表

症状可能原因排查方向
滚动卡顿setData 全量更新或节点数过多性能面板看主线程耗时,检查 renderGroups 数量
选中状态错乱wx:key 用了数组索引改用唯一 regionId 作 key
字母索引点击跳转不准tap 事件误触改用 touchstart / touchmove
搜索后选中项丢失选中状态存在 renderGroups 里统一用全局 selectedMap 作为数据源
iOS 吸顶标题闪动sticky 兼容性问题用 fixed 模拟吸顶标题
页面销毁后仍报 setData事件总线监听未移除detached 生命周期里 off 掉

9. 最终效果与几点体会

9.1 实测数据与效果

跑完这轮优化,我这边实测的数据是:三千两百多条城市数据,冷启动初始化耗时从原来的八百多毫秒降到两百毫秒出头;连续点击城市勾选,单次更新卡顿从肉眼可感知降到几乎无感;滚动帧率在开发者工具和真机上都很稳定,不再掉帧。

整个优化没有用什么冷门黑科技,就是把数据预先铺平、渲染按需裁剪、状态统一收敛、更新精准到位。这几件事每一项单拿出来都不算复杂,但组合在一起,效果是质变的。

9.2 核心体会与建议

整个过程回过头来看,最核心的几点体会我列一下,供后来者参考。

第一,做复杂组件之前,花两小时设计数据结构,远比写代码时再补来得划算。扁平化、分组索引、计数器的组合,是这个组件所有优化和功能的地基。

第二,小程序的性能优化,本质上是在管理setData的数据量。全量更新是万恶之源,能用字段路径局部更新就绝不传整数组。

第三,组件设计要预留扩展空间。参数、事件、数据源分离,业务方的需求变化才接得住。

最后分享一个实际调试中的小技巧:给搜索输入框加防抖时,不要只盯着输入框本身,要考虑虚拟键盘弹出导致scroll-view高度变化的问题。我在项目中就遇到过键盘顶起底部按钮,导致滚动容器高度变了,分组定位突然不准的情况。解决办法是在onFocus/onBlur里重新计算滚动容器高度,并重置可视区缓存。

这个组件的优化核心就一句话:把数据结构做对、把渲染量做小、把状态源做统一。希望这篇记录能帮到正在被城市多选组件折磨的你。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进