☰
微信小程序省市区三级联动:数据裁剪、picker联动与避坑全解析
2026/10/21 22:22:04 网站建设 项目流程

简介:微信小程序省市县三级联动资源包面向小程序开发者,帮助解决地址选择场景中省、市、县逐级筛选与联动更新的常见需求,适合初学者作为功能模板参考。压缩包共5个文件,包括页面结构wxml、样式wxss、逻辑js和页面配置json,其中js负责行政区数据处理与picker绑定逻辑,wxml定义三个选择器的展示交互,整体仅27KB,结构清晰。已有1461人浏览学习。资源提供了完整的parentId行政区数据组织思路,以及省、市、县三个picker联动更新的实现代码,包含初始化省级列表、监听选择变化、筛选下级城市和区县等关键过程,还附带了默认选中第一个省份的体验处理。读者可据此快速搭建可运行的三级联动地址选择组件,并理解事件处理与数据筛选在微信小程序中的实际用法。

1. 微信小程序省市县三级联动:一个表单控件的五个真实坑

微信小程序省市县三级联动,第一眼看去只是把 picker 的 mode 设成 multiSelector、再给 range 塞个二维数组的事,真按这个思路写下来,大概率会在省市切换、数据回显、异步加载这三个地方轮流翻车。这两年经手的表单需求里,收货地址、户籍信息、就诊人信息全都绕不开这套控件,踩过的坑包括:选完省切市,第三列区没跟着重置;直辖市只有两级,picker 第三列空转;后端返回的是地址字符串,回显时反查索引却查不到。这篇把三级联动的数据裁剪、picker 绑定、columnchange 逻辑、分表缓存和避坑排查一次写透,适合正在写地址类表单、想直接抄一套能跑的原生实现的人。

2. 行政区划数据处理:把官方代码表裁成 picker 吃得下的二维结构

2.1 裁剪规则:只要省市区三层,并标记直辖市

做三级联动之前,先得拿到一份行政区划数据。公开的行政区划代码表一般是「6 位代码 + 名称」的平铺列表:前两位是省级,前四位是市级,六位是区县级。三级联动只需要截至区县这一层,乡镇、街道全部砍掉。另外还有两类特殊结构要提前处理:一是直辖市,省这一级下面直接挂区,市这一级实际是空转的;二是省直辖县级行政区划,名义上是市,实际上没有下一级。处理不好,picker 的第三列就会出现空白或者凭空多出一列。

裁剪脚本的常见做法是遍历原始 rows,按代码长度三层归位,遇到直辖县级结构单独打标。下面这段逻辑是把平铺数据转成树的核心部分:

// rows 为 [code, name] 的二维数组,code 是 6 位数字字符串 function buildRegionTree(rows) { const provinceMap = {} const cityMap = {} const tree = [] rows.forEach(([code, name]) => { const pCode = code.slice(0, 2) + '0000' const cCode = code.slice(0, 4) + '00' if (code === pCode) { // 省级节点,children 挂市列表 const node = { name, code, children: [] } tree.push(node) provinceMap[pCode] = node } else if (code === cCode) { // 市级节点,children 挂区县列表 const parent = provinceMap[pCode] if (parent) { const node = { name, code, children: [] } parent.children.push(node) cityMap[cCode] = node } } else { // 区县级节点 const parent = cityMap[cCode] if (parent) { parent.children.push({ name, code, children: null }) } else { // 省直辖县级单位,直接挂在省下,并打 direct 标记 const province = provinceMap[pCode] if (province) { province.children.push({ name, code, children: null, direct: true }) } } } }) return tree }

这段代码的关键判断是 code === pCode 和 code === cCode:pCode 是省级代码的完整形态,cCode 是市级代码的完整形态,平铺数据里恰好有这两条记录,可以作为容器节点。这里有个容易漏掉的细节:省直辖县级单位的 children 我置为 null 而不是空数组,因为后面联动逻辑要区分「没有下一级」和「有下一级但还没加载」。如果是空数组,picker 联动时第三列会拿到一个空列表然后正常渲染空白选项;用 null 则可以在 columnchange 里直接跳过第三列更新,逻辑更干净。

