简介微信小程序省市县三级联动完整示例代码包面向微信小程序开发初学者与需要快速实现地址选择功能的前端工程师解决行政区划逐级筛选和数据联动中常见的数据结构设计、picker 组件绑定与事件处理问题。资源共5个文件包含2个 JavaScript 逻辑文件、1个 WXML 页面结构文件、1个 WXSS 样式文件及1个 JSON 配置分别承担数据过滤方法、页面交互、界面布局与页面配置压缩包仅27KB结构精简便于直接导入项目参考。已有1460人学习下载。代码采用 provinces/cityList/districtList 三级数据源结合三个 picker 组件动态更新下级选项并给出 handleProvinceChange、handleCityChange、handleDistrictChange 等事件处理示例同时包含初始默认省份的加载逻辑目录按工具方法和页面文件分层业务逻辑与界面展示分离便于初学者快速定位与修改可直接运行或改造成通用地址选择组件是理解微信小程序组件通信与列表联动的高质量入门范例。1. 省市县三级联动看着像三个 picker 叠一起做起来却有一半的功夫花在数据治理上省市县三级联动是微信小程序表单里最典型的“基础功能”选省份、再选城市、最后落区县看起来不过是把三个 picker 串在一起。可真在微信小程序项目实例里做一次就明白真正的难点不在“联动”那两行逻辑而在省市区数据怎么建模、索引错位怎么兜底、默认值回填怎么做到不闪断。这篇从数据模型讲到组件封装每一步都给了能直接复现的代码和参数最后把真机上才暴露的翻车现场一并列出来。适合正在写表单、地址选择、区域筛选的开发者照着抄。2. 省市区数据模型先决定数据怎么存再谈三级联动怎么写2.1 扁平数组还是嵌套树picker 联动的数据格式选择在微信小程序里做省市县三级联动第一步不是写代码是确定省市区数据用什么结构。常见做法是两种嵌套树和扁平数组。嵌套树结构直观但联动取数麻烦要在每层用 find 去递归找子级还需要额外维护“当前层级路径”一旦数据层级深、节点多递归查找的效率和调试成本都不友好。扁平数组加父子编码更适合 picker 联动省、市、区各一个数组每项带name和code/pcode联动时以省级的 code 为条件filter出市级再以市级的 codefilter出区级一次 filter 在几千条数据规模下只有毫秒级开销完全够用。我用的是后者格式大致长这样{ province_list: [ { name: 广东省, code: 440000, pcode: null } ], city_list: [ { name: 广州市, code: 440100, pcode: 440000 } ], district_list: [ { name: 天河区, code: 440106, pcode: 440100 } ] }之所以不选嵌套树还有一个现实原因小程序 setData 每次传输都有性能开销嵌套树联动时往往要把整个子树传过去扁平数组只传当前需要展示的那一列数据量小视图更新压力也小。另外补充一个选型判断标准multiSelector 要求传给range的是一个数组的数组也就是[[省], [市], [区]]每一列独立。这种格式天生匹配扁平数组嵌套树要展开成这种结构还是得先按层map出条目绕了一圈又回到扁平化。参数说明code用行政区划代码省级两位补零到六位市级四位补零到六位区级完整六位。这套编码在公开的行政区划数据里是统一的后端组织架构表也普遍拿这个字段做关联前端用它少做一层映射。2.2 数据放哪包内静态文件、storage 缓存还是云端下发省市区全量数据大约三百多个市、三千多个区县JSON 序列化后压缩体积在 200 到 300KB 量级。考虑小程序主包体积限制放包内、放缓存还是云端下发要分场景。项目里只有一个小表单建议把精简后的数据放 local 目录或分包内页面 onLoad 时用wx.getFileSystemManager().readFile同步读取一次性拿到。这样数据在组件创建前就绪不会出现加载白屏。项目里多个页面、多个表单都要用或者以后要加“按关键词搜城市”的能力我一般会把数据缓存在 storage 里接口获取后写入wx.setStorageSync(area_data, { version, list })。读取前先比较版本号后端更新过就重新拉没更新直接用缓存。数据在本地还有个隐藏好处picker 联动完全不依赖网络请求离线也能用。注意 storage 有 10MB 上限200KB 的省市区数据不构成压力但同一份数据只存一次不要反复把整棵树塞进去。实际开发里还有一个容易忽略的细节JSON 文件统一用 UTF-8 无 BOM 保存微信开发者工具在部分版本下读带 BOM 的文件会解析出隐形字符导致之后findIndex永远匹配不上。2.3 直辖市和省直辖县被忽略的“两级半”结构真正让三级联动翻车的是北京、上海、天津、重庆四个直辖市以及河南济源、湖北仙桃这类省直辖县级行政单位。它们在地理概念上是两级但在行政区划代码里依然是三级北京 - 北京市 - 东城区。不做任何处理的话用户选了“北京市”后第二列会出现一个跟省级名字一模一样的“北京市”第三列才是区。这个交互不算错但视觉上很怪所以多数项目会做一层特殊处理。常见做法有两种数据层过滤把直辖市下属的市级 code 直接指向省级 code生成 city_list 时把北京市、上海市这种同名市级项剔除区直接挂在省下面。UI 层特判联动逻辑里判断当前省份 code 在不在[110000, 120000, 310000, 500000]里命中时跳过市级选择第三列直接展示区列表。第二种更通用因为后端的组织数据可能本来就是按“直辖市 - 区”关联的。我会在组件里维护一个directMunicipality数组联动时先判断再决定是否保留市级这一列而不是去改数据源。改数据源的方式容易引入 code 断链后面排查起来很麻烦。3. 用 picker 实现三级联动multiSelector 的时序与最小可运行代码3.1 选型multiSelector 还是自定义弹层先交代结论绝大多数省市县三级联动需求用picker modemultiSelector就够了不用自己写弹层。multiSelector 天然支持多列columnchange事件能拿到用户当前拨到第几列、停在哪个索引联动逻辑在这里做。只有在需要“搜索城市”“地图选点”“多选区域”这类增强交互时才值得上自定义弹层那已经是另一个量级的组件了。3.2 最小实现三个数组加两个事件处理器下面是一份能直接跑的最小页面代码数据加载部分先省略假设在 data 里已经拿到了省市区全量列表Page({ data: { provinceList: [], cityList: [], districtList: [], provinceIndex: 0, cityIndex: 0, districtIndex: 0, range: [], valueText: }, onLoad() { // loadAreaData 负责从本地文件或 storage 读取全量数据 const { provinceList, allCities, allDistricts } loadAreaData(); const firstProvince provinceList[0]; const cityList allCities.filter(item item.pcode firstProvince.code); const districtList allDistricts.filter(item item.pcode cityList[0].code); this.setData({ provinceList, cityList, districtList, range: [provinceList, cityList, districtList], valueText: ${firstProvince.name} ${cityList[0].name} ${districtList[0].name} }); }, onColumnChange(e) { const { column, value } e.detail; if (column 0) { const province this.data.provinceList[value]; const cityList this.data.allCities.filter(item item.pcode province.code); const districtList this.data.allDistricts.filter(item item.pcode cityList[0].code); this.setData({ provinceIndex: value, cityIndex: 0, districtIndex: 0, cityList, districtList, range: [this.data.provinceList, cityList, districtList] }); } else if (column 1) { const city this.data.cityList[value]; const districtList this.data.allDistricts.filter(item item.pcode city.code); this.setData({ cityIndex: value, districtIndex: 0, districtList, range: [this.data.provinceList, this.data.cityList, districtList] }); } // column 2 时不需要联动交给 picker 自己滚动即可 }, onChange(e) { const { value } e.detail; const { provinceList, cityList, districtList } this.data; const province provinceList[value[0]]; const city cityList[value[1]]; const district districtList[value[2]]; this.setData({ provinceIndex: value[0], cityIndex: value[1], districtIndex: value[2], valueText: ${province.name} ${city.name} ${district.name} }); // 这里把最终结果交给调用方通常在组件里用 triggerEvent 抛给父页面 } });这里有两个关键参数要说明。e.detail.column是用户滑动的那一列的列号e.detail.value是这一列当前停在的索引不是完整的三列索引。很多人第一次写会误以为value是整个数组结果拿value[2]去取区取到 undefinedpicker 直接显示空白这种错位属于高频踩坑点。range必须用新数组赋值才能触发视图刷新。this.setData({ range: [provinceList, cityList, districtList] })看起来和原来差不多但数组是新创建的小程序才会对比出差异去更新 picker 列。如果图省事写this.setData({ range[1]: cityList })在部分基础库版本上不会刷新统一用整数组赋值最稳。对应的 wxml 结构也很简单picker modemultiSelector value{{[provinceIndex, cityIndex, districtIndex]}} range{{range}} range-keyname bindcolumnchangeonColumnChange bindchangeonChange view classpicker-value{{valueText || 请选择省 / 市 / 区}}/view /pickerrange-keyname表示每列展示对象里的 name 字段。如果数据里没有 name 而是叫 regionName就把这个属性改成对应字段名。3.3 columnchange 的触发时序为什么连续拨动会跳列multiSelector 的 picker 有一个交互细节用户快速连拨时columnchange不一定按 0 - 1 - 2 的顺序触发同一列也可能连发两次。联动逻辑如果只依赖“当前列的值”重算后续列第二列还没稳定时重算出来的第三列就会跟随错误索引。我采用的处理是在联动分支里强制把后级索引归零并把重算后的 range 一次性 setData 出去。这样即使上一个 columnchange 还没落地新事件进来时立刻又纠正回来极端情况只是列内容轻微抖动不会出现“市列显示省份名、区列显示市名”的错乱。还有一个注意点不要在不必要的时机调用 setData。比如用户只在第三列来回滑动column 2的分支什么都不做因为第三列后面没有需要联动的列picker 自己会处理滚动加代码反而增加渲染压力。4. 默认值回填与结果输出把“上次选过的地方”安稳地还回去4.1 从编码反推索引的查找逻辑表单回显场景里最常见的需求是后端返回{provinceCode:440000,cityCode:440100,districtCode:440106}前端要把这三个编码映射成 picker 的索引。逻辑不复杂但坑不少先看核心实现function findIndex(list, code) { if (!list || !code) return -1; const index list.findIndex(item item.code code); return index 0 ? index : 0; } function restoreByCode(page, { provinceCode, cityCode, districtCode }) { const { provinceList } page.data; const provinceIndex findIndex(provinceList, provinceCode); const cityList page.data.allCities.filter(item item.pcode provinceList[provinceIndex].code); const cityIndex findIndex(cityList, cityCode); const districtList page.data.allDistricts.filter(item item.pcode cityList[cityIndex].code); const districtIndex findIndex(districtList, districtCode); page.setData({ provinceIndex, cityIndex, districtIndex, cityList, districtList, range: [provinceList, cityList, districtList], valueText: ${provinceList[provinceIndex].name} ${cityList[cityIndex].name} ${districtList[districtIndex].name} }); }必须注意findIndex的兜底。数据版本不一致时后端可能返回一个本地列表里不存在的编码findIndex返回 -1此时如果直接取provinceList[-1].name结果是 undefined页面上的选择框会显示“undefined undefined undefined”。兜底逻辑返回 0选中的不是目标地区但至少 UI 不崩用户能重新选择。更稳的做法是找不到时返回 -1 并在组件外部提示“地区数据已更新请重新选择”。有些后端不返回 code 只返回名称字符串比如只给“广东省 广州市 天河区”。此时findIndex的条件要改成item.name name但直辖市同名问题会放大“北京市”在 province_list 和 city_list 里都存在第二列永远选第一个用户看到名称一样反而不容易发现错位。我的建议是能争取 code 就争取 code拿不到时再退到 name 匹配匹配失败优先落到第一项并打一条 warning 日志方便排查。4.2 回填时“切换省份后城市索引”要不要重置回填和手动选择联动有个区别手动选择是从索引 0 一路拨到目标位置回填则是直接跳到一个特定点。如果后端返回的 provinceCode 和 cityCode 匹配但 districtCode 对不上按上面逻辑重算出的 districtList 里没有这个区districtIndex 会被兜底成 0用户看到第三列是新区列表的第一个而不是目标区。这个行为可以接受但要注意别产生“用户没动第一列第二列却变了”的观感。回填时统一重算 cityList/districtList是因为它们本来就是按当前索引过滤得到的关联数组这是联动模型的一部分不是 bug。不过重算过程中不能出现中间态否则 picker 的 value 和 range 错位iOS 上的表现特别明显。4.3 结果输出编码、名称和路径字符串一起给出去表单最终提交时后端通常要区划代码展示要名称字符串两者要在一个事件里同时给出。在 onChange 里组装结果对象是最省事的做法const result { provinceCode: province.code, cityCode: city.code, districtCode: district.code, provinceName: province.name, cityName: city.name, districtName: district.name, fullText: ${province.name} ${city.name} ${district.name}, fullCode: ${province.code}${city.code}${district.code} };fullCode在很多业务库里被当作树形编码比如区域库存表按这个字段做前缀匹配一次提交两个字段就能同时满足展示和聚合查询需求。组件对外抛triggerEvent(change, result)父页面直接拿结果对象不需要自己按索引去数组里取名字父页面的业务代码也就不依赖组件内部数据结构了。另一种省事的做法是用户每次完成选择后把fullCode和fullText缓存进本地 storage下次进编辑页先按缓存回显再异步请求后端最新数据覆盖。这在多步表单和草稿场景里体验提升很明显三级联动不闪断。5. 实操排错省市县三级联动在真机上常见的五个翻车现场5.1 现象picker 打开后一滑动就白屏甚至整个页面假死原因省市区数据还没读到页面已经渲染了 picker。用户拨到第二列时cityList 还是空数组range[1]没有数据picker 的列渲染直接异常。解决数据加载完成前不渲染 picker用wx:if{{areaReady}}控制把数据读取提前到 App 启动阶段页面 onLoad 只读缓存不再发请求。这里用wx:if是安全的因为 picker 实例必须在数据就绪后才创建但如果页面其他逻辑也用wx:if反复控制同一个组件会导致组件状态丢失那是另一类问题最好用hidden。5.2 现象iOS 上拨动第一列时第二、三列闪一下旧内容再变新内容原因columnchange处理里没有一次性更新range而是先 setData 了 index又单独 setData 了 cityList、districtList两次渲染之间 picker 用旧 range 渲染了新索引视觉上就闪了。解决把联动后所有状态合并成一次 setData也就是range: [provinceList, cityList, districtList]一次性赋值。减少 setData 次数对 iOS 的 picker 渲染稳定性帮助很大这条经验在很多项目里反复验证过。5.3 现象控制台报 setData 数据量过大告警或者 iOS 上组件无响应原因把整棵省市区树放进 data联动时再把自己剪裁出来的子树 setData 出去。树的层级深、节点多一次 setData 传了几百个对象超过传输上限后 iOS 直接没反应。解决data 里只保留“当前正在渲染的三列数组”全量数据用普通变量挂在this.areaData { provinceList, cityList, districtList }上不参与响应式渲染。联动时从this.areaData里 filter只把展示需要的列 setData 出来在当前基础库下这是性能最稳的做法。5.4 现象同一页面有两个三级联动组件第二个组件的初始数据变成第一个组件的选择结果原因两个组件实例共享了同一个全局数组或 storage 对象其中一个组件 setData 后另一个组件的数据源被覆盖。常见于把全量数据存在全局变量再在组件的 attached 生命周期里执行 filter两个组件同时 attached 时发生竞争。解决组件内部把全量数据复制一份到实例属性上不直接引用全局对象。复制用JSON.parse(JSON.stringify(areaData))虽然慢一点但保证互不污染。省市区数据 200KB 级别的复制开销可以接受换来的是多实例隔离。5.5 现象安卓端正常iOS 端 picker 偶发点击无反应或者默认值没回显到 picker 上原因回显时 setData 传入的索引、列表、range 是分开的多次 setData。iOS 基础库对 picker 的 value 与 range 同步要求更高出现 index 已更新但 range 还没更新完的间隙picker 以为自己停在越界位置。解决回显同样合并成一次 setData。另外注意传给 picker 的 value 必须是[provinceIndex, cityIndex, districtIndex]数组形式不能传字符串或只传一个数字否则在 iOS 上会触发类型告警且表现不稳定。6. 进阶经验把三级联动收口成一个可控的 area-picker 组件到这一步前面的逻辑已经能在单页里跑通。但如果项目里有多个表单、多个入口都需要省市县三级联动继续复制粘贴页面代码下一次改需求时就很痛苦。我一般会在第二次遇到同样需求时把整套逻辑收口成一个自定义组件对外只暴露两个方法initAreaData(data)和restoreByCode(codes)再向外抛一个change事件。组件内部把前面提到的坑全部内置数据就绪前不渲染、联动一次性 setData、全量数据不进响应式、直辖市特判、索引兜底。父页面用起来只需要这样area-picker idareaPicker bind:changeonAreaChange /this.selectComponent(#areaPicker).restoreByCode({ provinceCode: 440000, cityCode: 440100, districtCode: 440106 });同时提供一个reset()方法把三列索引归零用于“新增”和“编辑”复用同一个弹层的场景。编辑时先 reset 再 restore两次 setData 合并成一次避免弹层刚打开就看到索引跳变。这套组件也兼容 uniapp 开发微信小程序的场景只要把生命周期函数和 setData 替换成 uniapp 的写法联动逻辑可以原样搬过去因为核心的 columnchange 时序和数据模型不依赖框架。最后留一条我自己最深的教训省市区三级联动真正难的从来不是“联动”那两下而是数据治理、回显兜底和 setData 的合并时机。以后每次再看到“简单却频繁出问题”的三级联动我都会先问三个问题——数据格式定了吗回显兜底了吗一次 setData 能合并完吗三个问题都确认了剩下的代码半小时就能写完。希望这些踩过的坑能帮你省下半天调试时间祝你一次跑通。本文还有配套的精品资源点击获取