做小程序原生开发的人,迟早会碰到省市区三级联动这个需求。不管是收货地址、身份信息填写还是门店选择,这玩意儿几乎是每家公司的必备组件。我刚入行那会儿也偷懒去搜现成组件,但你会发现市面上的第三方组件要么兼容性有问题,要么样式跟自己的设计稿差别太大没法改,要么带了太多你用不上的功能导致包体虚胖。折腾过几回之后,我干脆自己动手写了一个,用原生picker-view实现,不依赖任何第三方库,代码量不大,逻辑清晰,想怎么改样式就怎么改。这篇文章就把整个实现思路、踩坑记录和最终的完整代码分享出来,希望能帮你省下几小时的调研时间。
1. 为什么不用现成组件,而是自己写
先聊几句大实话。很多人一听到“自制组件”就觉得麻烦,其实关键在于搞明白你要的是什么。市面上的省市区组件,主流做法分两类:一类是模拟滚动选择器,用scroll-view加手写滚动监听,这种实现方式的问题是滚动结束的判定很难做准,经常出现松手之后选项停在不该停的位置;另一类是直接基于原生的picker-view来做,这种相对靠谱,但问题又出在数据结构和扩展性上——大部分现成组件写死了省、市、区三级数据,你想临时加一个“港澳台”或者“海外”选项就得改别人的源码,很痛苦。
再一个隐藏问题:数据源。很多组件从 CDN 拉数据,页面初始化时突然闪一下,或者干脆在小程序审核的环境下请求超时直接白屏。对于电商类的应用场景,地址数据属于强依赖,加载失败用户就没法下单,这后果挺严重的。所以我的结论是:原生picker-view自制组件 + 本地内置数据文件是稳定性和可维护性的最优解。
picker-view本身是微信小程序官方提供的滚动选择器组件,它的特点是支持多列,每一列可以独立滚动,并且能通过value字段控制每一列当前选中的索引。我们做三级联动,本质上就是对三个列的索引变化做事件监听,然后根据当前省市区的关系重新计算下一级的数据列表。这个交互逻辑本来就是官方组件设计来干的活,用它对号入座,没必要自己再造轮子。
2. 数据方案设计与预处理
写三级联动之前,数据结构必须先想清楚,这一步决定了你后续所有逻辑的复杂度。很多新手一上来就搞一个大的嵌套 JSON,然后写递归去遍历,费劲不说,数据量一大渲染还卡。我更推荐的是把数据拆成三个独立的扁平数组:
provinceList:数组,每一项是“省份名”cityList:数组,每一项是“城市名”areaList:数组,每一项是“区县名”
每个数组的索引就是它的唯一 ID。同时维护三个映射关系:
- 省份的索引 → 该省包含的城市索引列表(或者直接存城市数组)
- 城市的索引(全局) → 该市包含的区县索引列表(或者存区县数组)
这个设计的好处是,在picker-view的change事件里,我们只需要根据当前索引去映射表里查出新的子级数组,然后把子级value重置为 0,就能实现联动。省去了任何递归或深拷贝,性能开销极小。
数据源可以直接从公开的省市区 JSON 数据里转出来,转的时候要留个心眼:有的数据源把直辖市的“区”直接挂在城市下面,有的又把“省直辖县级行政区划”这种特殊单位单独列出来,需要做归并处理,不然联动的时候会出现选了一个省,城市列表为空的情况。
预处理脚本一般用 Node.js 跑一遍,把原始数据转成三个 JSON 文件,放到小程序的utils或者data目录里。转化完成后做一个完整性校验,重点是检查三项内容:
- 每个省份是否至少有一个城市
- 每个城市是否至少有一个区县
- 是否存在跨级索引越界的记录
校验通过之后再进仓库。这个预处理过程虽然不起眼,但能帮你避免开发阶段排查半天数据问题的尴尬。
3. 核心联动逻辑实现
联动逻辑主要分为两部分:初始化选中态和滚动响应。
先说初始化。组件挂载时读取provinceList,默认选中第一个省份。然后根据第一个省份的索引,从映射表里拿到它的城市列表,默认选中第一个城市。再根据这个城市的索引,拿区县列表,默认选中第一个区县。这个过程中有一个容易被忽略的细节:三个列表在初始状态下必须都有且至少有一个元素。正常情况下数据源里不会出现空的省份或空的城市,但万一有脏数据呢,我建议在初始化函数里对子级数组做个长度判断,为空时就把索引置为 -1 或者直接显示“暂无数据”,避免数组越界导致的渲染异常。
再看滚动响应。picker-view的bindchange事件返回的event.detail.value是一个长度为 3 的数组,分别对应三列当前的索引。拿到索引之后做判断:
- 如果第一个索引变了,说明省份切换,需要重置
cityIndex = 0,再根据新省份得到城市列表,然后重置areaIndex = 0,再根据第一个城市得到新的区县列表 - 如果第二个索引变了,说明城市切换,需要重置
areaIndex = 0,然后根据新城市得到新的区县列表 - 如果第三个索引变了,说明只是区县切换,那就什么都不用动
复杂度只在第一层和第二层的变化里,第三种情况最简单。整个逻辑用switch-case或者if-else嵌套都能实现,我习惯先判断列位置,再做对应的处理分支。
这里有一个需要说明的点:bindchange事件只有在滚动结束、选中状态改变后才会触发。但在小程序里,用户快速滑动时会先连续触发bindpickstart,然后才触发bindchange。如果数据量大的话,动态切换列数据时会出现瞬间的闪烁,这个问题的处理办法是:在bindpickstart的时候记一个标志位,bindchange触发后先判断该标志位是否为 true,如果是的话等几百毫秒再更新视图数据,避免滚动手感受影响。
picker-view的官方文档提示,列数据长度变化时,当前列的value可能超出新的列长度。例如上一轮城市列表有 20 个城市,选第 19 个,切换省份后新的城市列表只有 5 个,如果我没把value重置为 0,选中项就可能显示空白。所以无论哪个子级数据更新,都要顺带把对应列的value重置为 0,这是我在开发过程中反复踩出来的教训。
4. UI 布局与样式打磨
picker-view的默认样式很朴素,就是三个竖排的滚动列表。实现三级联动时,通常把它们放在同一行,占据屏幕宽度的一定比例。我一般会让三列各占三分之一,然后用 CSSflex布局实现自适应宽度。需要提醒的是:picker-view的height属性是必填的,不管是写死一个数字还是用rpx,不能省略,否则组件高度会塌陷成 0,看起来就像消失了一样。
在内部样式上,picker-view-column的每一项默认是居中的,文本过长时会换行。如果省市区名称都比较长(比如“内蒙古自治区”“大兴安岭地区”这种),建议给每一项加上text-overflow: ellipsis并强制不换行,否则视觉上会很凌乱。这里注意,picker-view-column内的节点不能写border-bottom之类的样式,因为滚动时这些分割线会跟着滚动一起走,观感很差。正确的做法是给picker-view容器本身加一个上下两条固定分割线的背景图或者伪元素,中间一行高亮显示当前选中项。
分割线的颜色推荐用#eee这种浅色系,太深了容易产生视觉压迫感。高亮行背景可以用半透明的rgba(0, 0, 0, 0.03)或者保持纯背景不加底色,具体看设计稿需求。\
确认按钮和遮罩层的交互设计。通常三级联动作为弹层出现,底部一个半透明遮罩,中间或者底部弹出选择器。picker-view本身没有自带的确定/取消按钮,需要自己写。确定按钮绑定的逻辑就是向外触发事件,把当前选中的省市区名称和对应 ID 传回调用页面。取消按钮则直接关闭弹层即可。这里有个交互细节容易被忽略:如果用户滚动了一半直接点确定,可能会拿到一个还没完全定住的索引,因为bindchange的稳定性在快速滚动时没那么可靠。严谨的做法是在滚动结束后再更新内部数据,或者点击确定时以value为准做一次最后同步,确保拿到的一定是当前三列的最终索引。
遮罩层最好做成可点击关闭,但需要注意遮罩层和弹层之间的事件冒泡隔离。如果弹层容器本身没有阻止冒泡,点击弹层区域可能会触发遮罩的关闭逻辑,导致用户误关闭。我踩过一次这个坑,后来统一在弹层最外层容器加了一个catchtap空事件来拦截所有子节点的冒泡,问题一次解决。
5. 组件封装与参数设计
不要写死组件,要做好封装。我的做法是把三级联动封装成一个独立的自定义组件,放在components/region-picker/目录下,包含四个文件:json、wxml、wxss、js。对外暴露以下属性:
| 属性名 | 类型 | 默认值 | 说明 |
|---|---|---|---|
visible | Boolean | false | 是否显示选择弹层 |
defaultRegion | Array | [] | 默认选中区域,格式[省, 市, 区] |
titleText | String | 选择区域 | 弹层标题 |
bind:confirm | Event | 无 | 点击确认时触发,返回选中数据 |
bind:cancel | Event | 无 | 点击取消或遮罩关闭时触发 |
defaultRegion这个设计是为了支持回显场景。比如用户以前填写过收货地址,再次编辑时需要定位到已保存的省市区。这时候组件要在observers(属性监听)里判断传入值,然后根据省名、市名、区名反查索引,定位到指定项,再重新计算子级列表。这个反查逻辑需要遍历provinceList找到对应省份索引,然后限缩到该省城市列表里找城市索引,再限缩到区县列表里找区县索引,复杂度是 O(n + m + k),正常情况下可以忽略不计。
组件内部还需要维护一个当前选择状态对象,结构如下:
data: { provinceList: [], cityList: [], areaList: [], provinceIndex: 0, cityIndex: 0, areaIndex: 0, pickerValue: [0, 0, 0] }pickerValue直接绑定在picker-view的value属性上,三列索引统一管理。这里有一个容易犯的错误:直接在bindchange里修改pickerValue的某个项,可能会造成自定义组件的observers连续触发,导致死循环。正确的做法是在事件处理函数内部显式创建一个新的数组[provinceIndex, cityIndex, areaIndex],再用setData整体赋值,确保数据和视图的一致性。
在组件外部使用时,调用方只需要维护一个visible变量,通过bind:confirm事件接收返回值:
handleRegionConfirm(e) { const { province, city, area } = e.detail this.setData({ regionText: `${province} ${city} ${area}` }) }从使用方的视角看,这个组件几乎没有任何理解成本,这也是封装的意义所在。
6. 常见问题与排查技巧实录
写这种组件,光看理论是不够的,真到联调阶段,各种奇怪的问题都会冒出来。我把自己遇到过的几个典型问题和排查思路整理出来,方便你遇到时快速定位。
第一个问题:picker-view高度不生效。表现是页面上组件塌陷,滚动列完全不显示。排查思路是打开调试器看一下组件的计算样式,如果发现height没有被应用,多半是因为样式中写的是height: 100%而父容器没有明确高度。解决方案是把高度设置到具体数值,例如height: 500rpx,或者给父容器也加上固定高度。
第二个问题:切换省份时第二列和第三列的数据错乱。表现是选完安徽,城市列却显示江苏的城市。这个基本可以断定是cityList更新了但cityIndex没归零,导致第二列还在显示上一次的选中位置。处理办法就是在省份变化的分支里强制cityIndex = 0和areaIndex = 0,一步都不能省。
第三个问题:数据源中存在空区县列表。比如某些偏远地区的区划调整后,下级的区县还没更新到数据源里。选到这类城市时,areaList会变成空数组,渲染时第三列就是一片空白。排查数据源时我会额外统计所有城市对应区县数量为 0 的情况,如果确实存在,就在处理函数里对这种边缘情况做一个兜底——显示“暂无区县数据”并把第三列隐藏或者禁用。
第四个问题: iOS 端和安卓端的滚动手感不一样。picker-view在 iOS 上惯性滚动比较顺滑,但在一些低端安卓机上会有顿挫感。这个问题没有太完美的解决办法,但可以把中间的高亮分割线设置得粗一点,减少视觉上“选不中”的感觉。同时注意不要在bindchange里做过于重度的计算,比如大量setData或者同步请求,否则滚动会明显掉帧。
我再提供一个通用排查手段:在整个bindchange处理流程埋几个console.log,打印出当前的列索引和对应的列表长度。数据驱动的问题,90% 都是索引和列表不匹配导致的,看清楚这几个值的对应关系,问题基本就浮出水面了。
7. 组件演进与扩展思路
写到这里,这个三级联动组件已经能应付绝大多数场景了。不过实际业务中,还会有人问我能不能加一个“不限”选项,能不能二级联动只要省和市,能不能自动识别当前定位城市。这些都说明一个问题:组件从定制走向通用,关键在于抽象程度够不够。
对于二级联动的支持,思路其实很清晰:把第三列标记为disabled,同时在bindchange的分支处理里忽略第三列的索引变化;配置属性加一个level,传入 2 或 3 来控制。初始化数据时,如果level === 2,就把areaList置为空数组,并把第三列的滚动操作禁用。数据返回时,area字段传空字符串。
对于“不限”选项,可以在每一级列表的最前面插入一个空字符串项,索引 0 代表“不限”。但这里有个坑:插入空项会导致所有真实索引整体后移一位,在回显反查索引时要把空项排除掉,否则定位会错位。
对于定位城市自动选中,我担心的是会带来额外的授权弹窗,所以正常情况下不会放到组件内部做。比较合理的设计是在外层业务页面拿到定位结果后,通过defaultRegion属性传入组件,组件只管根据已有的省市区数据来匹配选中项,职责边界就非常清楚了。
最后再分享一个小技巧:数据文件可以做成按需加载的版本。如果你只是在某个二级页面用到省市区,完全可以把数据文件放到utils/region-data.js里,在组件所在的页面require进来,然后setData传给组件。这样既能减少主包体积,又不影响组件复用。我在自己的项目里,省市区三份核心 JSON 加起来大概 60~80KB,压缩之后主包压力很小,完全没必要为此去做远程数据拉取。
这个组件从设计到落地,核心就一句话:数据结构做扁平化,联动逻辑做事件驱动,渲染层交给原生picker-view。你按这个思路走,不管是改样式还是加功能,都会比改第三方库省心得多。如果做的时候有细节卡住了,欢迎在评论区把你遇到的问题丢出来,能帮的我会尽量帮一把。