做后台管理的同学大概率都遇到过这个需求用户注册时要选所在地或者录入门店地址时要填省-市-区产品经理一句做个三级联动你就得去翻一张像模像样的行政区划数据。很多人第一反应是找现成接口每次下拉都请求一次服务端也有人从某个不知名博客里复制了一份 2014 年的city.json字段还是Province、City首字母大写的粘进项目里跑通了就再也没管过。这两种做法我都试过第一种在弱网环境下选个地址要转三圈菊花第二种在用户反馈我是杭州市临平区的为什么列表里没有的时候你会非常被动。省市区三级联动的本质是一份静态的层级数据加上一个渲染组件的配合接口调用只是拿数据的搬运环节真正决定体验和可维护性的是你手里那份 JSON 的结构设计、字段取舍和更新机制。这篇就把我基于腾讯地图行政区划数据整理省市区三级联动 JSON 的完整过程写清楚数据从哪来、Key 怎么管、JSON 怎么设计、脚本怎么写、Vue 里怎么接、哪些行政区划的坑必须提前处理。读完之后你应该能自己拉一份干净数据、生成结构合理的 JSON 文件并且知道它上线之后会怎么坏。1. 为什么行政区划数据值得自己存一份静态 JSON先把一个反直觉的结论摆在前面省市区三级联动的数据99% 的场景都不应该实时请求接口。这套数据的变化频率是以年为单位的一次撤县设区、一次功能区转正全国加起来一年也就几十条。为了这几十条变化让每一个用户在每一次填地址时都打一次网络请求成本收益完全不对等。我最早的做法是服务端透传前端调我自己的/api/region/children?parentIdxxx我在服务端再去请求地图开放平台的接口。这套链路在测试环境跑得很顺上线之后就出问题了——高峰期接口 QPS 超限返回status: 121配额超限前端拿到空数组下拉框直接变成一片空白用户以为页面坏了。更尴尬的是地图平台的免费配额是按 Key 计的你前端每点一次省、市、区就是三次请求一个用户填完一条地址就要消耗 3 次配额日活一上来配额就是流水一样地掉。改成静态 JSON 之后链路变成构建期生成一份数据文件放进前端静态资源目录或者对象存储浏览器一次性加载之后所有展开、收起、搜索全在内存里完成。好处是显而易见的零接口依赖网络抖动、平台限流、Key 过期都不会影响地址选择。用户在飞机上填地址照样能选。响应是瞬时的展开省级列表不需要等待搜索南山可以做到输入即出结果因为数据已经在本地了。可控数据结构你自己定想加拼音字段就加拼音字段想加经纬度就加经纬度不用迁就平台返回的格式。可审计数据什么时候生成的、基于哪个版本、有哪些人工修正全部可以写进文件头或者版本号里。代价也很清楚你得自己负责数据的时效性。这是很多人忽略的地方。静态数据的风险不是能不能用而是用了一年之后还准不准。我的建议是把数据生成做成一个可重复执行的脚本放在仓库里每季度手动跑一次比对差异确认无误后更新版本号。这件事花不了十分钟但能避免用户投诉。还有一个容易被忽视的点数据体积。全国 34 个省级、300 多个地级、2800 多个县级单位如果每个节点都保留平台返回的全部字段id、name、fullname、location 经纬度、pinyin、cidx 等一份完整的 JSON 大概在 1.5MB 到 2MB 之间。看起来不大但它是同步阻塞渲染的资源如果直接塞在主 bundle 里首屏会被拖慢。所以后面第 7 节我会专门讲体积优化和缓存策略这里先记住结论能用但需要裁剪字段和合理分包。2. 腾讯地图行政区划接口的取数姿势与 Key 配额管理数据来源定了腾讯地图接下来就是怎么把数据礼貌、稳定地拿下来。腾讯地图 WebService API 里跟行政区划相关的主要是三个接口/ws/district/v1/list拉全部行政区划返回省级配合参数还能往下钻、/ws/district/v1/getchildren按父级 ID 拉子级、/ws/district/v1/search按关键词搜索。我们要的是前两个。2.1 接口返回长什么样别被 result 的嵌套数组绕晕/ws/district/v1/list返回的结构里真正的数据挂在result下面而result是一个数组的数组——即使只有一层数据它也是result[0]。这一点非常容易踩坑我第一次写解析脚本的时候直接data.result.forEach结果拿到的是一堆数组打印出来全是[object Object]的幻觉。单个行政区对象大概是这样{ id: 110000, name: 北京市, fullname: 北京市, pinyin: beijingshi, location: { lat: 39.90469, lng: 116.40717 }, cidx: [0, 1] }几个字段的用途字段说明我是否保留id行政区划代码6 位和统计口径基本一致保留作为唯一键name简称如北京市南山区保留用于展示fullname全称通常会带上上级如广东省深圳市南山区视情况保留做搜索很有用pinyin全拼无分隔保留做拼音搜索location中心点经纬度一般裁掉除非要做地图定位cidx层级索引裁掉无意义getchildren接口返回结构类似参数是id父级行政区划代码和key。注意一个关键限制这个接口一次只返回下一级不会递归返回孙子级。所以你要拿到省-市-区三级就必须写两层循环先拉省级列表然后对每个省级 ID 调一次getchildren拿到市级再对每个市级 ID 调一次拿到区县级。按全国规模算这个循环大概是 1省级 34市级轮次 340区县级轮次次请求也就是375 次左右的接口调用。这个量级如果用实时接口做肯定是不可接受的但作为一次性构建脚本完全可以接受。而且腾讯地图有批量接口可以一次传多个 id用逗号分隔能把这个数字压下来不少我会在脚本那节讲。2.2 Key 申请与配额别用来源不明的共享 Key热词里出现了腾讯地图 key 第三方共享平台这类词我必须先把话说清楚不要用来路不明的共享 Key。原因很实际——那类 Key 通常被几十上百个项目共用配额是公共池子你不知道什么时候就被别人刷光了你的构建脚本会在某个下午突然全部返回限流错误而且你完全查不到原因。更麻烦的是这类 Key 的调用来源不受你控制一旦因为异常调用被平台处置你连申诉的凭据都没有。正规做法是自己去腾讯位置服务控制台创建一个应用申请一个 WebService 类型的 Key然后在控制台里把 Key 的调用来源限制配好WebService 类型的 Key 一般绑定服务端 IP 白名单避免泄露后被滥用。构建脚本里 Key不要硬编码进仓库用环境变量读取脚本里写成os.environ.get(QQMAP_KEY)。跑脚本的时候加个 sleep比如每次请求间隔 200 到 300 毫秒别把接口当自家数据库猛敲。这点礼貌能显著降低被限流的概率。记录每次调用的返回状态遇到status ! 0就写日志并跳过不要让它污染最终数据。提示把 Key 写进前端代码里是最危险的做法。前端代码是公开的任何人 F12 就能抄走你的 Key第二天你的配额就没了。前端要用地图能力请走服务端代理或者用平台的前端 SDK 专用 Key 并配好域名白名单。3. JSON 结构怎么设计嵌套树、扁平表与字典索引三种存法数据拉下来之后真正决定后续开发体验的是结构设计。同一份数据我见过三种存法各有明确的适用场景选错了后期改起来很痛苦。3.1 嵌套树组件直接能吃的格式最常见的就是树形嵌套每个节点带children[ { code: 440000, name: 广东省, children: [ { code: 440300, name: 深圳市, children: [ { code: 440304, name: 福田区 }, { code: 440305, name: 南山区 } ] } ] } ]这是 Element Plus、Ant Design 这类组件库的级联选择器默认想要的格式接进去改几个 props 就能跑。优点是前端零转换成本缺点是检索成本高——你想根据 code 反查440305 是哪条路径就得递归遍历整棵树。而且树结构对增量更新不友好插入一个区县要定位到父节点再 push。3.2 扁平数组适合做反查和表格扁平结构就是把所有节点拍平用parentCode表达层级[ { code: 440000, name: 广东省, parentCode: 0, level: 1 }, { code: 440300, name: 深圳市, parentCode: 440000, level: 2 }, { code: 440305, name: 南山区, parentCode: 440300, level: 3 } ]这种结构特别适合后台管理场景数据库里存的就是province_code、city_code、district_code三个字段回显的时候用 code 去扁平表里查名字一次Map查找 O(1) 搞定。但它是组件库不认的格式前端要么自己转树要么维护两套数据。3.3 双份输出我的最终选择纠结了很久之后我采取了最笨但最省事的方案构建脚本同时输出两份数据——一份regions.tree.json给前端级联组件用一份regions.flat.json给反查和后台表格用。两份数据从同一个源生成不存在不同步的问题代价只是文件体积翻倍而 gzip 之后其实没差多少。如果你只想留一份我建议留树形然后前端启动时用一段不到二十行的代码把它拍平建索引function flatten(tree, parent null, acc []) { for (const node of tree) { acc.push({ code: node.code, name: node.name, level: node.level ?? (parent ? parent.level 1 : 1), parentCode: parent ? parent.code : 0, fullName: parent ? ${parent.fullName}${node.name} : node.name }); if (node.children node.children.length) { flatten(node.children, { ...node, level: parent ? parent.level 1 : 1 }, acc); } } return acc; } const flat flatten(tree); const codeMap new Map(flat.map(item [item.code, item])); // 之后 codeMap.get(440305).fullName 就是广东省深圳市南山区这里有个细节值得说fullName最好在构建期就算好别放到运行期拼接。因为构建期你手上有完整的父子关系拼一次就行运行期每次反查都要沿parentCode往上爬虽然不慢但没必要。3.4 字段命名短字段名到底值不值得有人为了压体积把字段名改成c、n、l这种单字母。我的建议是别这么干。原因是这份数据的绝对体积本来就不大后面算过裁剪后 gzip 大概 200KB 出头为了省几十 KB 把代码可读性毁掉维护时看到n.children[0].c会想骂人。真要压体积收益更大的手段是按省拆分、按需加载而不是缩字段名。4. 从接口原始数据到可用 JSON 的清洗脚本这一节是实操核心。脚本用 Python 写因为处理 JSON 和递归最顺手标准库就够不需要装额外的包。4.1 拉取带重试和限速的轮子先把请求封装好重点是重试、限速和状态校验import json import os import time import urllib.parse import urllib.request KEY os.environ.get(QQMAP_KEY) BASE https://apis.map.qq.com/ws/district/v1 def fetch(path, params, retry3): params {**params, key: KEY} url f{BASE}/{path}?{urllib.parse.urlencode(params)} for attempt in range(retry): try: with urllib.request.urlopen(url, timeout10) as resp: data json.loads(resp.read().decode(utf-8)) if data.get(status) 0: return data[result] print(f[warn] status{data.get(status)} msg{data.get(message)}) except Exception as exc: print(f[error] {exc} attempt{attempt 1}) time.sleep(0.5 * (attempt 1)) return None def children(parent_id): # getchildren 支持一次传多个 id用逗号分隔能省不少请求 result fetch(getchildren, {id: parent_id}) if not result: return [] return result[0] if result and isinstance(result[0], list) else result这段里有三个细节是我踩过坑之后加的。第一result的嵌套形状不稳定有时是[[...]]有时是[...]所以我用isinstance(result[0], list)做了一次兼容判断避免脚本在中途崩掉。第二重试次数别设太大三次够了地图接口偶发失败很正常但如果是 Key 或配额问题重试一百次也没用。第三不要对同一个父级 ID 并发轰炸串行加 sleep 看起来慢但脚本一跑十几分钟就结束了比被封强得多。如果需要拉取全国数据一次性的遍历大概长这样def crawl_all(): provinces fetch(list, {get_pinyin: 1})[0] tree [] for prov in provinces: node {code: prov[id], name: prov[name], level: 1, children: []} for city in children(prov[id]): city_node {code: city[id], name: city[name], level: 2, children: []} for dist in children(city[id]): city_node[children].append({ code: dist[id], name: dist[name], level: 3 }) node[children].append(city_node) time.sleep(0.25) tree.append(node) time.sleep(0.25) return tree几百次请求按 0.25 秒间隔算十几分钟能跑完。这个脚本不要放到 CI 里自动跑手动执行、人工确认因为行政区划数据的变化需要你判断是否合理而不是无条件覆盖。4.2 清洗去重、补层级、修虚拟节点原始数据直接转成 JSON 是能用的但会有一堆让你在界面上尴尬的东西。我在清洗环节主要做四件事按 code 去重。同一份数据里偶尔会出现重复节点尤其是跨层级拉取时接口有重叠去重能避免级联选择器里出现两个南山区。补level字段。组件不一定用得上但你的反查逻辑和后台校验会用到。处理虚拟层级。直辖市下面的市辖区、省直辖县级行政区下面的省直辖县级行政区这类节点是数据源为了保持层级一致性造的壳前端展示时应该跳过。我是在构建期打标记加一个virtual: true让前端自己决定跳不跳。修fullname拼接。数据源给的fullname有时带空格、有时省略我干脆全部自己拼保证格式统一。去重和建索引的代码不复杂但要注意遍历时不要边改边迭代def dedupe(tree): seen set() def walk(nodes): out [] for n in nodes: if n[code] in seen: continue seen.add(n[code]) if n.get(children): n[children] walk(n[children]) out.append(n) return out return walk(tree)4.3 输出写文件的同时记录元信息最后一步是落盘。我习惯在文件里塞一个_meta节点记录生成时间、数据来源和版本号。这不影响组件使用遍历时跳过以下划线开头的键就行但对后期排查价值极大——用户投诉你们数据错了你打开文件一眼就知道这份数据是去年三月份生成的答案立刻有了。meta { source: tencent-map-district, generated_at: time.strftime(%Y-%m-%d), version: 2026.01, count: {province: n1, city: n2, district: n3} } with open(regions.tree.json, w, encodingutf-8) as f: json.dump(tree, f, ensure_asciiFalse, separators(,, :))ensure_asciiFalse必须加否则中文会变成\u5e7f\u4e1c\u7701虽然体积小一点但完全没法人工检查separators去掉空格能省 5% 到 8% 的体积。5. Vue 里落地三级联动Element Plus 级联选择器的完整接法数据有了前端接起来其实很快但细节比想象中多。这里以 Element Plus 的el-cascader为例Vue 3 环境。5.1 全量加载最省心的方案template el-cascader v-modelregionPath :optionsoptions :propscascaderProps filterable clearable placeholder请选择省 / 市 / 区 changehandleChange / /template script setup import { ref, onMounted } from vue; const options ref([]); const regionPath ref([]); const cascaderProps { value: code, label: name, children: children, emitPath: true }; onMounted(async () { const res await fetch(/static/regions.tree.json); options.value await res.json(); }); function handleChange(value) { // value 形如 [440000, 440300, 440305] console.log(value.join(/)); } /script几个参数必须说清楚。emitPath: true表示v-model拿到的是完整路径数组后台存三个字段的场景就靠它如果你只关心最后一级的 code把它设成false。checkStrictly默认是false意味着只能选到叶子节点——三级联动里这通常正是你要的但如果业务允许用户只选到市一级比如某些物流场景就得打开它让父节点也可选。filterable打开之后组件自带搜索但它只匹配label拼音搜索要自己实现后面讲。5.2 按需加载省流量的做法如果项目对首屏体积敏感可以不走全量加载改成懒加载const cascaderProps { value: code, label: name, lazy: true, lazyLoad(node, resolve) { const { level, value } node; if (level 0) { resolve(provinceIndex); // 只加载省级约 34 条 } else { resolve(index[value] || []); // 从预下载的分省文件里取 } } };注意这里的lazyLoad我并没有真的发请求而是从一个已经下载好的索引里取。因为如果把 34 个省级文件拆开按需下载用户从省展开到区县要做两到三次请求弱网体验反而更差。懒加载的价值主要是减少初始解析和渲染的节点数不是减少请求数这一点别搞混。真正要减请求还是全量加载一份 gzip 后的文件最划算。5.3 回显后台编辑场景的经典问题编辑一条已有地址时你需要把三个 code 变成级联选择器认识的路径数组。如果后台存的是province_code/city_code/district_code直接[p, c, d]塞给v-model就行。但有个坑如果数据更新后某个 code 已经不存在了比如某个区被合并了级联选择器会显示空白而且不报错。用户看到请选择以为系统出 bug 了。我的处理方式是在回显前做一次校验找不到就降级显示保留的文本而不是静默失败function buildPath(p, c, d) { const segs [p, c, d].filter(Boolean); let nodes options.value; const path []; for (const code of segs) { const hit nodes.find(n n.code code); if (!hit) return { found: false, path: segs, fallback: segs.join(/) }; path.push(hit.code); nodes hit.children || []; } return { found: true, path }; }这个函数返回的fallback可以直接在界面上展示成一段灰色文字让用户知道旧数据里有个已经不存在的地名需要重新选一次。比白屏友好太多。5.4 拼音搜索过滤默认搜索的短板组件自带的filterable只匹配label用户输入sz想找深圳它不会认。解决办法是在构建期给每个节点加上pinyin和initial字段然后自定义filter-methodconst filterMethod (node, keyword) { const kw keyword.toLowerCase(); return ( node.text.includes(keyword) || (node.data.pinyin || ).includes(kw) || (node.data.initial || ).includes(kw) ); };initial就是拼音首字母比如深圳市是szs。这个字段我自己在构建期算的用的是pypinyin库。多花一行依赖换来用户这个搜索真好用的评价很值。6. 那些年踩过的行政区划坑直辖市、省直辖县与数据漂移这部分是我认为比代码更值钱的内容。行政区划数据不是一棵整齐的树它的不整齐是有历史原因的不了解这些你会在上线后被各种奇怪的反馈缠住。6.1 直辖市和市辖区这层壳北京、上海、天津、重庆四个直辖市在行政区划体系里是省级单位。但它们的实际结构是北京市 → 市辖区 → 东城区中间那层市辖区是个虚拟节点。如果你老老实实把三级都展示出来用户会看到一个荒谬的下拉省选北京市选市辖区区选东城区。这显然不合理。正确的做法是在构建期识别这类虚拟节点把它标记出来前端渲染时遇到就跳过、直接展开下一级。识别方法有两种一是按名字匹配市辖区县省直辖县级行政区自治区直辖县级行政区等固定字符串二是按 code 判断——虚拟层级的 code 通常形如110100直辖市下的市级代码而真实地级市的 code 第二位不会是 0 之后的固定模式。我建议用名字匹配加白名单的方式简单可靠VIRTUAL_NAMES {市辖区, 县, 省直辖县级行政区, 自治区直辖县级行政区} def mark_virtual(nodes): for n in nodes: if n[name] in VIRTUAL_NAMES: n[virtual] True if n.get(children): mark_virtual(n[children])前端拿到virtual: true的节点在级联组件的lazyLoad或自定义渲染里直接把它替换成它的children。这一步做不做直接决定了你的组件是能用还是好用。6.2 省直辖县级行政区四不像的一层比直辖市更绕的是省直辖县级行政区。比如河南省的济源市、湖北省的仙桃市和潜江市、海南省的一批县级市它们名义上归省管但实际上自己就是县级单位。数据源为了把它们挂到树上会造一层省直辖县级行政区于是结构变成河南省 → 省直辖县级行政区 → 济源市。对用户来说他只想选河南省-济源市两级。这和直辖市是同一类问题用同一套虚拟节点标记方案就能解决。但后台存储时要注意这类地方的city_code存什么我的做法是让city_code等于虚拟层的 codedistrict_code存济源市的 code这样三层字段的语义保持一致虽然中间那层的值看起来有点怪但反查逻辑不变。6.3 开发区与非标准行政区划的口径差异这是最容易被忽略的坑。地图开放平台的行政区划数据里有时会包含一些功能区比如某个高新技术产业开发区、某个经济开发区。它们在管委会层面是实体但不在统计口径的标准行政区划序列里。后果是用户在某个下拉里找不到某个区或者反过来看到了一个自己没听说过的区。前者通常是因为你用的数据源是旧的那个区已经挂牌但数据没更新后者通常是因为数据源把功能区也算进去了。我的处理原则是以用户能填的地址为准而不是以行政序列为准。如果有人反馈某个新区找不到我会去核实确认是正式行政区划就加入白名单是功能区就人工排除。这一步不要指望脚本自动化人工确认几十条最稳妥。这也再次说明了第 4 节那个构建脚本手动执行的建议为什么重要。6.4 数据漂移与历史地址行政区划会变。撤县设区、区县合并、更名每年都有。如果你的系统里有历史订单用户三年前填的是旧名称今天用 code 去反查可能查到的是一个已经改名的新名称或者干脆查不到。处理方案有两种取决于业务重要性。轻量的做法是保留一份历史映射表把旧 code 映射到新 code 或者保留旧名称反查时优先查这份表。重量的做法是在订单表里同时存 code 和当时的名称快照——这个我认为是必须的因为地址名称本身就是业务数据的一部分不应该依赖外部数据源还原。提示任何涉及行政区划的外部数据都不要只存 code 不存名称。数据源一变你的历史订单地址就全错乱了而且无法恢复。7. 体积、缓存与更新让 JSON 文件在生产环境跑得稳数据能用只是第一步跑在生产环境还要考虑加载性能和更新机制。7.1 裁剪字段后体积到底多少我实测过一份裁剪后的全国数据只保留code、name、children加level和virtual标记。JSON 原文大概是 900KB 左右用 gzip 压缩服务器自动开后降到约 220KB。这个体积对一个正常的 Web 应用来说完全可以接受它比一张未优化的大图还小。如果你想再压有两个方向一是把code从字符串改成数字能省掉引号二是按省拆分全国 34 个省级文件用户选了省再加载对应文件。第二种做法我不太推荐因为它把一个同步问题变成了异步问题弱网下体验反而变差。除非你的应用只在少数几个省使用那拆分就非常划算。方案原始体积gzip 后加载时机适用场景全量单文件约 900KB约 220KB首屏大部分后台与业务系统按省拆分为 34 个文件单文件 20~80KB单文件 6~25KB选省后加载单省业务、移动端只保留关注的省市可变极小首屏内网系统、定向业务实时接口无无每次展开基本不推荐7.2 缓存策略用文件名做版本号静态 JSON 最怕的是浏览器缓存了旧版本数据更新后用户拿不到新数据。解决方案很成熟文件名带版本号比如regions.tree.2026.01.json同时给它配上超长的Cache-Control: max-age31536000, immutable。数据更新时改文件名HTML 引用的路径随之改变浏览器自然拉新文件。旧文件可以保留一段时间再清理。不要用?v202601这种查询串做版本号部分 CDN 对带查询串的资源缓存策略不一致容易出现有的节点命中新版本、有的命中旧版本的诡异情况排查起来非常痛苦。7.3 更新机制把什么时候更新也写进流程最后说一个流程上的建议。很多人把数据生成脚本写好了但没人负责跑它。半年后出问题翻出脚本发现 Key 已经换了、接口路径改了、依赖也没了。我的做法是在仓库里放一个docs/region-data.md写清楚四件事数据来源、生成命令、Key 的获取方式与来源限制、上次更新时间和更新原因。再配合一个简单的检查清单——每年年底或每次收到地址相关的用户反馈时跑一次脚本用diff比对旧新数据把差异列出来人工确认。差异通常只有几条扫一眼就能判断。确认后更新版本号、提交、发布整个过程不超过二十分钟。至于前端那个 component 本身基本不需要跟着数据变。真正需要你持续关注的只有第 6 节说的那几种非标准节点以及那些会因为行政区划调整而消失的旧 code。我在实际项目里维护这份数据大概三年最大的感受是它的问题几乎从来不出在技术上而是出在没人记得它需要更新上。把它当成一个需要定期维护的小资产而不是一次性的技术方案这件事就变得非常简单了。