简介:这份资源面向微信小程序开发者,尤其是需要实现地址选择功能的初中级开发者,提供省市县三级联动的完整实现方案。资源包共5个文件,包含2个js逻辑脚本、1个wxml结构模板、1个wxss样式文件和1个json配置文件,压缩包约27KB,体积轻巧便于直接引入项目。代码围绕JSON数据源设计,通过parentId字段区分省、市、县层级,配合picker组件动态设置range属性,并在handleProvinceChange、handleCityChange、handleDistrictChange等事件中逐级筛选下级行政区,同时支持页面加载时默认选中首个省份以初始化选项。读者可从中掌握数据结构设计、事件监听与组件联动等关键思路,并直接复用筛选方法来构建自己的地址选择模块。目前已有1464人学习下载,适合希望快速落地三级联动功能的小程序开发者参考。
1. 省市县三级联动:一个被低估的“小需求”,藏着三个真坑
做微信小程序表单的人,早晚会撞上地址选择。用户注册、收货地址、门店录入、活动报名,只要涉及“所在地”,产品经理张口就是“做个省市县三级联动呗”。听起来简单,真动手才发现:数据从哪来、picker 怎么串、回显怎么处理、直辖市和特别行政区怎么算,每一步都能让人卡半天。我见过不止一个项目,地址模块上线后用户反馈“选了省,市是空的”“编辑时地址对不上”“港澳台选不了”。这份资源就是围绕微信小程序原生picker组件做的一套省市县三级联动实现,适合正在写表单、又不想为了一个地址选择去引一堆第三方库的开发者。它不解决高深问题,但能把“能用、好维护、边界清楚”这三件事做扎实。
2. 三级联动的数据结构:为什么不能拿一份扁平数组硬套
2.1 三种常见数据组织方式与选型理由
省市县数据本质是一棵树,但落到代码里有三种存法,选错了后面全是补丁。
第一种是扁平数组,每条记录形如{code, name, parentCode},靠 parentCode 找父子关系。优点是增删改查方便,缺点是每次联动都要遍历过滤,小程序里频繁 setData 会明显卡顿。第二种是嵌套树,省节点下挂市数组,市节点下挂县数组,结构直观,但文件体积大,加载慢。第三种是“省数组 + 市县映射表”,省列表单独一份,市县按省 code 做键存成对象,用到哪个省取哪个。这是我在小程序场景里最常用的折中方案:首屏只加载省列表,用户选了省再取市县,既省内存又省 setData 次数。
这份资源采用的是第三种思路的变体——把完整数据放在一个 JS 模块里,但按“省 → 市 → 县”三层嵌套导出,页面初始化时只取省这一层,后续按需展开。选它的理由很实际:小程序包体积有限,数据文件不能无限膨胀;同时嵌套结构让 picker 的range绑定最直接,不需要在页面里写过滤逻辑。
2.2 数据文件长什么样:字段与层级约定
数据模块的典型结构如下,字段命名要统一,不然后面回显时对不上号。
// data/region.js // 三层嵌套:省 -> 市 -> 县,每层用 code 做唯一标识 const regionData = [ { code: '110000', name: '北京市', children: [ { code: '110100', name: '北京市', children: [ { code: '110101', name: '东城区' }, { code: '110102', name: '西城区' } ] } ] }, { code: '440000', name: '广东省', children: [ { code: '440100', name: '广州市', children: [ { code: '440103', name: '荔湾区' }, { code: '440104', name: '越秀区' } ] } ] } // 其余省份省略 ]; module.exports = regionData;这里有几个约定值得说清楚。code用六位行政区划码,省是前两位加四个零,市是前四位加两个零,县是完整六位,这样从 code 就能反推层级,回显时不用额外存层级字段。name只存短名,不带“省”“市”“县”后缀,展示时按需拼接,避免“北京市市”这种尴尬。直辖市的处理是省和市同名,市这一层只有一个节点,县区挂在下面,这样联动逻辑不用为直辖市写特判。
提示:数据文件建议单独放
data/目录,不要和页面逻辑混在一个文件里。小程序打包时会对 JS 做压缩,数据模块独立后,后续换数据源只改一个文件。
2.3 数据量控制:别把全国数据一次性塞进首屏
全国省市县完整数据大约三千多条,压缩后几十 KB,放在包里不算大,但一次性 setData 到页面会拖慢首屏。常见做法是页面onLoad时只把省列表 setData 进去,市县在用户选择后再取。这份资源的页面逻辑就是这么分的:provinceList初始化赋值,cityList和countyList初始为空数组,picker 的range绑定时空数组不会报错,只是选项为空,用户选完省自然就有值了。
3. picker 三连:把三个选择器串成一条链
3.1 WXML 结构:三个 picker 的绑定关系
微信小程序的picker组件用mode="selector"就能做单列选择,三级联动就是三个 picker 依次绑定不同数组。关键在于每个 picker 的value要绑到对应的索引变量上,bindchange各管各的。
<!-- pages/address/address.wxml --> <view class="form-item"> <text>省份</text> <picker mode="selector" range="{{provinceList}}" range-key="name" value="{{provinceIndex}}" bindchange="onProvinceChange"> <view class="picker-value">{{provinceName || '请选择省份'}}</view> </picker> </view> <view class="form-item"> <text>城市</text> <picker mode="selector" range="{{cityList}}" range-key="name" value="{{cityIndex}}" bindchange="onCityChange" disabled="{{!provinceName}}"> <view class="picker-value">{{cityName || '请选择城市'}}</view> </picker> </view> <view class="form-item"> <text>区县</text> <picker mode="selector" range="{{countyList}}" range-key="name" value="{{countyIndex}}" bindchange="onCountyChange" disabled="{{!cityName}}"> <view class="picker-value">{{countyName || '请选择区县'}}</view> </picker> </view>range-key="name"让 picker 直接显示对象的 name 字段,不用在 JS 里把数组转成字符串数组。disabled绑定是容易被忽略的细节:没选省之前,市和县的选择器应该是灰的,否则用户点开看到空列表会以为坏了。value绑索引而不是绑对象,是因为 picker 的 change 事件返回的是索引,绑对象反而要多做一次查找。
3.2 联动逻辑:change 事件里到底该做什么
三个 change 处理函数的职责要分清:选省时重置市和县,选市时重置县,选区县只更新自己。重置不只是清空数组,索引和显示名也要一起归零,否则会出现“数组换了但索引还指着旧位置”的错位。
// pages/address/address.js const regionData = require('../../data/region.js'); Page({ data: { provinceList: [], cityList: [], countyList: [], provinceIndex: 0, cityIndex: 0, countyIndex: 0, provinceName: '', cityName: '', countyName: '' }, onLoad() { // 首屏只加载省列表 this.setData({ provinceList: regionData }); }, onProvinceChange(e) { const index = Number(e.detail.value); const province = this.data.provinceList[index]; // 选省后重置市县,避免旧数据残留 this.setData({ provinceIndex: index, provinceName: province.name, cityList: province.children || [], cityIndex: 0, cityName: '', countyList: [], countyIndex: 0, countyName: '' }); }, onCityChange(e) { const index = Number(e.detail.value); const city = this.data.cityList[index]; this.setData({ cityIndex: index, cityName: city.name, countyList: city.children || [], countyIndex: 0, countyName: '' }); }, onCountyChange(e) { const index = Number(e.detail.value); const county = this.data.countyList[index]; this.setData({ countyIndex: index, countyName: county.name }); } });e.detail.value在部分基础库版本里是字符串,用Number()转一下更稳。选省时把cityList换成新省的 children,同时把cityIndex归零,这一步是联动的核心——如果只换数组不归零索引,picker 打开时高亮位置会错。选市同理。县没有下级,只更新自己。
3.3 回显:编辑地址时怎么把三级索引还原
新增地址好办,编辑地址才是翻车高发区。后端通常只存三个 code 或三个名字,页面要反查出索引。用 code 反查比用名字可靠,因为名字可能重名(比如多个“城关区”)。
// 编辑页回显:传入 provinceCode / cityCode / countyCode restoreAddress(provinceCode, cityCode, countyCode) { const pIndex = regionData.findIndex(p => p.code === provinceCode); if (pIndex === -1) return; const province = regionData[pIndex]; const cityList = province.children || []; const cIndex = cityList.findIndex(c => c.code === cityCode); const countyList = cIndex > -1 ? (cityList[cIndex].children || []) : []; const tIndex = countyList.findIndex(t => t.code === countyCode); this.setData({ provinceIndex: pIndex, provinceName: province.name, cityList, cityIndex: cIndex > -1 ? cIndex : 0, cityName: cIndex > -1 ? cityList[cIndex].name : '', countyList, countyIndex: tIndex > -1 ? tIndex : 0, countyName: tIndex > -1 ? countyList[tIndex].name : '' }); }反查顺序必须是省 → 市 → 县,因为下一级的列表依赖上一级的选中结果。findIndex返回 -1 时要兜底,不能直接把 -1 塞进value,否则 picker 会显示异常。实际项目里我一般会在 restore 之前先校验三个 code 是否都存在,缺一个就降级成“只回显到市”或“清空重选”,比显示错位强。
4. 避坑排查:五个真实翻车现场
4.1 选了省,市列表是空的
现象:用户选完省,点开城市选择器没有任何选项。原因通常是数据里该省的children字段缺失或为空,常见于数据源裁剪时把某些省的下级删了,或者字段名写成了cities而代码读的是children。解决:在onProvinceChange里加一行兜底cityList: province.children || [],同时在数据导入阶段做一次完整性校验,遍历所有省节点确认 children 是非空数组。
4.2 编辑回显时区县对不上
现象:编辑页打开,省和市显示正确,区县却是第一个选项。原因多半是回显时先 setData 了countyList但没同步countyIndex,或者反查用的名字而不是 code,遇到重名区县就匹配到第一个。解决:统一用 code 反查,且 setData 时把列表、索引、显示名三者在同一次调用里一起更新,不要分两次 setData,否则中间态会触发渲染错位。
4.3 直辖市出现“北京市 / 北京市 / 东城区”看着别扭
现象:直辖市和省管县场景下,市这一级和省级同名,用户觉得冗余。原因:行政区划数据本身就这么组织的,直辖市下面确实有一个同名的市级节点。解决:展示层做合并,当province.name === city.name时,城市选择器可以隐藏或显示为“同上”,但数据层不要改,否则 code 映射会乱。这是展示优化,不是数据问题,别在数据文件里动刀。
4.4 快速连点导致索引错乱
现象:用户手快,选完省立刻点市,偶尔出现市列表还是上一个省的。原因:setData是异步的,连续触发 change 时,后一次读到的this.data.cityList可能还是旧值。解决:在 change 处理函数里不要依赖this.data的即时值,而是从事件参数和已缓存的源数据重新推导;或者在关键赋值后加一个轻量的状态锁,避免同一轮渲染内重复进入。
4.5 数据文件过大导致包体积告警
现象:上传代码时提示主包超过限制。原因:全国完整数据加上其他页面资源,主包吃紧。解决:把 region 数据放到分包,地址页也放同一个分包;或者只保留项目实际覆盖的省份,按业务裁剪。裁剪时注意保留 code 的层级规律,别把某省的市删了却留着县,回显会直接崩。
5. 进阶:把联动封装成可复用组件并做一次自检
写到这儿,页面级实现已经能跑。但地址选择这种模块,一个项目里往往不止一处用,复制三遍 WXML 和 JS 就是给自己埋维护债。我的习惯是把它抽成自定义组件,对外只暴露value和bindchange两个接口。
组件化的关键是把“数据源”和“选中状态”分开:数据源在组件内部require,选中状态通过 properties 传入、通过事件传出。这样父页面完全不关心省市县怎么联动,只拿最终的{provinceCode, cityCode, countyCode, provinceName, cityName, countyName}。
// components/region-picker/region-picker.js const regionData = require('../../data/region.js'); Component({ properties: { // 外部传入的初始值,格式 { provinceCode, cityCode, countyCode } value: { type: Object, value: {}, observer(val) { if (val && val.provinceCode) { this.restore(val); } } } }, data: { provinceList: regionData, cityList: [], countyList: [], provinceIndex: 0, cityIndex: 0, countyIndex: 0, provinceName: '', cityName: '', countyName: '' }, methods: { restore(val) { const pIndex = regionData.findIndex(p => p.code === val.provinceCode); if (pIndex === -1) return; const province = regionData[pIndex]; const cityList = province.children || []; const cIndex = cityList.findIndex(c => c.code === val.cityCode); const countyList = cIndex > -1 ? (cityList[cIndex].children || []) : []; const tIndex = countyList.findIndex(t => t.code === val.countyCode); this.setData({ provinceIndex: pIndex, provinceName: province.name, cityList, cityIndex: cIndex > -1 ? cIndex : 0, cityName: cIndex > -1 ? cityList[cIndex].name : '', countyList, countyIndex: tIndex > -1 ? tIndex : 0, countyName: tIndex > -1 ? countyList[tIndex].name : '' }); }, onProvinceChange(e) { const index = Number(e.detail.value); const province = this.data.provinceList[index]; this.setData({ provinceIndex: index, provinceName: province.name, cityList: province.children || [], cityIndex: 0, cityName: '', countyList: [], countyIndex: 0, countyName: '' }); this.emitChange(); }, onCityChange(e) { const index = Number(e.detail.value); const city = this.data.cityList[index]; this.setData({ cityIndex: index, cityName: city.name, countyList: city.children || [], countyIndex: 0, countyName: '' }); this.emitChange(); }, onCountyChange(e) { const index = Number(e.detail.value); const county = this.data.countyList[index]; this.setData({ countyIndex: index, countyName: county.name }); this.emitChange(); }, emitChange() { const { provinceList, provinceIndex, cityList, cityIndex, countyList, countyIndex } = this.data; const p = provinceList[provinceIndex] || {}; const c = cityList[cityIndex] || {}; const t = countyList[countyIndex] || {}; this.triggerEvent('change', { provinceCode: p.code || '', cityCode: c.code || '', countyCode: t.code || '', provinceName: p.name || '', cityName: c.name || '', countyName: t.name || '' }); } } });这段代码里有两个设计点值得展开。observer监听value变化,父页面异步拿到后端数据后更新value,组件自动回显,不用父页面再调方法。emitChange在每次 change 后触发,但注意它读的是this.data,而setData是异步的——在微信小程序里,setData的回调执行时数据已更新,但同步代码里this.data可能还是旧值。稳妥写法是把要 emit 的数据作为参数直接传进emitChange,不依赖this.data的时序。上面为了可读性保留了读this.data的写法,实际项目里我会改成显式传参,这是血泪经验:异步时序问题在小程序里排查起来像黑匣子,能绕就绕。
组件用起来就干净了:
<region-picker value="{{address}}" bindchange="onRegionChange" />父页面onRegionChange里拿e.detail直接存或提交,不再碰任何省市县数组。
最后说验证。这类联动组件最怕“看着对、边界错”,我一般会跑一遍固定清单:选省后市列表是否刷新且索引归零;选市后县列表是否刷新;编辑回显三个 code 是否都能命中;直辖市是否正常;只传省不传市时是否降级不报错;快速连点是否错位。这六条走完,基本能挡住八成线上问题。从那以后我每次交付地址模块前,都强制把这六条在真机上点一遍,模拟器上点得快反而容易漏掉时序问题。希望帮到你。
本文还有配套的精品资源,点击获取