资源包里已经清洗好了一份 regionTree.json,格式就是上面这个树结构,省下面挂市、市下面挂区县,children 为 null 的节点不做任何补全。拿到的同学不用再跑清洗脚本,直接用即可,但理解裁剪规则仍然重要,因为后续如果要自己加数据、或者扩展乡镇级,得知道在哪一层动手。

2.2 树转二维数组:picker 的 range 与 value 到底怎么对应

picker 的 multiSelector 模式,range 要求是一个「数组的数组」,每一列对应一维数组;value 则是「每一列选中的索引」组成的数组。树结构和这个模型不匹配,需要先平铺成「当前选中省下的市列表」和「当前选中市下的区列表」。初始化时默认取第一省、第一市、第一区,代码如下:

// tree 是 buildRegionTree 返回的树结构 function initPickerRange(tree) { const province = tree[0] || null const city = province && province.children ? province.children[0] : null const district = city && city.children ? city.children[0] : null return [ tree.map((p) => ({ name: p.name, code: p.code })), province && province.children ? province.children.map((c) => ({ name: c.name, code: c.code })) : [], city && city.children ? city.children.map((d) => ({ name: d.name, code: d.code })) : [], ] }

range 的三列我放的是对象数组而不是字符串数组。字符串数组在 picker 里也能显示,但后续要获取 code 就要靠 index 再去查原树,绕一圈。对象数组配合 range-key="name",展示的是 name,取值时用 item.code,一步到位。这里有个小限制:range-key 只能取对象第一层属性,所以 children 这类嵌套字段不能塞进 picker 的 range 里,需要的时候再拿 code 去原树里查。

value 的初始化必须和 range 的初始化配对:如果 range 第二列取的是 tree[0].children[0],那 value 初始值就是 [0, 0, 0]。如果某省的 children 为空数组,initPickerRange 里 range[1] 会是 [],此时 value[1] 不能是 0,否则 picker 会把一个不存在的索引当成合法选中项,显示上会出现一把空白或 undefined。所以上面的代码里,city 取不到时 range 第二列直接给空数组,value 就交给调用方保持 [0, 0, 0],后续 columnchange 里再做保护。

到这里,三列数据已经能显示出来,但联动还差「省一变、市和区跟着换」这一层,这块是 columnchange 的核心职责,放下一章展开。这里想强调的只有一点:树结构是给人看的,picker 只认二维数组,中间这层「平铺」转换不能省,也别想着把一个两层嵌套对象直接塞给 range,小程序不会帮你解析。

3. picker 联动实现:columnchange 分支与索引重置的先后顺序

3.1 WXML 模板与 picker 属性配置

联动要响应的是滑动停止那一刻,对应的是 bindcolumnchange 事件,而不是 bindchange。bindchange 是用户点「确定」按钮才触发,如果联动写在 bindchange 里,省列滑动时市列不会实时刷新,等确认了才一次性改,交互上是残废的。所以模板里必须同时监听 columnchange 和 change,前者管联动刷新,后者管确认取值。

WXML 部分代码如下:

<picker mode="multiSelector" range="{{regionRange}}" value="{{regionIndex}}" range-key="name" bindcolumnchange="onRegionColumnChange" bindchange="onRegionChange" > <view class="region-picker-btn">{{regionText}}</view> </picker>

对应的 data 初始结构是:

