腾讯地图行政区划JSON清洗与Vue省市区三级联动实现

发布时间:2026/10/1 4:13:16
腾讯地图行政区划JSON清洗与Vue省市区三级联动实现
做表单的人迟早会撞上省市区三级联动这个需求。第一次遇到的时候我以为这是个纯前端问题,从网上扒一份现成的 JSON 包,塞进级联选择器,十分钟收工。真做下去才发现,组件那部分确实只花了十分钟,剩下的时间全耗在数据上:这份数据是哪一年的、code 对不对得上、直辖市长成什么样、省直辖县级市挂在哪一层、用户在数据库里只存了一个代码我拿什么反查出广东省 深圳市 南山区。我手上这个后台系统要录入用户常住地址,精确到区县,还要跟已有订单数据做地域维度统计。一开始用的是一份流传很广的省市区三级联动 JSON,跑了一个多月,报表里出现了两个名字指向同一片区域的情况——一个是地级市更名没同步,另一个是撤县设区之后代码变了但数据没更新。那之后我把数据源换成腾讯地图的行政区划接口,自己写脚本拉一份原始数据下来做清洗,生成一份结构统一、体积可控的 JSON 文件。这套流程到现在跑了快两年,中间只重新拉过三次数据。下面把整条链路完整拆开:数据从哪里来、原始返回长什么样、JSON 结构该怎么设计、清洗脚本怎么写、Vue 里怎么接最省事,以及那些只有真正踩过才知道的细节。1. 数据源选型:为什么最后落在腾讯地图的行政区划接口1.1 三类常见数据源的横向对比能拿到省市区三级数据的路子其实就那么几条,我把它们放在一起对比过,结论比较明确。数据源类型新鲜度字段完整度获取成本能否离线网上流传的静态 JSON 包差,多数停留在两三年前只有名称和代码零能行政区划代码标准文件转换中,随标准更新只有代码和名称需要自己写转换能地图服务商行政区划接口好,随地图数据同步含名称、全称、坐标、拼音、父子索引需要申请密钥,有免费额度拉取后可离线静态 JSON 包最大的问题是没人维护。你在某个资源站下载到的文件,大概率是某个人几年前从某处导出后随手传上去的,他自己也不知道数据是哪一版。一个地级市更名或者一个县撤县设区,这份文件不会自己更新,而这类调整每年都在发生。等到某天用户在表单里找不到自己的区,你才反应过来,那时候改起来就是全量替换加历史数据兼容,成本高得多。行政区划代码文件的好处是有官方口径,但缺点同样明显:它是给统计和编码用的,里面包含很多不在民政区划序列里的单位,而且没有层级索引,你得自己根据代码规则去推断谁是谁的上级。更麻烦的是它拿不到坐标,如果你的业务里有根据所选区域定位到地图中心点这类需求,还得再找一份坐标表来关联。1.2 腾讯地图接口能给你的东西腾讯地图的行政区划接口会一次性返回全国的三级行政区划数据,不需要你逐级请求。返回结果里每一个节点的字段大致包含这几类:id:行政区划代码,六位数字字符串name:简称,比如南山区fullname:全称,比如广东省深圳市南山区location:中心点经纬度,形如{lat: 22.53, lng: 113.93}pinyin:拼音数组,部分层级带这个字段,用来做搜索cidx:子节点索引范围,格式是[起始下标, 结束下标]这个cidx是整个接口里最关键也最容易看漏的字段。它不是一个 ID 引用,而是一个数组下标区间,告诉你我的孩子在下一级那个大数组里从第几号排到第几号。后面会专门讲它的坑。申请密钥的流程不复杂:进腾讯位置服务的控制台,创建一个应用,添加一个 key,服务类型勾选 WebService API。创建好之后会拿到一串字符,调接口的时候作为key参数带上。免费额度对个人项目和小型后台来说完全够用,一份全国的区划数据一年也就拉几次,根本用不完。1.3 关于共享密钥这件事,想多说两句网上有些所谓的共享平台会放出别人申请好的密钥,号称直接用不用申请。我不建议你走这条路。理由很实际:第一,这些密钥随时可能被原主人关掉或者被平台风控拉黑,你线上的功能会在毫无预兆的时候挂掉;第二,你的请求量会算在别人的额度上,哪天对方被刷爆了,你的服务跟着一起停;第三,密钥本身就是身份凭证,来源不明的凭证带到生产环境里,风险不好控。自己花几分钟申请一个,把密钥放在服务端环境变量里,前端永远不直接持有密钥。这个习惯值得养成,后期做灰度、做配额监控、做用量告警都会方便很多。2. 原始返回结构拆解:三段平铺数组和 cidx 指针2.1 result 里装的是三个大数组接口返回的最外层是一个状态信封,大概长这样:{ status: 0, message: query ok, result: [ [], [], [] ] }status为 0 表示成功,非 0 的时候message里会有原因,常见的是密钥配置不对、密钥未启用对应服务、请求超限这几种。result是一个长度为 3 的数组,三个元素依次是省级列表、市级列表、区县级列表。注意,它们都是平铺的一维数组,不是嵌套结构。也就是说市级列表里同时装着全国所有城市,区县列表里同时装着全国所有区县,谁也不包含谁。这种设计的动机很好理解:数据量小,传输时不用重复包装层级,解析也快。代价是调用方必须自己还原父子关系,而还原的依据就是每个节点上的cidx。2.2 cidx 是闭区间,而且只在有子节点时出现假设省级数组里广东省这个节点带的cidx是[180, 200],意思是从市级数组的下标 180 开始,一直到下标 200 结束,这 21 个市都属于广东省。注意它是闭区间,end那一项也是要算进去的,循环写成cities.slice(180, 201)才对。另一个容易忽略的点:不是所有节点都带cidx。区县是最底层,自然没有;有些市级节点如果下面没有区县数据,也可能不带这个字段。你的解析代码必须对cidx缺失的情况做判断,否则会直接抛undefined is not iterable这种异常。2.3 只靠 cidx 还原会遇到的三种情况写第一版脚本的时候我完全依赖cidx,结果跑出来发现有三类节点丢了。后来逐个排查才知道原因。第一种是直辖市。北京、上海、天津、重庆在省级数组里是一个条目,在市级数组里还有一条同名的市级条目,区县挂在市级条目下面。如果你只是简单地按 cidx 一层层往下走,数据其实是对的,但前端展示的时候用户会看到北京市 北京市 东城区这种尴尬的三层结构,需要做扁平化合并。第二种是省直辖县级行政区。比如某些省份下面的县级市,它行政上归省直管,不隶属于任何地级市。在数据里可能表现为省级节点的 cidx 直接指向区县数组,也可能在这一级出现一个叫省直辖县级行政区划的容器节点。这两种形态我都见过,取决于数据版本。第三种是数据本身的空洞。某些市下面确实还没有细分到区县,或者区县数据暂时缺失,这时候市级节点的 cidx 会不存在。如果你在前端无条件展开下一级,就会得到一张空面板。结论是:别把 cidx 当成唯一真相,它是个有用的快捷方式,但一个基于代码前缀的合并策略更稳,而且容错性更好。具体做法在第 4 章展开。3. JSON 结构设计:字段命名、层级与体积控制3.1 我最终采用的结构原始接口字段挺全,但对前端来说有明显冗余:fullname可以用层级拼出来,location大部分场景用不上,pinyin只有搜索场景需要。全量保留会让文件从三百多 KB 涨到一兆以上,打进前端包里就是实打实的首屏负担。我最后落盘的是一棵干净的树,节点只保留两个业务字段:{ code: 440305, name: 南山区, children: [ { code: 440305001, name: 南头街道 } ] }如果你只需要三级,把最底层的children去掉即可。如果你要四级(街道/乡镇),接口的区县数据里通常不含这一级,需要另外找数据补进来,这里不展开。children字段的一个细节:叶子节点不要留空数组。我见过太多级联选择器因为叶子节点带了children: []而在最后一级弹出空白面板,用户以为卡了。清洗脚本里加一步递归,把空的children键直接删掉,几行代码的事,但省掉不少解释成本。3.2 用 code/name 还是 value/label不同组件库对字段名的默认约定不一样。Element Plus 的级联选择器默认读value和label,Ant Design 的 Cascader 默认读value和label,一些移动端组件库则要求text和value。于是有人主张干脆把 JSON 生成成组件默认的样子,省去 props 映射。我个人的做法是数据层保持中立,用code和name,在组件上通过 props 配置做映射:el-cascader :props{ value: code, label: name, children: children } /理由是数据文件的寿命比组件库长得多。三年后你把项目从 Element 换成别的库,或者同一份 JSON 要同时给 Web 端和移动端用,中立的字段名就不用改数据,只改配置。反过来说,如果你的数据文件只服务一个固定的前端项目,那怎么省事怎么来,没人会因为这个批评你。还有一点:code保持字符串类型。千万不要在生成 JSON 的时候把它当数字处理,因为行政区划代码里存在前导零的情况吗?严格说六级代码本身不含前导零,但一旦你在某处做了parseInt,再转回来就可能丢失位数。统一按字符串对待最省心,前后端比对时也不会出现440305和440305对不上的问题。3.3 体积控制的几个实际手段一份含三级的全国数据,用上面这个精简结构,未压缩大概在 300 到 400 KB 之间。放到 HTTP 传输里开 gzip 之后通常只剩 60 到 90 KB,这个量级是可以接受的。但如果你直接import到 Vue 组件里,它会进到主 bundle,首屏就得等它。所以有几件事值得做。第一,把数据文件放到public目录或者对象存储,用运行时fetch拉,而不是静态 import。这样它能走浏览器缓存,也能和主包并行加载。第二,如果只做三级联动,别把街道级数据也塞进去。多一级数据体积至少翻三倍,而你多半用不上。第三,如果业务上选择省份后只看该省的市和区,那就按省拆成 34 个小文件,选省之后再拉对应的那份,单文件通常只有十几 KB。这个方案在第 5 章会细讲。第四,打包工具层面,JSON 是可以被 tree-shaking 影响不到的,别指望构建器帮你砍。它是数据,不是模块。4. 抓取与清洗脚本实操4.1 拉取原始数据并落盘准备工作只有一个requests,脚本本身很短:import json import requests KEY 你申请到的密钥 URL https://apis.map.qq.com/ws/district/v1/list def fetch_raw(): resp requests.get(URL, params{key: KEY}, timeout15) resp.raise_for_status() payload resp.json() if payload.get(status) ! 0: raise RuntimeError(f接口返回异常: {payload.get(message)}) return payload[result] if __name__ __main__: raw fetch_raw() with open(raw_district.json, w, encodingutf-8) as f: json.dump(raw, f, ensure_asciiFalse, indent2) print([len(part) for part in raw])最后那行打印很关键,它一次性告诉你省级、市级、区县三个数组各有多少条,正常情况下应该是三十几、三百多、三千多这个量级。数字明显对不上,说明返回结构和你预期的不一样,先别急着往下做清洗。落盘保存原始数据这个习惯一定要有。我第一次做的时候是边拉边转,结果转换逻辑写错了,想回头核对原始字段,只能再拉一次,而那次恰好赶上接口配额限制的时段,白白等了一天。4.2 用代码前缀合并,比 cidx 稳我现在的清洗脚本用的是代码前缀匹配,核心逻辑是:省级代码的后四位是0000,市级代码的后两位是00,区县代码六位都有效。所以判断一个市属不属于某个省,看前两位是否一致;判断一个区县属不属于某个市,看前四位是否一致。def build_tree(provinces, cities, districts): tree [] for p in provinces: p_prefix p[id][:2] pnode {code: p[id], name: p[name], children: []} for c in cities: if c[id][:2] ! p_prefix: continue c_prefix c[id][:4] cnode {code: c[id], name: c[name], children: []} for d in districts: if d[id][:4] c_prefix: cnode[children].append({code: d[id], name: d[name]}) pnode[children].append(cnode) tree.append(pnode) return tree三层嵌套循环听着吓人,实际规模是 34 × 340 × 3000 里做前缀比较,现代机器上跑完不到一秒。用可读性换这点性能,我觉得很值。这个方案最大的好处是容错。省直辖县级行政区这类特殊节点,不管它在数据里表现成挂在容器市下面还是直接挂省下面,只要代码规则成立,它都能被正确归位。而且脚本不依赖cidx,不会因为某条数据缺了这个字段就整体崩掉。4.3 校验脚本:把挂不上的节点揪出来清洗完一定要做一遍反向校验,否则你永远不知道漏了什么。思路是遍历整棵树收集所有出现过的代码,再跟三个原始数组里的代码做差集:def collect_codes(tree): codes set() def walk(nodes): for n in nodes: codes.add(n[code]) if n.get(children): walk(n[children]) walk(tree) return codes merged collect_codes(tree) for level, items in enumerate(raw): missing {item[id] for item in items} - merged if missing: print(f第 {level 1} 级丢失 {len(missing)} 条: {sorted(missing)[:20]})跑完输出为空,说明一个没漏。如果有输出,把丢掉的代码拿去搜一下,基本能确定是哪类特殊结构没处理。我在这步踩过一次坑:早期版本只校验了省和市,忘了校验区县,结果有一批区县因为代码前缀规则被写错(我把[:4]写成了[:3])全部丢失,页面上看不出来,因为用户刚好没选到那几个市。后来加了完整校验才暴露出来,想想有点后怕。清洗完之后还要补一步递归清理空children,以及把最终文件写成不带缩进的紧凑格式:def prune(node): if children in node: node[children] [prune(c) for c in node[children]] if not node[children]: del node[children] return nodejson.dump(tree, f, ensure_asciiFalse, separators(,, :))这样写出来的是最紧凑的形式,比带缩进小将近三分之一。5. 在 Vue 项目里怎么接最省事5.1 全量加载方案,适合后台系统数据量在几百 KB 的时候,全量加载是最省事的选择,配合浏览器缓存,第二次进页面几乎零成本。template el-cascader v-modelregionPath :optionsoptions :propscascaderProps 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 }; onMounted(async () { const res await fetch(/data/pca.json); options.value await res.json(); }); function handleChange(value) { const [province, city, district] value || []; console.log(province, city, district); } /scriptv-model绑定的是一个代码数组,比如[440000, 440300, 440305],正好对应省市区的层级顺序。提交给后端时直接把这个数组拆成三个字段就行,不用自己再拼名字。如果你的组件库默认读value/label,那就把cascaderProps直接删掉,省一行配置。5.2 懒加载方案,适合 C 端和移动端如果首屏性能敏感,或者你只想在用户真正选择的时候才加载数据,那就按省拆文件。清洗脚本里加一段,按省级代码各写一个文件:import os os.makedirs(split, exist_okTrue) for province in tree: with open(fsplit/{province[code]}.json, w, encodingutf-8) as f: json.dump(province, f, ensure_asciiFalse, separators(,, :))每个文件通常十几到几十 KB,用户选哪个省就拉哪个。前端用一个lazyLoad函数接:const cascaderProps { value: code, label: name, lazy: true, lazyLoad: async (node, resolve) { const { level, value } node; if (level 0) { const res await fetch(/data/provinces.json); resolve(await res.json()); return; } const res await fetch(/data/split/${value}.json); const data await res.json(); resolve(data.children || []); } };注意懒加载模式下省级列表得单独准备一份,只含省节点、不带children。这份文件非常小,一二十 KB,直接静态引入都没问题。懒加载唯一的坑在于回显。用户编辑一条老数据的时候,你手上只有三个代码,但组件只加载了省级,后面的节点还没拉。解决办法是在组件渲染完成后手动触发一次逐级加载,把路径上的节点补进去。不同组件库对这个操作的支持程度不一样,有的提供getCheckedNodes,有的需要你直接操作数据源。这一块要留出调试时间。5.3 从代码反查名称路径用户在数据库里存的通常只有三个代码,但列表页要显示广东省 深圳市 南山区。每次都去遍历整棵树太慢,尤其是列表里有几百行的时候。我的做法是初始化时建两个索引:一个code - node的映射,一个code - 父链的路径映射。const nodeMap new Map(); const pathMap new Map(); function buildIndex(nodes, parentPath []) { for (const node of nodes) { const curPath [...parentPath, { code: node.code, name: node.name }]; nodeMap.set(node.code, node); pathMap.set(node.code, curPath); if (node.children) buildIndex(node.children, curPath); } }之后反查就是一次Map.get,复杂度 O(1),几千行列表也不会有压力。而且这份索引只建一次,后面所有请求复用,内存占用也就几百 KB。如果只需要名称不需要层级,可以再省一步,只存一个code - name的映射,更轻。6. 踩坑实录与排查清单6.1 常见问题速查表现象大概率原因处理方式接口返回 status 非 0密钥未启用 WebService、配额用尽、参数缺失去控制台核对密钥状态与服务勾选省市数量对不上返回结构版本变化打印三个数组长度,核对字段名部分区县挂不上父级省直辖县级行政区等特殊结构改用代码前缀合并法选到最后一级出现空面板叶子节点留了空 children 数组递归清理空数组页面白屏或首屏极慢JSON 被打进主 bundle移到 public 目录运行时 fetch回显只有代码没有名称只存了 code 没做反查初始化建 code-name 索引编辑老数据时选项不全懒加载模式下路径未预加载渲染后主动补齐路径节点某地区搜索不到数据里没有拼音字段需要拼音就保留接口的 pinyin 字段6.2 前后端字段约定与解析报错有一类报错和前端数据无关,但排查起来特别费时间。典型的是服务端框架抛出的failed to deserialize the json body into the target type,意思是它拿到的 JSON 结构跟它定义的对象对不上。常见触发条件是字段名不一致:前端传provinceCode,后端 DTO 里定义的是province_code,而序列化配置又没有开启下划线与驼峰的自动转换,反序列化就直接失败了。还有一种情况是某些字段被你标成了必填,但用户只选到了市级,区县代码是空的,整个请求体就校验不过。应对办法有两个方向。要么在前后端之间把字段命名规则定死,并且在框架配置里统一开启命名策略,别靠人肉记忆。要么后端接收时先用一个宽松的结构(比如直接收JsonNode或者Map),自己再做校验和转换,这样即使多一个字段、少一个字段都不会导致整个请求失败。我个人更倾向于后一种,尤其是这种层级数据。因为前端传过来的数组长度本身就是不确定的,用固定长度的对象去接,早晚会因为数据形态变化而出问题。6.3 数据更新与版本管理行政区划数据不是一成不变的,每年都有调整。我的做法是给每次生成的数据文件带一个版本标记,比如文件名里带上拉取日期pca-20260115.json,同时在文件里存一个顶层版本字段。前端加载后把版本号打到页面的调试信息里,出问题时一眼就能看出用户用的是哪版数据。更新流程上,我不会无脑覆盖。新数据生成后先跑一遍校验脚本,再跟上一版做一次差异比对,重点看新增了哪些代码、删掉了哪些代码、哪些名称变了。删掉的代码要特别小心,因为历史数据里可能还存着它们,前端反查不到就会显示成一串数字。这种情况一般是在索引里加一层兜底,查不到就显示未知区域或者保留原始代码,不要让页面崩掉。另外,建议把原始返回数据和清洗后的成品都留一份归档。原始数据占空间不大,但它是唯一的追溯依据。等到某天有人问这个区去年还在,为什么今年没了,你能立刻翻出来核对。7. 几个值得一试的延伸用法7.1 报表里的地域聚合有了完整的代码树,做地域维度的统计会方便很多。很多报表需求是按省统计订单量,而订单表里存的往往只是区县代码。这时候用代码前两位做分组就行,不需要额外关联表:SELECT LEFT(district_code, 2) AS province_code, COUNT(*) AS order_count FROM orders GROUP BY LEFT(district_code, 2);拿到结果之后再用前端的索引映射把代码翻译成省名。如果数据量特别大,可以考虑在后端维护一张行政区划维表,把前缀计算放在数仓里做,查询性能会更好。7.2 拼音搜索和首字母匹配如果你的表单里有个搜索框,用户输入nanshan想直接跳到南山区,那就得在数据里保留拼音字段。我建议单独生成一份搜索专用的扁平列表,而不是往树形结构里塞拼音。扁平列表每一行是{ code, name, fullname, pinyin, initials },initials是名称的拼音首字母缩写,比如南山区对应nss。搜索时在名称、全称、拼音、首字母四个字段上做包含匹配,命中率会高很多。这份列表体积不大,几千条记录,加载到内存里做前端过滤完全够用。用户选中之后,你拿到 code 再去树里定位路径,体验是很顺的。7.3 别把街道数据一次性灌进来最后提一个我见过不少人栽的坑。有人觉得既然都已经做到区县了,索性把街道/乡镇也一起做进去,搞四级联动。这个需求本身没问题,但你要清楚数据量和更新频率都会上一个台阶。区县三千多,街道一级通常是几万条,全量 JSON 可能到几兆,而且街道一级的调整比区县频繁得多。如果确实要做,建议单独建一份按区县拆分的街道数据,只在用户选完区县之后按需请求。千万别把它和省市区的树合并成一个巨型文件,那样前端加载会非常痛苦,而且你每次行政区划调整都要重新生成一整份数据。我在实际项目里最终还是把街道这一级放到了后端接口里,前端选完区县再请求一次,拿到的结果直接渲染,既保证了数据新鲜度,又避免了体积问题。这个折中方案我觉得挺适合后台系统的场景。