原生小程序省市区三级联动:基于picker-view的自定义组件实战
做了这么多年小程序我最怕看到有人把省市区三级联动做成三个并列的下拉框或者直接拿个多列选择器 all in.不丑但用起来真的难受。原生小程序里其实没有现成的省市区联动组件要用就得自己写。这活儿我干过好几次踩了不少坑今天把一套基于原生小程序的自定义三级联动省市区控件完整拆给你看。这套方案不需要任何第三方库纯 WXML WXSS JavaScript用的是小程序原生提供的 picker-view 和 picker-view-column从数据结构到联动逻辑再到真机适配一条线全捋清楚。适合正在做表单类页面、收货地址、户籍信息采集这类场景的开发者新手能直接抄作业写过一点的人看完能明白为什么我之前那么抵触把三列平铺开。1. 需求拆解为什么非得自己造轮子1.1 原生 picker 的局限在哪小程序官方其实给了一个picker组件里面有一种模式叫modemultiSelector也就是多列选择器。理论上你可以把省市区的数据源直接塞进去看起来好像够用了。但等你真把数据喂进去问题就全冒出来了。第一多列选择器的列与列之间默认是独立的你需要自己维护columnchange事件来改数据源官方文档只给了最基本的写法联动逻辑要自己处理。你要是只做一版能用那确实可以但联动切换的数据刷新、滚动位置复位、异步加载这些细节最终都要写成你的业务代码跟自定义控件的工作量差别不大。第二多列选择器的弹层是系统自带的样式几乎不能动。项目设计稿里如果要求弹层底部按钮是“确定”和“重置”或者要求中间选择区高度只有 150px你就要开始疯狂写覆盖样式。哪怕你改得动不同机型的表现也不一样容易出现“开发者工具里好好的一到真机上就错位”的尴尬。第三多列选择器默认是底部弹层但很多业务场景需要的是内嵌在表单中间、直接展示三个滚轮的控件。比如很多报名页、信息登记页省市区就安安静静躺在页面上而不是弹出来。这种需求原生组件根本接不住。1.2 这套自定义控件的适用边界我做的这套自定义控件核心是借助picker-view它是真正可以在页面上渲染出来的滚轮选择器三个picker-view-column各占一列实现效果跟 iOS 上那种滚轮一模一样但数据、样式、交互全掌握在自己手里。它适合的场景我也是认真做过判断的页面需要内嵌展示省市区而不是弹层选择。交互上要求选择省之后市自动定位到第一个区跟着联动不是三列各选各的。样式要跟随项目主题比如选中线颜色、文字字号、滚轮高度。数据需要动态过滤比如某些地区不展示或者根据业务加可选项。如果只是临时表单里要用一个基础省市区弹层用系统 picker 的多列模式改一改也能顶没必要强行套我的方案。但如果你对交互、样式、数据粒度有自定义要求这套东西就是你要的。1.3 核心方案一句话版本一句话说清楚这套控件在做什么用三个picker-view-column模拟滚动选择前两列变化时自动重置后一列的数据索引保证任何时候显示的市级和区级数据与当前省级选择是匹配的。省市区数据用同一份全量 JSON按省、市、区三层嵌套关系维护索引数据量不大也不需要接口逐个联动加载一个页面加载完就能就地完成全部分级过滤。数据过滤和索引重置是联动控件的灵魂我会在后面的章节里把这两个环节掰开细讲。2. 数据准备联动控件的脚基必须牢2.1 省市区数据结构怎么设计“三级联动”这个词人人都挂在嘴边但真动手整理数据的时候很多人第一反应就是去网上找一份“省市区JSON”。数据本身不难找但直接用是有风险的。不同来源的 JSON 字段名不一致是小事关键是有没有按层级嵌套还是全部平铺开。我推荐的结构是这样的[ { code: 330000, name: 浙江省, children: [ { code: 330100, name: 杭州市, children: [ { code: 330102, name: 上城区 }, { code: 330105, name: 拱墅区 } ] }, { code: 330200, name: 宁波市, children: [] } ] } ]如果用这种嵌套结构联动逻辑会非常直观选省就是定位省级列表的第几项选市就是从该省children里取数组区同理。不用写任何层级匹配的算法纯数组索引操作。但要提前声明一个坑很多网上的省市区 JSON 是老的行政区划有些区已经合并或撤销了。比如某些市下面已经没有“XX新区”之类的旧区划如果你做的是跟物流、政务相关的业务建议以最新的行政区划标准为准实在不确定就从官方发布渠道或比较靠谱的静态库拉这一点影响的是业务正确性不是控件本身。2.2 索引坐标系的设计三级联动控件在工作中处理的是三个数组索引省索引、市索引、区索引。我把它们统称为“坐标”。data: { provinces: [], // 全部省 cities: [], // 当前省下所有市 districts: [], // 当前市下所有区 provinceIndex: 0, cityIndex: 0, districtIndex: 0 }理解这套坐标的关键是cities和districts不是全量的城市和区县数组而是依赖上一级索引临时过滤出来的。省索引变了cities就要替换成该省下的 children市索引变了districts就要替换成该市下的 children。有些人会进入一个误区在一份全量数组里存所有市、所有区然后每次根据 code 匹配显示。不是不行但对小程序这种体量的前端来说纯索引操作的代码更短、不容易出错而且省去大量遍历查找。我用一个简单类比来解释为什么要依赖索引而不是依赖 code 匹配你在书店里找书如果用“按分类和书架编号定位”你只需要走到对应书架再从那个书架上拿书这是索引方式。如果你要“报一个书名然后后台全书店扫描”这就是全量匹配。显然前者高效也不容易因为同名不同区而报错。2.3 数据初始化页面加载时控件要做的第一件事就是把省市区三级坐标设成默认值。默认值不一定是 0/0/0很多业务要回显已有地址比如编辑用户资料时已经保存了“浙江省/杭州市/西湖区”你不能一打开就停在“北京市/市辖区/东城区”。回显逻辑我放在后面的章节细说这里先说初始化逻辑onLoad() { this.initRegionData(this.data.regionTree); } initRegionData(tree) { const provinces tree; const firstProvince provinces[0]; const cities firstProvince.children || []; const firstCity cities[0]; const districts firstCity.children || []; this.setData({ provinces, cities, districts, provinceIndex: 0, cityIndex: 0, districtIndex: 0 }); }大多数人会忽略一个问题有些省份下直接就是县级市可能没有地级市这一层有些市下面也没有区只有街道乡镇。如果你把children数组为空的情况直接放进去第二列或第三列会出现空白滚轮体验极差。我在后面的兼容策略里会讲到怎么处理这类数据稀疏问题。3. 核心交互picker-view 联动实现3.1 WXML 骨架picker-view是官方组件它的高度必须通过样式显式指定不能靠内容撑开这是新手最容易踩的点。我的基础结构是这样的picker-view classregion-picker :valuepickValues changeonRegionChange picker-view-column view wx:for{{provinces}} wx:keycode classregion-column-item {{item.code selectedProvince.code ? active : }} {{item.name}}/view /picker-view-column picker-view-column view wx:for{{cities}} wx:keycode classregion-column-item {{item.code selectedCity.code ? active : }} {{item.name}}/view /picker-view-column picker-view-column view wx:for{{districts}} wx:keycode classregion-column-item {{item.code selectedDistrict.code ? active : }} {{item.name}}/view /picker-view-column /picker-view这里我把value用一个数组pickValues绑起来里面依次是省索引、市索引、区索引。官方文档里写的是value为数组每项对应一列当前选中索引数组长度要和列数一致。有一点需要特别提醒picker-view的bindchange事件回调event.detail.value返回的同样是当前三列的索引数组。修改这个数组可以让滚轮跳到指定位置但你不能在滚动过程中频繁 setData 这个数组否则会跟用户的手势打架。后面我要讲一个真实踩过的坑。3.2 联动核心逻辑联动逻辑都集中在onRegionChange里。这个事件在用户滑动停止时触发也可能在某些场景下被系统自动触发每一次回调都带着三列当前索引。onRegionChange(e) { const [provinceIndex, cityIndex, districtIndex] e.detail.value; const { provinces, cities, districts } this.data; // 省变化了市和区都要重置 const nextProvince provinces[provinceIndex]; const nextCities nextProvince ? (nextProvince.children || []) : []; const nextCity nextCities[cityIndex] || nextCities[0] || {}; const nextDistricts nextCity.children || []; const nextDistrict nextDistricts[districtIndex] || nextDistricts[0] || {}; this.setData({ provinceIndex, cityIndex: nextCity ? nextCities.indexOf(nextCity) : 0, districtIndex: nextDistrict ? nextDistricts.indexOf(nextDistrict) : 0, cities: nextCities, districts: nextDistricts, selectedProvince: nextProvince, selectedCity: nextCity, selectedDistrict: nextDistrict }); }有人会问这里明明是三列索引都能获取到为什么我要说市列和区列没变时就不重置呢这里隐藏着一个非常关键的设计决策用户滑动的是一列但change事件会同时上报三列的值。如果你每次拿到事件就把三列数据全重新 setData 一遍性能损耗倒是其次更麻烦的是用户正准备滑第二列你突然把第二列重置了体验就毁了。所以我建议的做法是省索引变化时强制重置市索引和区索引数据源也同步替换市索引变化时只替换区数据源和区索引区索引变化时只更新选中的区对象。要真正区分是哪一列在滚动需要自己维护一个临时变量记录上一次滚动结束时的索引组合。3.3 区分联动的触发源这里我给你一个可以落地的方案。在触摸开始的事件touchstart里记录一下当前pickValues的值在change的时候对比新值和旧值onTouchStart() { this._lastValues [...this.data.pickValues]; } onRegionChange(e) { const newValues e.detail.value; const oldValues this._lastValues || newValues; const [provinceIndex, cityIndex, districtIndex] newValues; if (provinceIndex ! oldValues[0]) { // 省级变动重置市和区 } else if (cityIndex ! oldValues[1]) { // 市级变动重置区 } else { // 区级变动只更新选中项 } this._lastValues newValues; }这算是我在实战中自己加的优化。官方示例里根本没有这一步直接把三列全量更新滚动起来会明显感觉到卡顿和跳列。尤其在区县数量很多的省份一次全量 setData 的渲染压力还是有的后面性能章节会展开说。注意touchstart不一定每次都触发比如用户先滑到一半又滑回去change依然会触发所以我这个_lastValues只是辅助判断不能依赖它做严格的联动状态机。更严谨的状态记录是每次change结束后同步同时用时间戳区分序列但对大多数业务场景来说这个方案已经足够顺滑。4. 实际开发中的适配与避坑4.1 真机上的样式差异同样是picker-view在 iOS 和 Android 上的表现差异非常明显。iOS 上滚轮默认有弹性效果滑动非常跟手Android 一些机型滚动到底会多一点阻尼但总体可控。最容易出问题的是选中线的位置。picker-view官方给了一个indicator-style属性可以设置中间选中线的样式。我的经验是别依赖默认的选中线干脆自己画一个覆盖层.region-picker-wrap { position: relative; height: 400rpx; } .region-picker { width: 100%; height: 400rpx; } .picker-indicator { position: absolute; left: 0; right: 0; top: 200rpx; height: 40rpx; border-top: 1rpx solid #e5e5e5; border-bottom: 1rpx solid #e5e5e5; pointer-events: none; box-sizing: border-box; }为什么要用覆盖层因为不同机型的指示条高度不一样你按照设计稿画一个 40rpx 的指示条在部分安卓机上会跟文字对不齐。自己画覆盖层就完全可控而且可以随便加阴影、渐变、圆角。选中文字高亮效果不要在滚轮视图外做正常在每个picker-view-column的 item 上用activeclass 控制即可但注意要配合指示条的位置来设计。我在一个项目里试过给 item 加 transition 动画结果滑到一半文字颜色突然虚掉体验很怪。后来就只在文字大小上做区分选中项字号加大颜色变深其他项统一灰色不搞动画。4.2 滚动掉帧和性能优化小程序 setData 的性能瓶颈是人人都知道的老话题三级联动控件特别容易触发这个问题因为一次滚动可能会刷新一整个列表的数据源。我做过的优化手段有以下几条按优先级排序联动数据源变化时只在对应列变化时才更新对应数据片段不做全量 setData。cities 和 districts 用独立字段更新而不是塞进一个大对象里整个 setData。数据量大的省份比如四川、广东区县数量较多滚动时只更新当前高亮文字不在滚动过程中做样式计算。设置合理的节流change事件本身不是高频事件但如果用户快速甩动滚轮一秒钟可能会触发四五次可以把 setData 统一放到同一个事件循环里合并。我还干过一件更省事的优化第三列区县如果超过 80 个渲染的时候默认只渲染前 100 个超过的部分等滚动快到尾部再补渲染。这种情况在新疆、内蒙古这种地级市下面区县特别多的场景会碰到虽然 100 个项目在小程序里不至于内存爆掉但 DOM 节点多了滚轮滑动时帧率下降是实打实的。4.3 默认值回显的三种场景回显这个问题真实项目里一定躲不掉。我分成了三种场景每种处理方式不一样。第一种新增页面没有初始值打开就是 0/0/0直接用初始数据。第二种编辑页面后端返回了一个区划 code比如“330102”你需要通过这个 code 反查省、市、区索引。反查逻辑不要写得太憨别每次都从省列表三层遍历一遍可以在初始化的时候构建一份 code 到索引路径的映射表function buildIndexMap(tree) { const map {}; tree.forEach((province, pIndex) { map[province.code] [pIndex]; (province.children || []).forEach((city, cIndex) { map[city.code] [pIndex, cIndex]; (city.children || []).forEach((district, dIndex) { map[district.code] [pIndex, cIndex, dIndex]; }); }); }); return map; }第三类后端返回的是“浙江省/杭州市/西湖区”这种名称而不是 code。这时候反向查找的代码就麻烦一点因为同名区县很多必须带上上级城市一起去查。我建议无论如何都让后端返 code如果实在只能返名称那就用“省级名称”加“市级名称”双重条件去过滤只匹配第一个结果同时做一个兜底匹配不到就落到默认值 0/0/0。4.4 数据稀疏的处理策略开头我提过有些省份下面没有地级市有些市下面没有区。这在行政区划上确实存在但 UI 上不能真让某一列全空。我有两个处理策略看业务而定。策略A跳过空列。如果某个省没有市级节点那市这一列就只放一个和省同名的占位项区同理。适合简单页面。策略B把有下级的数据压缩到同一列。比如某个省下面直接是几十个县我就会把这些县的数据放到市级列里命名成“XX县省直辖”区级列放一个“-”占位。这种方式维护成本稍高但信息更准确适合做物流和政务相关的场景。大多数人做的都是一般业务表单我建议用策略A代码简单不引入额外判断用户看一眼也不会有歧义。5. 弹层封装与扩展思路5.1 从内嵌到弹层只需要一层壳前面讲的都是页面内嵌的写法但很多项目最后要的还是点击输入框弹出一个省市区选择层。这个改造毫不费力只需要在外层包一层遮罩和弹层容器view classregion-mask wx:if{{visible}} bindtapclose/view view classregion-pop wx:if{{visible}} view classregion-header text classregion-cancel bindtapclose取消/text text classregion-confirm bindtapconfirm确定/text /view view classregion-body !-- 上面写的 picker-view 结构原样放进来 -- /view /view弹层的关闭和确认事件单独处理确认的时候把当前选中的省市区对象和 code 回传给业务页面取消则不覆盖原值。这里有个细节弹层显示期间用户可能滚动了好几列但中途又取消了你要保证取消后下次打开依然是原来的值而不是滚动到一半的值。实现上就是在close时重新初始化一遍坐标到进入弹层前的快照confirm时才更新快照。5.2 样式定制的颗粒度样式定制我在真实项目里总结出几个常用参数滚轮高度、列数间距、指示条样式、文字大小、选中态颜色。基础项目我把它们做成 data 里的配置项比如data: { regionConfig: { itemHeight: 40, // 单位 rpx indicatorHeight: 40, fontSize: 32, activeColor: #333333, inactiveColor: #999999 } }然后在 WXML 上通过 style 绑定设置。这样一套控件可以同时用在多个页面不用每个页面都重新写样式只要穿不同主题就行。不过picker-view-column内部的样式继承比较特殊它内部的 item 高度需要和picker-view的计算逻辑匹配具体表现是picker-view可视高度除以 item 高度等于可显示的选项数。如果你设置的 itemHeight 和实际样式不一致滚轮就会出现错位这是很多自定义样式的人栽过的地方。我一般保证picker-view高度等于 itemHeight 的 3 倍或 5 倍视觉上比较自然。5.3 换成其他级联数据也能用这套控件不只能做省市区换成其他有父子层级的数据完全可以复用。我试过用它做“品牌-系列-型号”“年级-班级-科目”“组织架构-分部-小组”只需要保证数据结构是 children 嵌套并且每层有 code 和 name 字段联动逻辑一行都不用改。这里唯一的改动是字段配置化把children字段名、name字段名、code字段名都抽成 config 里的属性代码可以不侵入业务。比如有的后端给的字段叫subList和label你做一层适配就能直接套用。这也解释了为什么我在前面反复强调数据结构设计控件本身是纯逻辑真正决定它能用得多顺的是数据形态。数据统一成嵌套数组后级联控件就是一个通用组件。6. 常见问题与调试实录我把自己在这个控件上踩过的坑整理成了一个速查表每个问题都有对应的解决思路。开发时遇到某个现象直接按表索骥能省不少时间。问题现象原因分析解决方案滚动后页面不显示选中线picker-view高度和 item 高度不匹配检查picker-view高度是否等于 itemHeight 的整数倍省列变了市列数据也变了但滚轮位置还是旧的没有同步更新pickValues数组联动时把新的三列索引一起写入pickValuesAndroid 上滚动到首尾时白色空区域过多没有关闭弹性效果给picker-view设置enable-flex或用样式控制数据量大的省份滑动掉帧一列变化时三列数据全量 setData分列更新数据源只有对应列才 setData回显时区划 code 找不到旧区划已合并或数据源过期更新省市区数据源回显前做默认值兜底弹层取消后重新打开值被改了关闭时没有恢复快照close时重新初始化进入前的坐标再说一个我在开发中最常遇到但又最容易被忽略的坑在picker-view里给picker-view-column的子项设置wx:key时如果用index作为 key在某些基础库版本下数据源切换后会导致滚动位置识别错乱。推荐统一用业务 code 做 key而且 code 必须保证在同一列内唯一。这一点在省市区这种大量重复名称的数据里尤其重要因为同名的市和区太多了比如很多城市都有“开发区”“高新区”。另外一个调试经验也分享给各位开发者工具里看到的滚动行为和真机差异很大主要是惯性手感不同。开发者工具里滚动一下就停了真机上会甩出去很远所以联动触发的频率也不同。尽量在真机上调联动节奏不要只看着工具调试完就交付。// 实际调试时经常用的一段辅助代码快速打印当前三列数据路径 console.log( this.data.selectedProvince?.name, this.data.selectedCity?.name, this.data.selectedDistrict?.name );最后我想说做这种基础组件最忌讳的是写着写着就开始堆逻辑、加各种 flag。三级联动的本质就是“选上层重置下层”数据源按需切换三个索引维护好控件就完成了 80%。剩下的 20% 才是你看得见的坑样式适配、回显、空数据兜底、性能优化。把我的这套骨架拿过去在你自己的项目里跑一遍再按业务微调会比从零开始顺畅得多。我第一次做这个控件花了两天现在再做一个下午能交付这就是沉淀代码的价值。