简介:JavaScript省市区三级联动是前端表单开发中常见的地理信息选择交互,这份PDF示例以原生JS演示完整实现,面向前端初学者或需要快速集成地址选择的开发者,适合用于用户注册、收货地址等场景。示例从基本概念讲起,说明省-市-区数据关联、select下拉列表与onchange事件,再按定义数组、createOption动态创建选项、监听上级变更更新下级等步骤拆解,并解析province.onchange、city.onchange等关键代码,逻辑清晰。包体方面,单个PDF文件,压缩包仅42KB,内容集中便于离线查阅与对照练习。目前该示例已有2132人学习下载,是入门三级联动常用的参考案例之一。同时涵盖数据结构优化、异步加载、错误处理与浏览器兼容性等扩展思路,帮助读者不仅会写demo,也能理解多级联动在实际项目中的设计演进。
1. 省份城市的三级联动:为什么简单交互反而最常被“重置”绊倒
省份城市的三级联动是 JavaScript 前端里最常见的交互之一,小到用户注册表单,大到订单收货地址,基本都会遇到。很多人觉得无非是三个下拉框,省一变市跟着变,市一变区跟着变,几条 change 事件就能交差。但实际做一次就会发现,真正难的不是遍历数据,而是“要不要清空下一级”这个决策:省改掉了,市和区是保留原值还是重置?重置了,用户已填的地址就这样没了;不重置,提交的数据很可能指向一个根本不存在的组合。这类问题一旦上线,往往以脏数据的形式暴露在后台。这篇笔记会把数据结构、渲染与事件链、异步加载和编辑回显一次讲透,读完可以照着落地。
2. 准备行政区划数据:树形 JSON 与扁平列表怎么选
三级联动看着是三个下拉框,其实地基是数据。我见过不少实现,代码写得不算差,但数据源里省、市、区三级关系没有表达清楚,导致后面每个交互都在打补丁。所以第一步不是写 change 事件,而是想清楚用什么结构装这份数据。
2.1 树形嵌套:跟手但改不动的结构
最常见的做法是把行政区划组织成嵌套树,每个节点带 children:
// 数据源示意:树形嵌套结构 const regionTree = [ { code: "P001", name: "某省", children: [ { code: "C001", name: "某市", children: [ { code: "D001", name: "某区", children: [] }, { code: "D002", name: "某区", children: [] } ] } ] } ];逻辑上很直白:渲染省下拉时遍历第一层;选了省之后,直接从 provinceNode.children 拿市列表;选了市之后,再从 cityNode.children 拿区列表。这种逐层取数的过程几乎没有额外计算,写起来最顺手,也是我早期首选的结构。
但它的短板藏在“改动”里。如果接口返回的不是完整树,而是平铺列表,前端要先做一次转换;如果运行时要删除或合并某个节点,你得先一路遍历找到父节点,再操作它下面的数组,比较绕。对于纯展示型的省市区联动,树形结构完全够用,可一旦后面要做搜索、回显、按 code 精确命中,树形就会多出不少递归代码。
2.2 扁平列表:利于查与回显,但渲染要“顺着父级走”
另一种常见做法是把所有节点拍平,每条记录带 parent 字段:
// 扁平结构:每条记录通过 parent 指向上一级 const regionList = [ { code: "P001", name: "某省", parent: null, level: 1 }, { code: "C001", name: "某市", parent: "P001", level: 2 }, { code: "D001", name: "某区", parent: "C001", level: 3 } ]; function getChildren(parentCode) { return regionList.filter(item => item.parent === parentCode); }这份结构的核心优势是反查。编辑用户地址时,后端通常只返回一个区级编码,你需要由下往上找出“省—市—区”整条链路,扁平列表配合一个 Map 就能在 O(1) 时间内完成。而树形结构此时要做递归搜索,反而麻烦。
代价是渲染时要靠filter或者提前建立索引。省市区全量数据一般也就几千条,filter的线性开销完全可以忽略,不必担心性能。我的建议是:接口给什么结构先不要急着定渲染代码,先把数据梳理成一份 node 列表,再按需派生一份树形结构。两者共存,展示用树,回显用扁平的 Map,互不耽误。
2.3 特殊层级:直辖市和省直辖县的边界处理
真实行政区划并不总是标准的“省→市→区”三级。有的省级单位下面直接挂着区县,没有中间市;有的省级直管县级市,层级也从三级变成了两级。如果代码里写死“省一变一定渲染市,市一变一定渲染区”,遇到这种数据就会拿到空列表。
| 数据形态 | 三级下拉呈现 | 实现建议 |
|---|---|---|
| 省 → 市 → 区 | 标准三级,无特殊处理 | 直接逐级渲染 |
| 省级 → 区(无市级) | 市级下拉没有可选内容 | 用“省级直辖”占位,或直接隐藏市级下拉 |
| 省级 → 县级市 → 区 | 中间级仍存在,可正常渲染 | 不要假设中间级 code 符合某类命名规则 |
我的做法是给每级数据都保留level字段,渲染前先判断子级是否存在。判断条件不要用“children 数组长度是否为 1”,而是用level或直接判断 children 是否为空。这一条不处理好,直辖市的数据在页面上会表现成“选了省,市是空的,区也是空的”,玄学得很,但其实只是数据层级断了。
提示:树形结构和扁平结构可以各自准备一份索引。树形负责页面上逐级取 children,扁平结构配合 Map 负责编辑回显时的反向查找。
3. 原生 JavaScript 实现三级联动:渲染函数与 change 事件链
数据准备好之后,开始写实现。这一节不带框架,用原生 JavaScript 把联动链路完整走一遍,因为联动的本质是“渲染 + 事件顺序”,框架只是换了一层 API,底层逻辑不会变。
3.1 最小页面结构:三个下拉框与一个入口数据
页面结构非常固定,三个 select 排在一起:
<select id="province" class="region-select"></select> <select id="city" class="region-select"></select> <select id="district" class="region-select"></select>入口数据我直接使用上一节的regionTree,因为写演示代码时树形最直观。如果后端实际返回的是扁平列表,先把它转成树:
// 把扁平列表转换成树形结构,方便逐级渲染 function buildTree(list) { const map = new Map(); list.forEach(item => map.set(item.code, { ...item, children: [] })); const root = []; map.forEach(node => { if (node.parent && map.has(node.parent)) { map.get(node.parent).children.push(node); } else { root.push(node); } }); return root; }这里有一个细节:判断根节点时不要依赖level === 1,因为某些数据源里省级节点的level可能不标准。用parent是否存在更稳。如果parent指向的节点不在列表里,这条记录会被当成省级节点处理,至少不会丢失。
3.2 渲染函数:把数组映射为 option
所有下拉框的渲染可以共用一个函数:
function renderOptions(selectEl, list) { // 保留一个“请选择”的占位项 const placeholder = '<option value="">请选择</option>'; const options = list.map(item => { return `<option value="${item.code}">${item.name}</option>`; }).join(''); selectEl.innerHTML = placeholder + options; }用innerHTML而不是逐个createElement的原因很简单:省市区数量级不大,一次性生成字符串再赋值,代码短、可读性好。但有一个安全前提:数据必须是受控的,也就是接口返回或本地 JSON 里的内容。如果数据源混入了用户可编辑的文本,比如自定义地址,必须对item.name做 HTML 转义,否则会被塞进页面里执行。
选项的value统一存 code,不要存名称。名称会重复,而且用户改名后旧记录没法通过 value 定位;code 是稳定标识,提交表单时后端也只认这个。
3.3 事件链与级联重置:联动正确性的分界点
三个下拉框之间靠 change 事件串联:
const provinceEl = document.getElementById('province'); const cityEl = document.getElementById('city'); const districtEl = document.getElementById('district'); function onProvinceChange() { const node = getNodeByCode(regionTree, provinceEl.value); const cityList = node ? node.children : []; renderOptions(cityEl, cityList); // 注意:这里必须连区级一起重置,否则会留下上级城市对应的旧区 renderOptions(districtEl, []); cityEl.value = ''; districtEl.value = ''; } function onCityChange() { const node = getNodeByCode(regionTree, cityEl.value); const districtList = node ? node.children : []; renderOptions(districtEl, districtList); districtEl.value = ''; } provinceEl.addEventListener('change', onProvinceChange); cityEl.addEventListener('change', onCityChange);这里联动正确性的分界点在于:省 change 时,区级是否被一并重置。很多人只重置了市级下拉,区级还保留着上一次的内容,用户就会看到“省是新的,市是新的,区却是另一个城市的”,提交后在后台形成一条不存在的组合数据。正确做法是省一变,下面的市和区全部清掉,再填充新的市;市一变,区清掉,再填充新的区。
getNodeByCode是对树做递归查找的辅助函数,这里单独拆出来,后面回显也会复用:
function getNodeByCode(tree, code) { if (!code) return null; for (const node of tree) { if (node.code === code) return node; const hit = node.children ? getNodeByCode(node.children, code) : null; if (hit) return hit; } return null; }初始化时只渲染省级:
renderOptions(provinceEl, regionTree);页面加载后,用户从“请选择”开始逐级选择,链路是干净的。到这里,一个纯前端的静态三级联动已经能跑通了。
4. 三级联动避坑:5 个让下拉“断开”的常见翻车点
把上面的代码放到真实项目里,还会遇到一批很具体的问题。这些问题不解决,功能就会在某个边界条件下突然失效。下面按“现象 → 原因 → 解决”的顺序整理,都是我实际调试时反复撞过的。
4.1 选择一次后下拉选项反复跳动,浏览器卡顿
现象:用户选完省,市和区的选项开始闪动,有时候页面直接卡死,控制台报递归超时。
原因:renderOptions通过innerHTML重建了 select 内部的 option。此时如果原本选中的 option 不存在了,浏览器会把 value 重置为第一个 option。如果第一个 option 刚好又是某个真实值,而这个值又触发了 change 事件,就可能进入“渲染—触发—再渲染”的循环。
解决:在 change 回调开头做一次值判重,值没变化直接返回:
let lastProvince = ''; function onProvinceChange() { const code = provinceEl.value; if (!code || code === lastProvince) return; lastProvince = code; // 后续渲染逻辑不变 }这里要注意,占位 option 的 value 必须为空字符串,而且它必须保留在第一位。这样内联重建后浏览器重置到的位置永远是无害的“请选择”,不会凭空造出一个真实选中态。
4.2 “请选择”占位项被当成真实值提交
现象:表单校验通过了,提交后后端报“省市区参数不能为空”,或者数据库里存进了“请选择”这样的字符串。
原因:占位 option 的 value 写成了文本本身,比如value="请选择",或者写成了value="0",而真实数据的 code 也有0,提交时无法区分。
解决:占位项固定为value="",提交前做一次空值校验:
if (!formData.province || !formData.city || !formData.district) { // 提示“请完整选择省市区” }如果接口要求省市区 code 为数字,提交前再用Number()转换。select 的 value 永远是字符串,这是很多匹配问题的根源。
4.3 异步按级加载时的竞态:后返回的数据覆盖先选择的数据
现象:用户快速从 A 省切到 B 省,A 省的市列表请求先发出但后返回,B 省的请求后发出却先返回。界面先正常显示 B 省的数据,几毫秒后又被 A 省的数据覆盖。
原因:网络请求没有顺序保证,后发的不一定先回。
解决:在发起请求前维护一个递增序号,响应回来后只响应最新一次请求:
let requestSeq = 0; function loadChildrenWithSeq(parentCode, selectEl) { const seq = ++requestSeq; // 自增后赋值给当前请求 fetchRegionChildren(parentCode).then(list => { if (seq !== requestSeq) return; // 说明已经有更新的请求发出 renderOptions(selectEl, list); }); }这个做法比“请求完成后判断选中的省是不是当初那个省”更简单可靠,因为 select 的 value 不一定会在请求期间立刻变化。
4.4 接口返回数字 code,前端按字符串匹配失败
现象:省、市都正常,但选中市之后区级列表始终为空,控制台里数据却能看到该市下有 children。
原因:select 的 value 必然是字符串,而接口返回的 code 可能是 number。getNodeByCode里用node.code === code做严格比较,字符串"110100"和数字110100永远不相等。
解决:匹配前统一转成字符串:
function getNodeByCode(tree, code) { const target = String(code); for (const node of tree) { if (String(node.code) === target) return node; const hit = node.children ? getNodeByCode(node.children, target) : null; if (hit) return hit; } return null; }这个坑隐蔽在“平时正常,某一天接口改了类型就全线失效”。最彻底的办法是在数据入口处统一把所有 code 字段标准化成字符串,后面所有逻辑都不再关心原始类型。
4.5 直辖市和省直辖县的层级被打断
现象:选择一个直辖市后,市级下拉是空的,区级也拉不出数据,或者选一个省直辖县级市后,区级下拉永远没有内容。
原因:这些行政单位没有标准的中间市层级,代码却默认“省一变一定有市列表”。
解决:渲染市级之前先判断节点下有没有真正的市级数据。如果没有,就把下一级直接映射到第三个下拉框,并在市级下拉放一个占位项:
const childList = node ? node.children : []; const hasCityLevel = childList.some(item => item.level === 2); if (hasCityLevel) { renderOptions(cityEl, childList); renderOptions(districtEl, []); } else { renderOptions(cityEl, [{ code: node.code, name: '省级直辖' }]); renderOptions(districtEl, childList); }这里的“省级直辖”只是交互占位,提交时真正用的还是区级 code。如果你不想让用户看到这个占位,也可以直接把市级下拉禁用,并把区级选项渲染出来。核心是代码里不再假设层级永远为三级。
5. 从本地数据到接口加载:按父级查询的 Promise 封装与缓存
本地静态数据在演示里跑得通,但真实项目里,行政区划数据通常放在服务端,由一个按父级编码查询的接口提供。前端每次选择省或市时发请求,拿下一级列表。这一章拆分这种场景下的封装思路,以及异步带来的额外成本。
5.1 为什么不建议一次性拉全量数据
全量数据也就几千条,几 KB 到几百 KB 不等,内部系统里一次性拉回来其实没问题。但如果页面面向公网用户,行政区划数据更新频率低、单次语义固定,按需加载是更友好的方案:首屏不背这份数据,用户选到哪一级才拉哪一级,接口也能按需下发。
提示:别把“按需加载”当教条。如果项目部署在内部网络,数据量又只有一千多条,一次性拉全量能让代码少掉缓存和竞态两层复杂度,反而是更好的选择。
我一般会看两个指标:数据总体积是否超过首屏可接受的预算,以及团队维护这份 JSON 的频率高不高。更新频繁的数据放接口,更新不频繁的数据直接打进静态资源。
5.2 用 Promise 封装 loadChildren,并处理失败分支
接口通常这样定义:传入parentCode,返回该父级下的直接子级列表。先封装一层异步函数:
async function fetchRegionChildren(parentCode) { const resp = await fetch(`/api/region?parent=${parentCode}`); const json = await resp.json(); if (json.code !== 0) { throw new Error(json.message || '加载失败'); } return json.data; }这里约束了接口返回格式为{ code, message, data }。如果你的后端不是这个结构,改这一处字段映射即可,不用散落到各个调用点。调用侧需要包一层try/catch:
async function onCityChangeWithApi() { try { const list = await fetchRegionChildren(cityEl.value); renderOptions(districtEl, list); districtEl.value = ''; } catch (err) { renderOptions(districtEl, []); // 这里可以弹轻提示,避免界面停留在“选了市但没有区”的状态 } }失败的界面处理很重要:接口超时或返回错误时,区级下拉应该被清空,而不是保留上一次的内容。保留旧数据会让用户误以为当前市下面就是这个区,提交后再次产生脏数据。
5.3 用 Map 做已加载缓存,避免重复请求
用户从 A 省切到 B 省再切回 A 省,如果不加缓存,同一份市列表会请求两次。加一个内存级 Map 就能解决问题:
const regionCache = new Map(); function loadRegionChildren(parentCode) { if (!regionCache.has(parentCode)) { // 缓存的是 Promise,而不是数组,这样并发调用只会发一个请求 const p = fetchRegionChildren(parentCode).catch(err => { regionCache.delete(parentCode); // 失败时清掉缓存,让下次可以重试 throw err; }); regionCache.set(parentCode, p); } return regionCache.get(parentCode); }这里的关键点在于 Map 里存的是 Promise 而不是最终数组。两个 change 事件几乎同时都请求同一个父级时,第二次调用直接复用第一次的 Promise,网络层面只有一个请求。如果缓存的是数组,则需要额外处理“正在请求中”的状态,代码会复杂一截。
缓存失效问题也要考虑。行政区划不是高频变更数据,内存缓存到页面刷新就会清掉,通常够用。如果后台确实会更新区域划分,可以给缓存加一个版本号或过期时间:
const regionCache = new Map(); // key: parentCode, value: { data, expire } const CACHE_TTL = 5 * 60 * 1000; function loadRegionChildren(parentCode) { const hit = regionCache.get(parentCode); if (hit && Date.now() < hit.expire) { return Promise.resolve(hit.data); } return fetchRegionChildren(parentCode).then(list => { regionCache.set(parentCode, { data: list, expire: Date.now() + CACHE_TTL }); return list; }); }缓存策略不必做太重,5 分钟过期足够覆盖大部分交互场景。后台数据改动后,最多等缓存过期,不会出现“永远用旧数据”的问题。
异步场景下,上一章的竞态处理和这里的缓存经常要组合使用。事件回调里先++requestSeq,再调用loadRegionChildren,返回后判断序号是否为最新:
let requestSeq = 0; async function onProvinceChangeWithApi() { const parentCode = provinceEl.value; const seq = ++requestSeq; try { const list = await loadRegionChildren(parentCode); if (seq !== requestSeq) return; // 过期响应,直接丢弃 renderOptions(cityEl, list); renderOptions(districtEl, []); } catch (err) { // 不在这里处理时,由 loadRegionChildren 的 catch 兜底 } }这套组合几乎覆盖了所有按父级加载的联动场景。
6. 编辑回显技巧:从编码一次性定位省市区三级
前面的逻辑都建立在用户从零开始逐级选择。到了编辑页面,情况反过来了:后端返回一个区级编码,你需要提前把三个下拉框都选中。这里有一个很实用的技巧,能省掉模拟用户操作的副作用。
6.1 拿到目标编码,先反查父链
如果后端返回的是“省市区编码”中的区级 code,比如字符串"D001",先通过扁平列表的 Map 向上找父链:
const regionMap = new Map(regionList.map(item => [item.code, item])); function findChain(code) { const chain = []; let current = regionMap.get(code); while (current) { chain.unshift(current); current = current.parent ? regionMap.get(current.parent) : null; } return chain; // 返回 [省级节点, 市级节点, 区级节点] }如果后端返回的是完整地址文本,比如“某省某市某区”,那么先用名称匹配到对应节点,再走同一个函数。匹配时优先用编码,因为名称在不同层级可能重复。
6.2 按链顺序渲染,而不是靠触发 change 事件
回显最容易翻车的做法是模拟用户操作,手动dispatchEvent(new Event('change'))。省下拉的变化事件会顺手清空下一级,你刚回填的市和区可能在这条事件链里被重置掉。更安全的做法是按顺序复用渲染函数,直接设置 value:
function echoRegion(targetCode) { const chain = findChain(targetCode); if (chain.length < 3) return; // 先渲染省一级,再设置选中值 renderOptions(provinceEl, regionTree); provinceEl.value = chain[0].code; // 再渲染市一级,并设置选中值 const cityNode = getNodeByCode(regionTree, chain[0].code); renderOptions(cityEl, cityNode.children); cityEl.value = chain[1].code; // 最后渲染区一级,并设置选中值 const districtNode = getNodeByCode(regionTree, chain[1].code); renderOptions(districtEl, districtNode.children); districtEl.value = chain[2].code; }回显时一次性完成后三级赋值,中间不触发任何 change 事件,也不经过重置逻辑,数据一致性由我们自己保证。这个函数看起来不起眼,但编辑地址页的体验差距往往就体现在这几十行里。
我早期也图省事,直接代码触发 change,结果总在回显后立刻被清掉,排查了很久才发现是事件链里做了一个“清空下一级”的动作。后来就养成一个习惯:任何代码回填都走渲染函数,不通过事件链去“借道”。这个习惯后来在其他联动组件里也帮我避了不少雷。希望帮到你。
本文还有配套的精品资源,点击获取