ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

原生小程序省市区三级联动:基于picker-view的自定义组件实战

原生小程序省市区三级联动:基于picker-view的自定义组件实战

做了这么多年小程序,我最怕看到有人把省市区三级联动做成三个并列的下拉框,或者直接拿个多列选择器 all in.不丑,但用起来真的难受。原生小程序里其实没有现成的省市区联动组件,要用就得自己写。这活儿我干过好几次,踩了不少坑,今天把一套基于原生小程序的自定义三级联动(省市区)控件完整拆给你看。这套方案不需要任何第三方库,纯 WXML + WXSS + JavaScript,用的是小程序原生提供的 picker-view 和 picker-view-column,从数据结构到联动逻辑再到真机适配,一条线全捋清楚。适合正在做表单类页面、收货地址、户籍信息采集这类场景的开发者,新手能直接抄作业,写过一点的人看完能明白为什么我之前那么抵触把三列平铺开。

1. 需求拆解:为什么非得自己造轮子

1.1 原生 picker 的局限在哪

小程序官方其实给了一个picker组件,里面有一种模式叫mode="multiSelector",也就是多列选择器。理论上,你可以把省市区的数据源直接塞进去,看起来好像够用了。但等你真把数据喂进去,问题就全冒出来了。

第一,多列选择器的列与列之间默认是独立的,你需要自己维护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 class="region-picker" :value="pickValues" @change="onRegionChange" > <picker-view-column> <view wx:for="{{provinces}}" wx:key="code" class="region-column-item {{item.code === selectedProvince.code ? 'active' : ''}}" >{{item.name}}</view> </picker-view-column> <picker-view-column> <view wx:for="{{cities}}" wx:key="code" class="region-column-item {{item.code === selectedCity.code ? 'active' : ''}}" >{{item.name}}</view> </picker-view-column> <picker-view-column> <view wx:for="{{districts}}" wx:key="code" class="region-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 class="region-mask" wx:if="{{visible}}" bindtap="close"></view> <view class="region-pop" wx:if="{{visible}}"> <view class="region-header"> <text class="region-cancel" bindtap="close">取消</text> <text class="region-confirm" bindtap="confirm">确定</text> </view> <view class="region-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数组联动时把新的三列索引一起写入pickValues
Android 上滚动到首尾时白色空区域过多没有关闭弹性效果给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% 才是你看得见的坑:样式适配、回显、空数据兜底、性能优化。把我的这套骨架拿过去,在你自己的项目里跑一遍,再按业务微调,会比从零开始顺畅得多。我第一次做这个控件花了两天,现在再做,一个下午能交付,这就是沉淀代码的价值。

RELATED READING

延伸阅读

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