表单里那个“所在地区”字段,看着不过是三个下拉框的事,却是我见过的返工率最高的表单项之一。刚入行那会儿我也觉得简单:省一选,市一查,区一刷,完事。直到第一次接编辑页——用户改一条半年前录的地址,三个下拉全是空的,保存之后省份被清掉,客服电话直接打到工位上。后来才明白,三级联动写地址这活儿,真正难的不是“联动”,是回填、是数据脏、是异步时序、是那些行政层级本身就不规整的情况。
这篇就把我这些年做地址选择器踩过的坑摊开讲。从一份原始的行政区划清单怎么改造成能联动的结构,到联动状态该怎么管,再到编辑态回填、按需加载的竞态、区划变更导致的历史数据失效,最后到校验和存储格式。代码示例用的是框架无关的原生 JS 思路,套到 Vue、React 或者小程序里都能用。适合刚接触这类表单的同行,也适合接了老项目、正在被一堆“省份为空”的线上问题折磨的人。文中示例的省市名我用了代称,你换成自己的数据源就行,结构完全一致。
1. 先把数据结构想清楚:一份地区清单要改成什么样才能联动
绝大多数人写三级联动,第一步就错了——拿到后端返回的数组,直接照着渲染,然后在 change 事件里 filter 一遍。这不是不行,但当数据量到三千条、还要支持搜索和回填的时候,这个结构会让你多写至少三倍的代码。数据结构没设计好,后面的联动逻辑再怎么优化都是在补窟窿。
1.1 三种常见的数据形态,各自的坑在哪
做过的项目里,地区数据大概就这三种给法,每种的处理思路完全不同。
第一种是扁平数组,每条记录带自己的编码和父级编码,形如{ code, name, parentCode, level }。这是最理想的形态,可塑性最强,能建索引、能建树、能反查。第二种是嵌套树,父节点里塞children,看起来最省事,直接tree[0].children就能渲染第二级。但它的麻烦在于检索:想根据一个区县编码找到它属于哪个省,得递归遍历整棵树,三千条数据每次回填都跑一遍,性能上不划算。第三种是三张表分开请求,/provinces、/cities?pid=、/districts?cid=,这种形态天然适合懒加载,但你失去了全局视野,做“根据编码反查路径”就得连发三次请求。
我的建议很明确:不管后端给什么形态,前端第一件事都是归一化成扁平数组 + 索引表。嵌套树在渲染时有优势,但那个优势用一个parentCode → children[]的 Map 就能完全替代,而扁平数组在检索、缓存、增量更新上的优势是嵌套树给不了的。至于三张表分开请求,那是传输层的事,落到内存里照样拼成扁平数组。
| 数据形态 | 渲染成本 | 反查路径成本 | 增量更新 | 推荐处理 |
|---|---|---|---|---|
| 扁平数组 | 需建索引 | 一次 Map 查找 | 简单,push 即可 | 直接用,最优 |
| 嵌套树 | 直接可用 | 需递归遍历 | 麻烦,要定位父节点 | 拍平成数组 |
| 分表请求 | 需拼接 | 需三次请求 | 看接口设计 | 拼成扁平数组后缓存 |
1.2 value 一定要用行政编码,别图省事用名字
这条是血泪教训。早期为了省事,<option value="甲省">,直接拿中文名当值存库。当时觉得挺好,人也能看懂,导出报表也方便。结果遇到三个问题,一个比一个致命。
第一是同名。县级单位里同名的不少,两个不同的区叫同一个名字,在同一个地级市下面可能还好,一旦跨市跨省做统计或者反查,名字根本区分不开。第二是改名。行政区域调整的时候名字是会变的,一旦改了,所有历史数据里的那个名字就变成了“不存在的选项”,下拉框显示空白,用户一提交就被清空。第三是排序不稳定。按名字排序,中文的排序规则在不同浏览器、不同环境下结果可能不一致,用户找起来也难受。
用编码就没这些问题。编码是稳定的、唯一的、可排序的,而且它天然携带层级信息(虽然不建议依赖编码的具体位数来判断层级,那个规则在有些地方并不严格遵守)。前端做联动的时候,value用编码,label用名字,两套东西分开,改名的时候只需要更新 label,历史数据完全不受影响。
// 归一化后的标准结构,示例名称用代称 const flatRegions = [ { code: 'P01', name: '甲省', parentCode: 'ROOT', level: 1 }, { code: 'P01C1', name: '甲一市', parentCode: 'P01', level: 2 }, { code: 'P01C1D1', name: '甲一市城东区', parentCode: 'P01C1', level: 3 }, { code: 'P01C1D2', name: '甲一市城西区', parentCode: 'P01C1', level: 3 }, { code: 'P02', name: '乙省', parentCode: 'ROOT', level: 1 }, ];如果你拿到的数据里编码字段叫id、value、regionCode,别嫌麻烦,写个十几行的适配函数统一映射成code / name / parentCode / level。这十几行会在后面省下几百行的条件判断。
1.3 建两张索引表,把“查子级”和“查父级”都变成 O(1)
扁平数组的问题是查子级要遍历。三千条数据遍历一次也就零点几毫秒,看起来无所谓,但一次联动要遍历、一次回填要遍历三次、一个列表页有二十行地址每行都要回填——累积起来就不好看了,而且代码里到处是filter也很难读。
两张 Map 就能解决:
// 索引一:父级编码 -> 直接子级数组(渲染下拉用) const childrenMap = new Map(); // 索引二:编码 -> 节点本身(反查、回填用) const nodeMap = new Map(); function buildIndex(list) { childrenMap.clear(); nodeMap.clear(); for (const item of list) { nodeMap.set(item.code, item); if (!childrenMap.has(item.parentCode)) { childrenMap.set(item.parentCode, []); } childrenMap.get(item.parentCode).push(item); } // 顺手按编码排个序,保证选项顺序稳定 for (const arr of childrenMap.values()) { arr.sort((a, b) => (a.code > b.code ? 1 : -1)); } } buildIndex(flatRegions); // 之后取省份:childrenMap.get('ROOT') // 取某省下的市:childrenMap.get('P01') // 根据区县编码反查节点:nodeMap.get('P01C1D2')建索引的成本是一次三千条的遍历,几毫秒的事,只做一次。后面所有的联动、回填、搜索都在这两张表上跑。这里有个容易被忽略的点:排序要在建索引的时候做完,不要每次渲染下拉再去 sort。Array.prototype.sort在选项多的时候开销不小,而且每次渲染都排一遍纯属浪费。
提示:如果你的数据里有“省直辖县级市”这类跨层级的情况(省下面直接挂县级单位,跳过了地级),
level字段就不可靠了。渲染时不要用level判断该不该显示下拉,而要看childrenMap.get(当前选中码)有没有内容。层级是靠父子关系推出来的,不是靠 level 字段声明的。
2. 联动逻辑的核心不是“选完就查”,而是一条单向状态流
我见过太多实现是三个下拉各自维护一份 options,A 变了去刷新 B,B 变了去刷新 C,C 变了再回头去校验 A。事件在三个组件之间来回串,最后谁也说不清某个时刻的完整选中状态是什么。一旦加上回填、加上异步、加上“用户快速连续切换省份”,这套逻辑就彻底失控。
2.1 三个下拉共享一条“已选路径”,options 全部派生
换个思路:整个组件只维护一份状态——用户当前选中的路径,形如['P01', 'P01C1', 'P01C1D1']。三个下拉的选项列表,全部由这条路径推导出来,谁都不单独存 options。
const state = { path: ['', '', ''], // 依次是省、市、区 }; // 第一级:所有省 function getProvinceOptions() { return childrenMap.get('ROOT') || []; } // 第二级:由 path[0] 推导 function getCityOptions() { const provinceCode = state.path[0]; if (!provinceCode) return []; return childrenMap.get(provinceCode) || []; } // 第三级:由 path[1] 推导 function getDistrictOptions() { const cityCode = state.path[1]; if (!cityCode) return []; return childrenMap.get(cityCode) || []; }这种“单一状态源 + 派生视图”的写法,好处是任何时候你都能从state.path完整地知道用户选了什么,不需要去三个组件里挨个问。而且它是幂等的:不管你重渲染一百次还是重连一次数据,只要path不变,三个下拉显示的内容就完全一致。这在做回填的时候尤其重要,后面第 3 节会展开。
2.2 清空下游的触发条件,比你想的要严苛
“选完省要清空市和区”——这话对,但只对了一半。真正的问题是:什么时候算“选完”?
用户从“甲一市”切到“甲二市”,市这一级变了,区必须清空,因为旧区不属于新市。用户从“甲省”切回“甲省”(下拉重新选了同一个值),很多浏览器会触发 change 事件,这时候你去清空市和区,用户辛苦选的两级就白费了。还有程序化回填的时候,你依次设置path[0]、path[1]、path[2],如果每次赋值都触发清理逻辑,那回填永远填不进去。
我的做法是把“用户交互”和“程序赋值”彻底分开,用一个统一的入口函数:
// 用户交互入口,level 为 0/1/2 表示省/市/区 function onUserSelect(level, code) { const old = state.path[level]; if (old === code) return; // 值没变,什么都不做 state.path[level] = code; // 清空所有下游 for (let i = level + 1; i < state.path.length; i++) { state.path[i] = ''; } // 上下文变化后,如果下游有唯一选项,可以自动选上(见 2.3) maybeAutoSelect(level); emitChange(); } // 程序回填入口,直接给完整路径,不清空 function setPath(codes) { state.path = [codes[0] || '', codes[1] || '', codes[2] || '']; emitChange(); }onUserSelect里的if (old === code) return是关键的一行。它挡掉了“重新选同一个值”这种伪变更,也挡掉了某些浏览器在失焦时重复派发 change 的怪行为。别小看这一行,我见过因为少了它导致“用户选好城市后点了下别的输入框,回来发现城市没了”的线上问题。
2.3 只有唯一子项时,自动选中能省掉一半点击
这是个纯体验优化,但收益很直观。有些省份下面只有一个地级市(结构上确实存在这种情况),用户选完省还得再点一次市、再点一次区,明明没得选。更常见的是直辖市——省和市其实是同一块地方,第二级只有一个选项。
处理方式很简单:每次用户选择之后,检查下一级的选项数量,如果恰好是 1,就直接选中并继续往下检查。
function maybeAutoSelect(fromLevel) { let level = fromLevel + 1; while (level < 3) { const parentCode = state.path[level - 1]; if (!parentCode) break; const children = childrenMap.get(parentCode) || []; if (children.length === 1) { state.path[level] = children[0].code; level++; } else { break; } } }不过要注意,自动选中只在用户交互触发的链路里做,不要放在setPath回填里。原因很实际:回填的路径是历史数据,可能就是一个只有省和区、没有市的残缺路径,你自动帮它补一个市,反而把数据改坏了。回填就老老实实按原样填,缺的那级就留空,等用户自己去补。
注意:自动选中很容易和“清空下游”打架。顺序必须是先清空、再自动选。如果反过来,你先自动选了,再清空,等于白选。这个顺序我调过两次才发现。
3. 回填与编辑态:三级联动里最容易翻车的地方
新增好写,编辑难做。新增的时候状态是干净的,用户从零开始点;编辑的时候状态是别人三个月前存的,你不仅要把它还原出来,还要处理“还原不出来”的各种情况。我统计过手上这几个项目的地址相关 bug,七成以上出在回填环节。
3.1 从区县编码反推省市:三种做法,只有一种靠谱
拿到一条历史数据{ provinceCode, cityCode, districtCode },要把它填回三个下拉。如果三个编码都在,那最省事,直接setPath就完了。但现实里经常只有最末一级的编码(比如数据是从别的系统导过来的,或者当初为了省字段只存了区县),这时候就得反推。
做法一:用 nodeMap 逐级向上查 parentCode。这是唯一推荐的方式。
function resolvePathFromCode(anyCode) { const path = ['', '', '']; let node = nodeMap.get(anyCode); let guard = 0; while (node && guard++ < 10) { if (node.level >= 1 && node.level <= 3) { path[node.level - 1] = node.code; } node = nodeMap.get(node.parentCode); } return path; }guard那个计数器不是多余的。如果数据里有脏记录形成了环(父级指向自己,或者 A 的父是 B、B 的父是 A),没有这个保护就是死循环,页面直接卡死。这个坑我在一个外包接手的项目里真遇到过,那份数据里有两条记录互相指对方当爹。
做法二:按名字逐级匹配。给个名字,去当前层级的所有选项里找同名的。这种做法极度脆弱:同名、简称、带不带“市”字、用户手输的错别字,全都能让它失败。只有在完全没有编码、只剩文本的极端情况下才考虑,而且必须加人工确认。
做法三:依赖后端一次性返回完整路径。让接口在返回地址数据的时候顺带把provinceName / cityName / districtName一起给出来。这是最省心的,如果你能推动后端改,强烈建议这么做。前端少一堆反查逻辑,出错的概率也低。
| 反查方式 | 依赖条件 | 稳定性 | 适用场景 |
|---|---|---|---|
| parentCode 逐级上溯 | 有完整编码 + 索引表 | 高 | 首选,绝大多数场景 |
| 名称逐级匹配 | 只有文本 | 低 | 老数据兜底,需人工核对 |
| 后端返回全路径 | 接口支持 | 高 | 能推动后端就用这个 |
3.2 编码失效:历史数据撞上区划调整
比回填失败更麻烦的是“编码还在,但对不上了”。行政区划是会调整的,某个县被撤并到隔壁市,某个区改了个名,甚至整个层级被重组。历史订单里存的是老编码,你今天的数据源里已经没有这条记录了,nodeMap.get(oldCode)返回undefined。
这时候千万别做的一件事是:静默地把这个字段清空。用户打开一个老订单,什么都没动,保存之后地址没了,这是最恶劣的体验。正确的处理是分层兜底:
第一步,维护一份别名映射表。区划调整时,让数据维护的同学记录一条旧编码 → 新编码的映射。回填时先查nodeMap,查不到再查别名表,命中就自动换成新编码,同时给个轻提示“该地区已调整,已自动更新”。
第二步,别名表也没命中,就降级显示为文本。在下拉位置显示一个置灰的、不可选的条目,label 就是数据库里存的那个旧名称,让用户知道“这里原来是什么”,并给一个“重新选择”的按钮。保存时如果用户没动,就保留原值;用户动了,就换成新值。
function fillAddress(record) { const codes = [record.provinceCode, record.cityCode, record.districtCode]; const resolved = codes.map(c => { if (!c) return ''; if (nodeMap.has(c)) return c; if (aliasMap.has(c)) return aliasMap.get(c); // 别名兜底 return '__LEGACY__'; // 标记失效 }); if (resolved.includes('__LEGACY__')) { renderAsLegacyText(record); // 走降级展示,不塞进下拉 return; } setPath(resolved); }3.3 异步回填的时序:别让第一个请求的结果覆盖第三个
如果用的是懒加载(第 4 节会细说),回填就变成了一场时序游戏。你要填省、市、区三级,但市的选项得等省的请求回来才有,区的选项得等市的请求回来才有。写成await链还算稳,写成回调就容易出事:
// 错误示范:三个请求并发,谁先回来谁先渲染 loadProvinces().then(list => { options[0] = list; }); loadCities(pCode).then(list => { options[1] = list; }); loadDistricts(cCode).then(list => { options[2] = list; });这段代码在网速好的时候看着没问题,在弱网下会闪一下——第二级先渲染出来,但第一级还没数据,用户看到一个“市已选、省为空”的诡异状态。更糟的是如果用户在请求回来之前又改了省份,旧请求的结果回来会把新状态覆盖掉。
正确的写法是串行等待 + 请求标记。先确保省的选项到位,再加载市,再加载区,每一步都校验当前请求是不是“最新的那一次”。
let requestToken = 0; async function fillAddressAsync(record) { const token = ++requestToken; const [p, c, d] = [record.provinceCode, record.cityCode, record.districtCode]; await ensureOptionsLoaded(0); // 省一定有全量,通常秒回 if (token !== requestToken) return; // 已经被更新的操作取代,放弃 state.path[0] = p; if (c) { await ensureOptionsLoaded(1, p); // 懒加载市 if (token !== requestToken) return; state.path[1] = c; } if (d) { await ensureOptionsLoaded(2, c); // 懒加载区 if (token !== requestToken) return; state.path[2] = d; } emitChange(); }requestToken这个自增标记,是解决一切异步回填竞态最便宜的手段。每次开始回填就自增一次,中途任何一步发现自己的 token 已经不是最新的,直接return。不用去管理 AbortController,不用去取消请求,逻辑上就杜绝了旧结果污染新状态。
提示:编辑页打开时,如果地址字段在首屏之外(比如要滚动才能看到),别急着回填。等滚到可视区域再加载,或者干脆等用户点了“编辑地址”再加载。否则首屏会多发两三个请求,用户还在看别的内容,地址数据已经悄悄加载完了,纯属浪费。
4. 异步加载、缓存与竞态:数据量大起来之后的现实问题
省市两级的数据量很小,省级三十多个、地级三百多个,一次性全量拉下来也就几十 KB。真正让人头疼的是县级单位和更下一级。县级单位全国有两千八百个上下,如果还要做街道/乡镇级别,那是四万级别的数据,全量加载就完全不现实了。
4.1 先算笔账:全量到底能不能扛
给个粗略的估算,帮助你判断该不该偷懒全量加载。一条记录包含编码、名称、父级编码,平均算 40 个字节(中文名 UTF-8 占三个字节),三千条就是 120 KB 左右。JSON 序列化会有些冗余,算 150 KB。经过 gzip 压缩,中文的重复前缀很多,压缩率通常能到 4 到 5 倍,实际传输量三十 KB 上下。
这个量级意味着:省 + 市 + 县三级全量加载是完全可行的,压缩后三十 KB 左右,和一张普通的产品图差不多。如果你的页面本来就有一堆资源,这三十 KB 几乎感知不到。而且全量加载之后,所有的联动、回填、搜索都是内存操作,没有任何异步时序问题,代码能简单一大截。
但一旦到了街道/乡镇这一级,四万条记录,压缩后也要几百 KB,全量就不合适了。这时候必须懒加载,而且选项列表要用虚拟滚动或者搜索框来收窄。
| 层级范围 | 记录量级 | 压缩后体积 | 建议策略 |
|---|---|---|---|
| 省 + 市 | 约 350 条 | 5 KB 以内 | 全量,随页面一起加载 |
| 省 + 市 + 县 | 约 3200 条 | 30 KB 上下 | 全量,可考虑放 CDN 缓存 |
| 加街道/乡镇 | 约 4 万条 | 300 KB 以上 | 懒加载 + 搜索 + 虚拟滚动 |
我个人的选择倾向是:能全量就全量。异步带来的复杂度(缓存、竞态、加载态、失败重试)远超过那三十 KB 的传输成本。只有在数据源头不在自己手上、或者明确要支持街道级的时候,才上懒加载。
4.2 懒加载的缓存与请求去重
真要做懒加载,两个东西必须做:缓存和去重。
缓存好理解,用 Map 存parentCode → 子级列表,加载过的就不再请求。去重指的是:同一个parentCode在极短时间内被请求了两次(用户快速来回切换省份就能触发),第二次不应该发新请求,而应该复用第一次还在飞的那个 Promise。
const cache = new Map(); // 已完成的 const inflight = new Map(); // 正在飞行的 Promise function loadChildren(parentCode) { if (cache.has(parentCode)) { return Promise.resolve(cache.get(parentCode)); } if (inflight.has(parentCode)) { return inflight.get(parentCode); // 复用飞行中的请求 } const p = fetch(`/api/regions?parent=${parentCode}`) .then(res => res.json()) .then(list => { cache.set(parentCode, list); inflight.delete(parentCode); return list; }) .catch(err => { inflight.delete(parentCode); // 失败也要清理,否则永远卡住 throw err; }); inflight.set(parentCode, p); return p; }注意catch里的那行inflight.delete。如果失败了不清理,这个parentCode就会永远停留在“请求中”的状态,用户再选到这一级,拿到的是一个已经 reject 的旧 Promise,直接报错,而且永远好不了。这个 bug 很难复现,因为要恰好遇到一次请求失败。
4.3 快速切换省份时,旧请求回来的结果该不该用
这是懒加载里最隐蔽的问题。用户在省份下拉里快速从甲省切到乙省再切到丙省,三个请求依次发出。如果甲省的请求最后才回来,它会把options[1]覆盖成甲省的市列表,而此刻用户选的是丙省——第二级下拉里显示了一堆不相关的城市,用户一选就错。
解决办法和回填那里一样,用自增 token,但这里是针对每一级的独立 token:
const levelToken = [0, 0, 0]; async function onProvinceChange(code) { state.path[0] = code; state.path[1] = ''; state.path[2] = ''; const token = ++levelToken[1]; const list = await loadChildren(code); if (token !== levelToken[1]) return; // 已经不是最新的选择了 cityOptions.value = list; }每次触发加载就自增对应层级的 token,请求回来后对比,不是最新的一次就丢弃。这个模式简单、可靠、不依赖任何框架特性。用 AbortController 主动取消当然也行,但在很多环境里(比如小程序)不一定支持,token 方案是通吃的。
注意:网络请求失败时,千万不要只
console.error了事。用户看到的是一个空的、没有任何提示的下拉框,会以为是数据没选对,反复点击。至少要给出“加载失败,点击重试”的入口,这是基本功。
5. 地址数据的“脏”是常态:区划变更、同名与层级折叠
做地址组件做得越久,越会意识到一件事:行政数据天然就是不规整的,任何一个假设“省下面一定是市、市下面一定是区、层级一定是三层”的代码,迟早会在某个真实数据点上翻车。
5.1 层级不是三层,是“最多三层”
最常见的错误假设是“省→市→区三级一定都有”。实际上有几种情况会打破它:
一种是省下面直接挂着县级单位,中间跳过了地级。这种结构在某些省份是真实存在的,数据里体现为某个县的parentCode直接指向省编码,而不是某个市的编码。如果你的渲染逻辑写死了“第二级必须是市、第三级必须是区”,这个县就永远选不到。
另一种是直辖的市级单位,省和市合二为一,第二级只有一个选项,甚至数据结构上第二级的parentCode直接是ROOT。
还有特别行政区和自治区域,它们的层级划分规则和普通省份不完全一样,数据源里可能只给到两级。
处理方式其实第 1 节已经埋了伏笔:渲染哪一级,看的是当前选中节点有没有子级,而不是看它的 level 等于几。如果childrenMap.get(省级编码)有内容,就显示第二级;如果第二级的选中项childrenMap.get()没内容,第三级就禁用或者隐藏。
// 判断某一级该不该显示 function shouldShowLevel(level) { if (level === 0) return true; const parentCode = state.path[level - 1]; if (!parentCode) return false; const children = childrenMap.get(parentCode); return Array.isArray(children) && children.length > 0; }5.2 同名节点和“看起来一样”的选项
名字重复的问题,不只是跨省同名那么简单。更常见的困扰是:同一个省下面,两个不同城市各有一个“城关区”“新华区”,用户在下拉里看到两个一模一样的名字,完全不知道该选哪个。
如果只显示名字,用户是分辨不出来的。这里有两个改进方向。一是在选项文本里带上上级名称,比如“甲一市 · 城东区”,虽然啰嗦但绝对清晰。二是保持名字简洁,但在选中之后,在下方用小字回显完整路径:“甲省 / 甲一市 / 城东区”,让用户能确认自己选对了。
我个人更倾向第二种。下拉列表里塞太长的文本会撑破布局,尤其移动端;而选中后回显完整路径,既解决了辨识问题,也顺便让用户复核了一遍。
还有一种情况是名字相近但不相同,比如“甲县”和“甲区”,一字之差。这种只能靠用户自己看清楚,我们能做的是保证数据准确、排序稳定、不要用截断省略号把差异部分截掉。下拉框里的text-overflow: ellipsis在这种场景下是有害的。
5.3 数据版本管理:别让一次更新把老数据全打死
这是我踩过最贵的一个坑。当时图省事,直接把最新的行政数据覆盖了旧文件。结果更新当天,大量老订单的地址回填失败——那些被撤并的区县编码在新数据里彻底消失了,nodeMap查不到,用户的订单详情页地址全是空白。
后来改成了多版本共存的方式:数据文件按年份归档,比如regions-2023.json、regions-2024.json,当前版本用于新录入,回填的时候按记录里存的dataVersion字段去对应的版本里查。如果记录没有版本信息,就按“新版本 → 旧版本 → 别名表”的顺序依次尝试。
function findNodeAnyVersion(code, recordVersion) { // 先查记录对应版本 if (recordVersion && versionMap.has(recordVersion)) { const node = versionMap.get(recordVersion).nodeMap.get(code); if (node) return node; } // 再查当前版本 if (nodeMap.has(code)) return nodeMap.get(code); // 最后查历史版本 for (const [ver, table] of versionMap) { if (table.nodeMap.has(code)) return table.nodeMap.get(code); } return null; }多版本数据确实会让包体积变大,但通常只保留最近两三个版本就够了,压缩后也就多几十 KB。相比“用户订单地址丢失”这种事故,这点成本完全可以接受。
提示:更新地区数据之后,务必跑一遍回归。我一般是准备一份“历史问题地址清单”,二十来条,覆盖撤并的、改名的、跨层级的、编码缺失的,每次更新数据源都拿它过一遍。这个清单比任何单元测试都管用。
6. 从三个下拉框到可提交的表单:校验、拼接与反解
组件跑通了只是第一步,真正交付的时候,后端要的是能入库的数据,运营要的是能看懂能导出的文本,产品要的是“用户填一半也能保存草稿”。这些需求决定了你的存储和校验设计。
6.1 校验规则:三级全必填?不一定
先说个反常识的结论:“省市区三级都必须选”这个规则,未必是对的。
对于普通用户的收货地址,三级全选是合理的,因为快递需要精确到区县。但对于某些业务场景——比如用户填写“所在地”用于内容分发、或者填写“曾经居住过的地方”,区县级别的精度可能并不必要,要求三级全填反而增加了填写成本,导致放弃率上升。
我的做法是把精度要求做成配置项:
const rules = { requireProvince: true, requireCity: true, requireDistrict: true, requireDetail: true, detailMinLength: 5, detailMaxLength: 100, };校验函数就按这个配置走,逻辑清晰,不同业务线传不同配置即可。另外还有两条容易漏的规则:一是详细地址不能只有空白字符,用户敲几个空格就提交的情况真实存在;二是详细地址里不应该重复包含省市名,很多人习惯把“甲省甲一市城东区”再手打一遍,导致最终拼接出来的地址变成“甲省甲一市城东区甲省甲一市城东区XX路”,非常难看。
第二点可以在提交时做一次清理:如果详细地址的开头匹配到了已选的省市区名称,就自动去掉。
function cleanDetail(detail, pathNames) { let d = detail.trim().replace(/\s+/g, ' '); for (const name of pathNames) { if (name && d.startsWith(name)) { d = d.slice(name.length).trim(); } } return d; }6.2 存储格式:分开存编码,冗余存文本,是性价比最高的方案
存储这块,我的经验是三个编码 + 一个拼接文本,都要。
三个编码(省、市、区)用于程序逻辑:筛选、统计、反查、回填。这个是必须的,而且必须是编码不是名字,理由前面说过。
拼接文本用于展示和导出:列表页要显示“甲省甲一市城东区XX路12号”,报表要导出给运营看,短信通知要把地址发出去。如果每次展示都去实时拼接,就得每次都加载地区数据、每次都得反查名称,慢且容易出错。存一份拼接好的文本,展示的时候直接取,简单可靠。
const payload = { provinceCode: state.path[0], cityCode: state.path[1], districtCode: state.path[2], // 冗余字段:展示和导出用 regionText: getSelectedNames().join(''), detail: cleanedDetail, // 完整地址 fullAddress: getSelectedNames().join('') + cleanedDetail, // 数据版本,用于将来回填 dataVersion: '2024', };冗余字段的代价是一致性问题:如果地区改名了,拼接文本里的旧名字不会自动更新。这个可以接受,因为地址一旦提交,本质上就是一个历史快照,名字应该是当时那个名字。真要更新,可以做一次后台批处理。但如果你的业务对一致性要求高,也可以只存编码,展示时实时拼,那就是用性能换一致性,看具体场景权衡。
| 存储方案 | 查询筛选 | 列表展示 | 导出报表 | 一致性 |
|---|---|---|---|---|
| 只存编码 | 快 | 需实时拼,慢 | 需实时拼 | 好 |
| 只存文本 | 慢,且易错 | 快 | 快 | 好 |
| 编码 + 文本冗余 | 快 | 快 | 快 | 需定期对齐 |
6.3 从一长串地址反解三级:能不做就不做
有些历史数据只有一坨文本,比如“甲省甲一市城东区某某路12号3单元”,没有任何编码。要把它还原成三级结构,是个典型的字符串匹配问题,而且没有银弹。
我试过几种思路。最简单的逐级最长匹配:先用所有省名去文本里找,找到最长的那个匹配;再在该省的所有市名里找;再找区县。
function parseAddressText(text, regionTree) { let rest = text.trim(); const path = ['', '', '']; // 第一级 const province = longestMatch(rest, regionTree.provinces); if (!province) return { path, rest }; path[0] = province.code; rest = rest.slice(rest.indexOf(province.name) + province.name.length); // 第二级 const cities = regionTree.childrenMap.get(province.code) || []; const city = longestMatch(rest, cities); if (city) { path[1] = city.code; rest = rest.slice(rest.indexOf(city.name) + city.name.length); } // 第三级 const districts = regionTree.childrenMap.get(path[1]) || []; const district = longestMatch(rest, districts); if (district) { path[2] = district.code; rest = rest.slice(rest.indexOf(district.name) + district.name.length); } return { path, rest }; } function longestMatch(text, candidates) { let best = null; for (const c of candidates) { if (text.includes(c.name) && (!best || c.name.length > best.name.length)) { best = c; } } return best; }这段代码能解决八成的普通地址,剩下的两成靠人工。常见失败场景:用户写了简称(“甲省”写成“甲”)、名字里带错别字、地址里没有写省份、写了别名(“某某新区”这种非标准名称)。
所以我的结论很明确:反解只能当辅助工具,不能当数据迁移方案。真要做批量迁移,宁可加一个人工核对环节,也不要指望算法全自动搞定。上线前让运营抽检两百条,看准确率能不能接受,不能接受就老老实实人工过一遍。
最后分享一个我一直在用的做法:给地址组件写一份“历史问题数据”清单,二十来条,覆盖撤并的区县、改名的市、跨层级的县、编码缺失的、只有文本的、同名区县的、直辖市只有两级的。每次改动这个组件——哪怕只是改了个<select>的属性——都拿这份清单跑一遍。地址这种东西平时不出问题,一出问题就是“用户订单地址消失”这个量级,返工成本极高。那份清单比任何测试用例都实在,因为每一条都是真实踩过的,改完了立刻能看出有没有复发。