ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

JavaScript省市区三级联动实战:数据结构、回显与性能优化

JavaScript省市区三级联动实战:数据结构、回显与性能优化 简介这份资源面向前端初学者与需要实现地址选择功能的开发者提供用原生JavaScript完成省份、城市、区域三级联动的完整示例。通过动态创建select元素、监听onchange事件实现选择省份后自动更新城市与区域列表解决表单中地理信息级联选择的问题。压缩包内共1个PDF文件约42KB内容以代码讲解与示例为主便于直接阅读和对照练习。目前已有2131人学习下载说明该知识点在实际开发中较为常用。资源详细拆解了provinceArr、cityArr、countryArr三层数据结构createOption函数封装选项创建以及清空下级列表、按selectedIndex取值的联动逻辑同时给出数据结构优化、异步加载、错误处理与浏览器兼容性等扩展思路帮助读者掌握DOM动态操作与事件监听并理解多级联动数据的组织方式适合作为前端交互练习与项目参考。1. 省市区三级联动从一份脏数据到能上线的选择器电商收货地址、外卖配送范围、企业CRM客户录入——只要表单里出现「省市区」三个字前端就绕不开三级联动。很多人第一次写觉得无非是三个select互相监听半小时搞定真到上线才发现数据源里「北京市」下面挂着「北京市」再挂着「东城区」用户选完省市区拿到的是一串对不上的编码后端接口直接报参数错误。JavaScript实现省份城市的三级联动核心从来不是那几行onchange而是数据怎么组织、联动怎么触发、回显怎么对齐、性能怎么兜底这四件事。这篇笔记按我实际做过的方案拆开讲先讲清数据结构和选型理由再给能直接抄的代码最后把踩过的坑一条条列出来。适合正在写地址表单的前端新手也适合想重构老联动组件的熟手。2. 三级联动的数据结构与联动模型为什么不能三个数组硬编码2.1 三种常见数据组织方式与选型对比做联动第一步是决定数据长什么样。我见过最粗暴的写法是三个平行数组provinces、cities、areas靠下标对齐。这种写法在数据量小、层级固定时能跑但只要省份顺序一变整个映射就崩了。更稳的做法是树形嵌套或扁平加父级ID下面这张表是我在几个项目里实际用过的三种结构对比。结构类型数据形态查找复杂度适合场景主要问题平行数组三个独立数组按下标对应O(1) 但脆弱一次性静态页面顺序一变全错无法动态增删树形嵌套省对象内嵌 citiescity 内嵌 areas逐层查找 O(n)数据量小、前端直出深层嵌套遍历慢JSON 体积大扁平 parentId所有节点一个数组靠 parentId 关联需过滤 O(n)数据量大、需动态加载每次联动都要 filter需缓存我一般选扁平加 parentId因为真实项目里省市区数据往往来自后端接口扁平结构最容易做增量更新和缓存。树形结构看着直观但一旦某个市要改归属得递归找父节点维护成本高。平行数组只适合写死的 demo生产环境不要用。2.2 用 parentId 构建联动索引的最小实现扁平数据的核心操作是「根据父级ID筛子级」。如果每次 change 都全量filter数据量上千时会有明显卡顿。我的做法是初始化时建一个Mapkey 是 parentIdvalue 是子节点数组把 O(n) 查找降到 O(1)。// 假设后端返回的扁平数据每个节点有 id、name、parentId // 省级节点的 parentId 为 0 或 null const rawData [ { id: 1, name: 广东省, parentId: 0 }, { id: 2, name: 广州市, parentId: 1 }, { id: 3, name: 深圳市, parentId: 1 }, { id: 4, name: 天河区, parentId: 2 }, { id: 5, name: 越秀区, parentId: 2 }, { id: 6, name: 南山区, parentId: 3 } ]; // 构建 parentId - children 的索引 function buildIndex(data) { const map new Map(); data.forEach(item { const pid item.parentId || 0; if (!map.has(pid)) map.set(pid, []); map.get(pid).push(item); }); return map; } const indexMap buildIndex(rawData); // 取某省下的所有市 function getChildren(parentId) { return indexMap.get(parentId) || []; } console.log(getChildren(1)); // 广州市、深圳市 console.log(getChildren(2)); // 天河区、越秀区这段代码的关键在buildIndex它只遍历一次原始数据把每个节点挂到父级ID对应的数组里。getChildren直接查 Map不涉及循环。参数上要注意parentId的默认值处理——有些后端返回null有些返回0统一用item.parentId || 0兜底否则省级节点会漏掉。另外 Map 的 key 用数字还是字符串要统一后端如果返回字符串ID前端parseInt一下不然getChildren(1)和getChildren(1)取不到同一批数据这是很隐蔽的翻车点。2.3 联动触发的三种绑定方式与事件委托数据结构定了接下来是三个select怎么互相触发。最直接的是给每个 select 绑change省变清空市和区、市变清空区。但如果你用框架动态渲染 option每次 change 都重建 DOM会有闪烁。我一般用事件委托绑在父容器上减少监听器数量。// HTML 结构三个 select 放在 #addr-box 里 const box document.querySelector(#addr-box); const provinceSel document.querySelector(#province); const citySel document.querySelector(#city); const areaSel document.querySelector(#area); // 渲染 option 的通用函数 function renderOptions(selectEl, list, placeholder) { selectEl.innerHTML option value${placeholder}/option; list.forEach(item { const opt document.createElement(option); opt.value item.id; opt.textContent item.name; selectEl.appendChild(opt); }); } // 初始化省份 renderOptions(provinceSel, getChildren(0), 请选择省份); // 事件委托监听整个容器的 change box.addEventListener(change, (e) { const target e.target; if (target provinceSel) { const pid Number(target.value); // 省变重置市和区 renderOptions(citySel, pid ? getChildren(pid) : [], 请选择城市); renderOptions(areaSel, [], 请选择区县); } else if (target citySel) { const cid Number(target.value); renderOptions(areaSel, cid ? getChildren(cid) : [], 请选择区县); } });这里有几个参数细节。Number(target.value)是因为 select 的 value 永远是字符串不转数字会导致getChildren查不到。pid ? ... : []是处理用户选回「请选择」的情况此时 value 为空字符串转数字是 0应该清空下级。事件委托的好处是即使后续动态插入新的 select也不用重新绑事件。注意renderOptions里先塞一个占位 optionvalue 为空这样用户能主动取消选择表单校验时判断空值即可。3. 回显、校验与异步加载真实表单里的三级联动怎么写3.1 编辑场景下的回显先设值再触发联动新增表单简单编辑表单才是三级联动的照妖镜。后端返回的往往只有areaId你得反推出它属于哪个市、哪个省再把三个 select 都设上值。常见错误是直接provinceSel.value xxx但此时 option 还没渲染出来设了也白设。// 假设后端返回 { provinceId: 1, cityId: 2, areaId: 4 } function echoAddress({ provinceId, cityId, areaId }) { // 第一步渲染省并选中 renderOptions(provinceSel, getChildren(0), 请选择省份); provinceSel.value provinceId; // 第二步根据省渲染市再选中 renderOptions(citySel, getChildren(provinceId), 请选择城市); citySel.value cityId; // 第三步根据市渲染区再选中 renderOptions(areaSel, getChildren(cityId), 请选择区县); areaSel.value areaId; }顺序不能乱必须先渲染 option再赋值 value否则 select 找不到对应项value 会变成空字符串。如果后端只给了areaId那就得反查遍历扁平数据找到该节点拿它的parentId当cityId再拿市的parentId当provinceId。反查建议在初始化索引时顺便建一个id - node的 Map避免每次遍历。3.2 表单校验三级必须都选还是允许只选到市校验规则取决于业务。快递地址必须精确到区但有些统计场景只要求到市。我的做法是给三个 select 都加required属性但用 JS 控制areaSel的disabled状态——如果业务允许只选到市就把区 select 禁用提交时跳过它的校验。function validateAddress() { const province provinceSel.value; const city citySel.value; const area areaSel.value; if (!province) return { ok: false, msg: 请选择省份 }; if (!city) return { ok: false, msg: 请选择城市 }; // 如果区县可用但没选报错 if (!areaSel.disabled !area) { return { ok: false, msg: 请选择区县 }; } return { ok: true, data: { province, city, area } }; }注意disabled的 select 在原生表单提交时不会带上值所以后端拿到的area会是空。如果后端要求必填就不能用 disabled得用隐藏域或者允许空值但前端提示。这个边界要和后端对齐不然联调时又是一轮扯皮。3.3 大数据量下的异步加载与缓存策略全国省市区数据大概三千多条一次性加载 JSON 也就几百KBgzip 后更小通常没必要异步。但如果你的业务扩展到街道四级或者要支持海外地址数据量上万就得考虑按需加载。我的策略是省级数据首屏加载市和区在用户选择时再请求同时用Map缓存已请求过的父级ID避免重复请求。const cache new Map(); async function fetchChildren(parentId) { if (cache.has(parentId)) return cache.get(parentId); const res await fetch(/api/region?parentId${parentId}); const list await res.json(); cache.set(parentId, list); return list; } // 省 change 时 box.addEventListener(change, async (e) { if (e.target provinceSel) { const pid Number(provinceSel.value); const cities pid ? await fetchChildren(pid) : []; renderOptions(citySel, cities, 请选择城市); renderOptions(areaSel, [], 请选择区县); } });缓存 key 用 parentId注意请求并发时可能重复发同一个 parentId 的请求严谨点可以加一个 pending 的 Promise 缓存。另外异步渲染 option 后如果用户手速快连续切换省份旧请求返回可能覆盖新请求的结果这是经典的竞态问题。解决办法是记录一个请求序号只渲染最新一次的结果或者用AbortController取消旧请求。4. 避坑与排查三级联动最常见的五个翻车现场4.1 现象切换省份后城市列表不更新控制台无报错原因通常是事件没绑上或者绑在了错误的元素上。如果你用框架动态生成 select在 DOM 还没插入时就querySelector拿到的是 null绑事件自然无效。另一个可能是change事件在移动端某些浏览器上不触发需要改用input或同时绑blur。解决把事件绑定放在 DOM 渲染完成之后或者直接用事件委托绑在始终存在的父容器上。移动端测试时真机跑一遍别只信桌面浏览器。4.2 现象回显时区县选不中value 设了但显示空白原因前面提过option 还没渲染就赋值。还有一种情况是 value 类型不匹配后端返回数字4你设areaSel.value 4但 option 的 value 是字符串4严格比较下选不中。DOM 的 value 赋值会自动转字符串但如果你在比较时用了就会出问题。解决统一在赋值前String(areaId)或者渲染 option 时opt.value String(item.id)。回显函数里按「渲染省→设省→渲染市→设市→渲染区→设区」的顺序走一步都不能省。4.3 现象数据量大了以后每次选择都卡顿半秒原因是用innerHTML拼接大量 option或者每次 change 都全量遍历原始数据。三千条数据用字符串拼接再一次性赋值浏览器要重新解析 HTML开销很大。解决用document.createDocumentFragment()批量插入或者用new Option()构造。查找子级用前面建的 Map 索引不要每次filter。如果还是卡考虑虚拟滚动但省市区场景一般用不上。4.4 现象直辖市和省会城市出现「北京市-北京市-东城区」的重复层级这是数据源的问题不是代码问题。很多公开数据里直辖市下面会再套一层同名市。用户看着别扭但后端接口可能就认这个结构。解决如果后端能改让数据源去掉重复层级如果改不了前端渲染时判断「市名等于省名」就跳过市这一级直接把区挂到省下面。但这样提交时要注意补全市的ID否则后端对不上。我一般倾向于保留原结构只在 UI 上做视觉合并数据层不动。4.5 现象异步加载时快速切换省份区县列表显示的是上一个省的数据这是竞态条件。省 A 的请求还没回来用户切到省 B省 B 的请求先返回并渲染然后省 A 的响应才到把区县列表覆盖成了 A 的数据。解决用一个自增的requestId每次发起请求前requestId响应回来时比对当前的requestId和发起时的是否一致不一致就丢弃。或者用AbortController在切换时取消上一个请求。这个坑在弱网环境下必现本地开发很难复现上线后用户投诉才被发现。5. 把联动组件封装成可复用函数参数设计与一个验证技巧写到这儿三个 select 的逻辑已经能跑了但每个页面都复制一遍事件绑定和渲染代码维护起来是灾难。我一般会封装成一个工厂函数接收容器选择器和数据源返回一个带getValue、setValue、reset方法的实例。参数设计上data支持扁平数组或返回 Promise 的函数level控制需要几级联动placeholder统一占位文案。function createRegionLinkage({ container, data, level 3, placeholder 请选择 }) { const selects []; const box document.querySelector(container); // 根据 level 创建对应数量的 select for (let i 0; i level; i) { const sel document.createElement(select); sel.dataset.level i; box.appendChild(sel); selects.push(sel); } let indexMap new Map(); let idMap new Map(); // 初始化索引data 可以是数组或异步函数 async function init() { const list typeof data function ? await data() : data; list.forEach(item { const pid item.parentId || 0; if (!indexMap.has(pid)) indexMap.set(pid, []); indexMap.get(pid).push(item); idMap.set(item.id, item); }); renderOptions(selects[0], indexMap.get(0) || [], placeholder); } function renderOptions(sel, list, ph) { sel.innerHTML option value${ph}/option; const frag document.createDocumentFragment(); list.forEach(item { const opt document.createElement(option); opt.value String(item.id); opt.textContent item.name; frag.appendChild(opt); }); sel.appendChild(frag); } // 事件委托 box.addEventListener(change, (e) { const sel e.target; const levelIndex Number(sel.dataset.level); const pid Number(sel.value); // 清空所有下级 for (let i levelIndex 1; i selects.length; i) { renderOptions(selects[i], [], placeholder); } // 渲染直接下级 if (pid levelIndex 1 selects.length) { renderOptions(selects[levelIndex 1], indexMap.get(pid) || [], placeholder); } }); return { init, getValue: () selects.map(s s.value), setValue: (ids) { // 按顺序回显逻辑同 3.1 ids.forEach((id, i) { if (i 0) { renderOptions(selects[0], indexMap.get(0) || [], placeholder); } else { const parentId Number(selects[i - 1].value); renderOptions(selects[i], indexMap.get(parentId) || [], placeholder); } selects[i].value String(id); }); }, reset: () { selects.forEach((s, i) { renderOptions(s, i 0 ? (indexMap.get(0) || []) : [], placeholder); }); } }; }这个封装里level参数让组件能适配「省-市」两级或「省-市-区-街道」四级。data支持函数是为了异步加载场景。setValue接收一个 ID 数组按层级顺序回显。验证封装是否可靠我有个笨办法但很有效写一个循环随机生成一百组合法的省市区ID组合依次调用setValue再getValue比对输入输出是否一致。这个测试能覆盖大部分回显和类型转换的边界问题比手点靠谱得多。最后说个习惯。每次做完联动组件我都会在移动端真机上把「快速连续切换省份」这个操作重复二十遍看区县列表有没有错乱。这个动作帮我抓出过三次竞态 bug血泪经验希望帮到你。本文还有配套的精品资源点击获取
返回列表