资讯详情 微信小程序城市多选分组组件优化实践:从卡顿到流畅
📅 2026/10/16 11:32:12
微信小程序里的城市多选分组组件说实话一开始我并没觉得有多难。无非就是左侧省份列表、右侧城市列表加个多选和全选。直到真实需求砸过来——要支持省市区三级数据量三千多条还要按字母索引快速定位、支持搜索、多选结果要能回显。我上手第一版的时候还信心满满结果真机一测滚动掉帧、内存飙升、选中状态还会错乱整个人都被这个组件按在地上摩擦。这篇文章就把我优化这个城市多选分组组件的完整过程、踩过的坑和最终沉淀下来的方案一次性讲清楚。这个组件适合谁看凡是小程序项目里要做城市多选、省市区联动、或者任何分组长列表多选组合需求的前端开发都能直接抄思路。不管是数据量几百条还是几千条核心的优化思想完全一致。我会把数据设计、渲染优化、状态管理、搜索联动这些环节拆开讲配合代码说明方便你直接搬到自己的项目里。1. 这个组件为什么会卡先定位问题再谈优化1.1 初版实现的三宗罪我当时的第一版实现非常朴素把省-市-区三层数据一次性塞给setDataWXML 里用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 里给每个节点绑定好>// 用扁平对象模拟 Mapkey 是 regionIdvalue 是 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属性给每个分组设置一个idscroll-view scroll-y scroll-into-view{{scrollIntoViewId}} scroll-with-animation bindscrollonScroll view wx:for{{renderGroups}} wx:keyid idgroup-{{item.id}} view classgroup-title{{item.groupName}}/view view wx:for{{item.items}} wx:keyregionId classcity-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事件测试发现点击字母边缘时经常跳错分组或者没触发。改进方案是把字母索引区域改为touchstarttouchmove联动在触摸移动过程中连续切换字母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、200ms150ms 是体验和响应速度的相对平衡点。太短会频繁触发太长用户感觉延迟。当然这也取决于你的数据量三千条数据 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 classcity-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和groupStateattached() { 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里重新计算滚动容器高度并重置可视区缓存。这个组件的优化核心就一句话把数据结构做对、把渲染量做小、把状态源做统一。希望这篇记录能帮到正在被城市多选组件折磨的你。