Page({ data: { regionTree: null, regionRange: [[], [], []], regionIndex: [0, 0, 0], regionText: '请选择省 / 市 / 区', }, onLoad() { // resource 里封装了获取 regionTree 的逻辑 const tree = getRegionTree() const range = initPickerRange(tree) this.setData({ regionTree: tree, regionRange: range, regionIndex: [0, 0, 0], regionText: `${tree[0].name} / ${tree[0].children[0].name} / ${tree[0].children[0].children[0].name}`, }) }, })

需要注意 picker 的 value 一开始是 [0, 0, 0],但 range[1] 和 range[2] 不一定都有数据。如果第一省恰好是直辖市,tree[0].children[0].children 是 null,range[2] 就是空数组,此时 value[2] 这个 0 其实是无意义的,picker 不会报错但会显示一个空白项。这个问题在第 5 章避坑部分有专门的处理方案,这里先记住:value 的每一项必须对应 range 对应列的真实存在索引,哪怕那列是空的,也不要往 value 里塞一个不存在的值。

3.2 联动逻辑:省变重置市和区,市变只重置区

onRegionColumnChange 收到的 e.detail 里有 column 和 value 两个字段,column 是当前滑动的列序号(0 省、1 市、2 区),value 是这一列当前选中的索引。注意这个 value 不是完整的 regionIndex,只是当前列的新值,必须和原有索引拼起来才能得到完整状态。

onRegionColumnChange(e) { const col = e.detail.column const val = e.detail.value // 拷贝一份当前索引,不能直接改 this.data.regionIndex const idx = this.data.regionIndex.slice() idx[col] = val const tree = this.data.regionTree const province = tree[idx[0]] if (!province) return // range 也只更新有变化的部分 const nextRange = this.data.regionRange.slice() if (col === 0) { // 省变了,后面两列索引全部重置 const cities = province.children || [] idx[1] = 0 idx[2] = 0 nextRange[1] = cities.map((c) => ({ name: c.name, code: c.code })) const firstCity = cities[0] const districts = firstCity && firstCity.children ? firstCity.children : [] nextRange[2] = districts.map((d) => ({ name: d.name, code: d.code })) } else if (col === 1) { // 市变了,只重置区 const city = (province.children || [])[idx[1]] const districts = city && city.children ? city.children : [] idx[2] = 0 nextRange[2] = districts.map((d) => ({ name: d.name, code: d.code })) } // col === 2 时不需要更新任何列,索引本身已经是用户选的区 this.setData({ regionIndex: idx, regionRange: nextRange, }) this.updateRegionText(tree, idx) },

这里有个容易想岔的点:当 col === 0 时,idx[1] 和 idx[2] 重置为 0,下一列的内容同步换成新省的第一个市和第一个区。但 picker 内部在 range 替换后,会再触发一次 columnchange,这次 col === 1、value === 0。不用担心,走第二次分支把 idx[2] 重置为 0 并重新计算 range[2],和第一次的结果一致,只是多了一次 setData。真正要防的是在 columnchange 里去读 this.data.regionRange,那时上一次 setData 可能还没生效,读到的还是旧 range,必须把 nextRange 在这一次事件里算好再一起 setData。

updateRegionText 是把当前索引转成文字展示在按钮区域:

updateRegionText(tree, idx) { const province = tree[idx[0]] const city = (province && province.children) ? province.children[idx[1]] : null const district = city && city.children ? city.children[idx[2]] : null const text = [province?.name, city?.name, district?.name].filter(Boolean).join(' / ') this.setData({ regionText: text || '请选择省 / 市 / 区' }) },

filter(Boolean) 这步是给直辖市准备的,当 district 是 null 时,文本只显示「省 / 市」两级,不拼一个空字符串进去。bindchange 里拿到的是最终的 idx,直接复用 updateRegionText 即可,同时把 regionIndex 存一份,确认时读取或用 code 快照存后端都可以。

3.3 回显初始化:从地址字符串反推三维索引

编辑场景下,后端存的是「省 / 市 / 区」的文本组合,也可能是三个 code。如果是 code 串,第 6 章有一个更稳的做法;这里先讲文本反推,因为它是最常见也最原始的方案。逻辑就是三层循环:先找到省名,进了省再看市名,最后匹配区名。

// 返回 [省索引, 市索引, 区索引],找不到返回 null function findIndexesByText(tree, provinceName, cityName, districtName) { for (let i = 0; i < tree.length; i++) { const p = tree[i] if (p.name !== provinceName) continue const cities = p.children || [] for (let j = 0; j < cities.length; j++) { const c = cities[j] if (c.name !== cityName) continue // 两级地区没有区名,直接给 0 if (!districtName) return [i, j, 0] const districts = c.children || [] for (let k = 0; k < districts.length; k++) { if (districts[k].name === districtName) return [i, j, k] } } } return null }

这套三层循环在数据量不大时性能完全没问题,全国大约 34 个省级、300 多个市级、3000 个区县级,单次查找在几毫秒内。真正的坑不在性能而在同名区县多:「高新区」「开发区」这种名字一个省里能出现十几次,只凭文本反查会命中错误的那一个。所以回显时如果后端能返回 code,优先用 code 反查,文本匹配只做兜底。回到调用侧:

const indexes = findIndexesByText(tree, provinceName, cityName, districtName) if (indexes) { this.setData({ regionIndex: indexes }) this.updateRegionText(tree, indexes) } else { // 匹配不上时保持原样,不强行归零到第一省 wx.showToast({ title: '地址区域异常,请手动选择', icon: 'none' }) }

注意这个 if-else 分支设计:很多实现里反查失败直接默认 [0, 0, 0],结果用户辛辛苦苦编辑的地址在重新进入页面后神秘地变成了第一省第一市第一区,这就是第 5 章要展开的「悄悄改掉」问题。反查不到就保留原文本显示,并要求用户重新选择,比默认归零诚实得多。

4. 异步加载与缓存:省市区按需请求的时序控制

4.1 三级表拆分:为什么不全量塞进代码包

把全国数据一次性塞进小程序代码包能省很多事,但一个完整区县级数据的 JSON 大概要 100KB 到 300KB,这还没算乡镇街道。代码包有体积限制,而且就算塞得进去,Page 里一次性 setData 大对象会让真机首屏卡顿。更合理的做法是拆成三张表:省列表、市列表、区列表,全部后端化,页面启动只拉省,选中省后拉对应市,选中市后再拉对应区。

资源包里封装的请求函数是 Promise 化的 wx.request,调用方式如下:

function fetchRegion(url, code) { return new Promise((resolve, reject) => { wx.request({ url: `${API_BASE}${url}`, data: { code }, success: (res) => { if (res.data && res.data.code === 0) { resolve(res.data.data) } else { reject(res) } }, }) }) }

加载省列表并初始化的过程:

async onLoad() { const provinces = await fetchRegion('/region/provinces', '') this.setData({ provinceList: provinces.map((p) => ({ name: p.name, code: p.code })), }) }

这里 setData 只放当前需要的列表,省、市、区三列变量分别是 provinceList、cityList、districtList,columnchange 里再根据选择去请求下一级,而不是一次性把整棵树的 range 准备好。异步加载的好处是首屏轻、后续按需,坏处就是第 4.3 节要讲到的竞态问题。

4.2 内存缓存与请求去重:避免每次滑动都拉接口

如果用户在省 A 和省 B 之间反复切换,每一轮切换都会触发一次市列表请求,这些请求大部分是重复的。常见做法是在内存里放一个 Map,按 provinceCode 缓存市列表、按 cityCode 缓存区列表;页面关闭缓存自然释放,不需要持久化到 Storage。

// 资源包 cache.js 的核心逻辑 const cacheMap = new Map() function getRegionCache(key) { return cacheMap.get(key) || null } function setRegionCache(key, data) { cacheMap.set(key, data) }

缓存何时用、何时不用,边界如下:

缓存键含义失效策略
city_{provinceCode}某省下的市列表页面关闭即失效
district_{cityCode}某市下的区列表页面关闭即失效

只在页面生命周期内做缓存,好处是不用处理 Storage 的过期问题。有过一版把市列表存到了 Storage,结果某省新增了一个县级市,用户不清缓存就永远看不到新区划,这种线上反馈非常难排查,后来干脆全部改成内存缓存。加载市列表的完整代码如下:

async loadCityList(provinceCode) { const cacheKey = `city_${provinceCode}` const cached = getRegionCache(cacheKey) if (cached) { this.setData({ cityList: cached }) return } const cities = await fetchRegion('/region/cities', provinceCode) setRegionCache(cacheKey, cities) this.setData({ cityList: cities }) }

这里 setData 的字段是 cityList,和 range 模式不同。range 模式是把三列塞在一个数组里整体更新,而异步加载通常不维护一个连续的三列 range,而是维护三个独立字段,columnchange 只更新对应的那一列。两者写法差异不影响 picker 绑定,range 是二维数组的写法,cityList/districtList 则需要你自己在 columnchange 里把 range 拼出来。资源包里的示例混用了两种写法,读的时候注意区分,确认一下 range 的组装位置在哪。

4.3 竞态处理:快速滑动时旧请求不能覆盖新选择

异步加载最容易翻车的场景是用户快速滚动省列,从省 A 滑到省 B,再滑到省 C,这个过程中会连续发出多个请求。网络返回顺序不保证,可能省 A 的市列表最后才回来,此时用户界面已经停在省 C,但 setData 把省 A 的市列表填了进去,结果省 C 下面显示省 A 的城市,这就是典型的竞态。

解决思路是给每次请求打一个递增的序号,setData 之前校验这个序号是否还是最新的,代码实现如下:

Page({ _requestSeq: 0, async onSelectProvince(provinceCode) { const seq = ++this._requestSeq const cities = await fetchRegion('/region/cities', provinceCode) // 如果期间用户又选了别的省,seq 和最新的不一致,丢弃这次结果 if (seq !== this._requestSeq) return setRegionCache(`city_${provinceCode}`, cities) this.setData({ cityList: cities }) }, })

这个方案的细节在于++this._requestSeq必须放在 await 之前,这样每次发起请求都会把旧请求的 seq 变得不再匹配。还有一种等价写法是在 setData 前把当前选择的 provinceCode 和发起请求时传入的 provinceCode 做比较,但用自增序号更通用,因为有些场景下两次请求可能来自同一省份的不同编码,校验字符串容易漏判。

市列和区列也要同样处理,不能只保护省一级。想象用户选了省 A,又快速切换省 A 下的市 X 和市 Y,最后停留在市 Y,市 X 的区列表请求晚回来一样会覆盖区列。所以每个异步列都用一把独立的 seq 还是共用一把,取决于联动层级,我习惯是每个列单独一把 seq:省的请求和市的请求互不干扰,列内保证「后发请求优先」。共用一把 seq 有个 bug:省还没返回时市列也发了请求,省的 seq 把市的校验也拦截了,角色混在一起,容易莫名其妙丢响应,所以分开更稳。

5. 避坑与排查:索引越界、空白列与回显翻车的五个案例

5.1 现象:市和区显示成上一个省的数据

选省 A 之后市列、区列还保持省 B 的内容,这在一个初版组件里经常出现。原因是 columnchange 只改了省那一列的索引,后两列索引没有归零,range 也没有按新省重新组装。picker 的 value 指向的是「原数组的索引」,省变了以后市数组整个被替换,原来的索引 2 在新数组里可能是完全不同的市。

解决方法是 col === 0 的分支里必须同时重置 idx[1] 和 idx[2],并重新计算 nextRange[1] 和 nextRange[2]。注意顺序:先重置索引,再重算 range,最后 setData 一起提交,不能在计算 range 时引用一个还没重置的旧索引。

5.2 现象:直辖市第三列空白,还占着位置

选到直辖市后,picker 第三列是空的,滚动时能看到一个没有文字的空白项。原因是直辖市的数据结构里省直接挂区,市这级是虚拟的,但 range 依然初始化了三个数组,其中第三个数组是空数组,picker 照常渲染第三列。

解决方法是把直辖市的「区」挂到省下面的同一个 children 里,市这级处理成占位节点;同时联动逻辑里判断 districts 为空数组时,把 range[2] 置空,value[2] 保持 0 但兜底文案不拼接。还有一种做法是动态把 range 变为两列,第 6 章会给出进阶写法,这里先保证数据不报错。

5.3 现象:用户选好的地址,刷新后被悄悄改掉

一个特别隐蔽的坑:用户选的是某个开发区,后端存的是「省 / 市 / 开发区」文本,下次进入页面反查索引时,因为开发区在数据里匹配到了第一个同名的,选中的是另一个区,页面显示也变成了那个区。用户会非常困惑,觉得系统擅自改了自己的地址。

解决思路在 3.3 里已经提过:反查不到不强行归零,匹配到同名时也不保证唯一。最稳妥的是后端改为存三个 code,前端拿到 code 直接定位索引。如果存量数据只有文本,第一次反查时记录下匹配到的 name 和 code,提交时把 code 存下来,以后回显优先用 code,文本匹配只作为兜底。

5.4 现象:快速滑动后省份和城市对不上

省显示的是省 C,市列表却是省 A 的,原因是异步请求返回乱序,旧请求覆盖了新选择。这个问题在真机弱网环境下最明显,本地开发很难复现,一旦上线就会有用户投诉,属于典型的「测不出但线上有」问题。

解决方法是按列维护自增序号,await 返回后校验序号是最新一次请求才 setData,否则直接丢弃。不要试图用 loading 遮罩挡住用户滑动,picker 的滚动本身是流畅的,遮一层 Toast 或 loading 反而让交互变卡,用户体感更差。序号校验是无感知的,正确的做法就是让它悄悄丢掉旧数据。

5.5 现象:真机打开页面白屏几秒,或 picker 滑动掉帧

一次把所有省市区数据全部 setData 进页面,数据量大到真机渲染承受不住,省列滚动时就掉帧。小程序 setData 走的是逻辑层到渲染层的消息通道,大数据量大对象传输本身就是性能瓶颈。

解决方法是把树结构挂在 this 上而不是 data 里,data 里只放当前可见的省、市、区三列数组,每一列控制在几十条以内。响应式数据只有 range 变化那部分发生变更,消息包变小,渲染自然就快了。如果坚持用 range 模式,建议把树挂到 this.regionTree,range 始终只取当前省市的子集。

6. 进阶技巧:用 code 快照做回显,两级地区自动藏掉第三列

6.1 code 快照替代文本匹配

文本反查索引的所有问题,根源都在「同名」和「字符串容错」上。改掉它最彻底的办法是改变存储格式:用户确认选择时,把省、市、区三个 code 用竖线拼成一个字符串存下来,下次回显直接按 code 定位。

// 确认选择时生成快照 onRegionChange(e) { const idx = e.detail.value const tree = this.data.regionTree const province = tree[idx[0]] const city = (province.children || [])[idx[1]] const district = (city && city.children || [])[idx[2]] this.setData({ regionSnapshot: `${province.code}|${city.code}|${district ? district.code : ''}`, }) }

回显时按竖线拆开,每一段用 code 在树里定位。code 在行政区划里是唯一标识,不会出现同名区县撞车。找不到时还能很快判断出某一段已经是旧行政区划(并区、撤县),这时候提示用户重新选择,省得后台存了一堆失效地址也不知道。

6.2 按需收缩列数:让两级地区只显示两列

picker 的 multiSelector 列数是随 range.length 走的,所以把 range 临时变成两列,界面就会隐藏第三列。在 columnchange 里判断选中省份是直辖市(其市级的 children 为 null)时,把 range 和 value 都缩成两项:

// 省变化时按省类型决定列数 if (province.children[0] && province.children[0].children === null) { this.setData({ regionRange: [nextRange[0], nextRange[1]], regionIndex: [idx[0], 0], }) } else { this.setData({ regionRange: [nextRange[0], nextRange[1], nextRange[2]], regionIndex: [idx[0], 0, 0], }) }

这个写法有个要注意的细节:列数变化会再触发一次 columnchange,事件里的 column 和 value 是按变化后的列数传的,处理函数里要兼容「只有两列」的场景,不然代码里 col === 2 的分支会把 undefined 写进范围里。我是用一个this.data.regionRange.length判断当前列数,再做分支,而不是固定写死三个分支。

从那以后我每次做地址类组件,都强制把「code 快照 + 动态列数」放进去,文本匹配只留在兜底分支里,再也不愿意纯靠 name 来回显了。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询