简介:这份资源面向前端初学者与需要实现地址选择功能的开发者,提供用原生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) 但脆弱 | 一次性静态页面 | 顺序一变全错,无法动态增删 |
| 树形嵌套 | 省对象内嵌 cities,city 内嵌 areas | 逐层查找 O(n) | 数据量小、前端直出 | 深层嵌套遍历慢,JSON 体积大 |
| 扁平 + parentId | 所有节点一个数组,靠 parentId 关联 | 需过滤 O(n) | 数据量大、需动态加载 | 每次联动都要 filter,需缓存 |
我一般选扁平加 parentId,因为真实项目里省市区数据往往来自后端接口,扁平结构最容易做增量更新和缓存。树形结构看着直观,但一旦某个市要改归属,得递归找父节点,维护成本高。平行数组只适合写死的 demo,生产环境不要用。
2.2 用 parentId 构建联动索引的最小实现
扁平数据的核心操作是「根据父级ID筛子级」。如果每次 change 都全量filter,数据量上千时会有明显卡顿。我的做法是初始化时建一个Map,key 是 parentId,value 是子节点数组,把 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里先塞一个占位 option,value 为空,这样用户能主动取消选择,表单校验时判断空值即可。
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 也就几百KB,gzip 后更小,通常没必要异步。但如果你的业务扩展到街道四级,或者要支持海外地址,数据量上万,就得考虑按需加载。我的策略是:省级数据首屏加载,市和区在用户选择时再请求,同时用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,血泪经验,希望帮到你。
本文还有配套的精品资源,点击获取