小程序原生开发:用 picker-view 自制省市区三级联动组件
做小程序原生开发的人迟早会碰到省市区三级联动这个需求。不管是收货地址、身份信息填写还是门店选择这玩意儿几乎是每家公司的必备组件。我刚入行那会儿也偷懒去搜现成组件但你会发现市面上的第三方组件要么兼容性有问题要么样式跟自己的设计稿差别太大没法改要么带了太多你用不上的功能导致包体虚胖。折腾过几回之后我干脆自己动手写了一个用原生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。对外暴露以下属性属性名类型默认值说明visibleBooleanfalse是否显示选择弹层defaultRegionArray[]默认选中区域格式[省, 市, 区]titleTextString选择区域弹层标题bind:confirmEvent无点击确认时触发返回选中数据bind:cancelEvent无点击取消或遮罩关闭时触发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。你按这个思路走不管是改样式还是加功能都会比改第三方库省心得多。如果做的时候有细节卡住了欢迎在评论区把你遇到的问题丢出来能帮的我会尽量帮一把。