恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
Vue 3 从零实现省市区三级联动选择器:数据组织、联动逻辑与踩坑实录
首页
资讯中心
/
Vue 3 从零实现省市区三级联动选择器:数据组织、联动逻辑与踩坑实录
Vue 3 从零实现省市区三级联动选择器:数据组织、联动逻辑与踩坑实录
发布时间:2026/9/29 14:24:32
很多做后台管理系统和电商前台的朋友估计都遇到过这个需求让用户选择省、市、区。我第一次接到这个需求是在一个 Vue 2 的老项目里当时第一反应就是去网上复制一段现成的联动代码。结果发现网上的代码要么是依赖 jQuery 的远古产物要么就是封装过度、改起来想砸键盘的老古董。后来在 Vue 3 项目里又遇到一遍我决定彻底自己动手写一个三级联动组件省/市/区选择器。这篇文章就记录一下我在 Vue 3 Element Plus 环境下从零手写省市区选择器的完整过程包括数据组织、联动逻辑、API 设计还有我踩过的几个坑。内容适合正在用 Vue 3 写业务、想摆脱对第三方库依赖、或者想搞懂联动原理的朋友如果你只想快速复制代码交差那直接看我第三节的完整实现也能满足需求。1. 为什么非要自己写一个—— 需求拆解与方案选型1.1 表面需求是“三个下拉框”实际是数据、交互、性能三件事先别急着写代码把需求翻译一下所谓三级联动本质上是一棵树形数据的“逐级筛选”。你需要一个省份列表用户选了某个省之后要拿到这个省下面的城市列表选了城市之后再拿到这个区县列表。这个过程拆开来看涉及几个核心问题数据从哪来是像中国行政区划这种相对固定的静态数据还是每次从后端动态拉取联动逻辑怎么写每当上一级变化时下一级应该怎么更新——是清空、禁用还是直接加载新数据数据量怎么控制如果一次把所有省市区数据全部塞到前端上万条数据在浏览器里渲染会卡成 PPT所以数据结构的组织要非常讲究组件的“就知道怎么办”用户选完后你还需要把选中的省市区名字拼接成“广东省/广州市/天河区”这种格式后面提交给后端时数据要怎么格式化表面上看这就是三个el-select套在一起但真正做产品的同学都知道地址选择是表单里最容易出交互细节问题的地方。从需求角度来说做好数据组织基本就成功了一半。1.2 网上方案为什么不好用我也不是没试过网上的现成组件库。Element Plus 官方其实没有提供省市区联动组件它只给了el-cascader级联选择器。很多人会用el-cascader加上一份省市区数据来实现联动效果。el-cascader的问题是它把一个下拉面板做成了“联动”交互上更接近“选一个东西”而不是“三个独立的省、市、区下拉框”。对于有些业务场景比如页面已经有明确的分步流程需要把省市区分开填写你还是需要三个独立的el-select。第三方库比如某个行政区划选择组件虽然开箱即用但维护节奏、API 风格、UI 风格不一定符合你的项目。再者如果你的项目需要对数据进行定制比如要禁用某些偏远地区、某市下设的区有改动改第三方的代码难度远大于改自己的代码。所以自己做不是“重复造轮子”而是为了让组件真正贴合业务。这里你的思路应该是把组件的“数据层”和“UI 层”解耦——静态数据写成可替换的配置UI 用三个el-select实现联动逻辑抽成纯函数。这样既能保证 UI 和项目完全一致又有极高的定制自由度。1.3 方案选型Props 驱动 事件向上传递我的目标是做一个“傻瓜式”组件使用的人只需要传一个modelValue比如[440000, 440100, 440106]这种编码数组然后监听change事件就能拿到所有选中项。组件内部的设计是用areaData作为属性传入它是一棵完整的省市区树维持三个内部状态provinceList、cityList、districtList用watch监听modelValue的变化实现回显用户每选一个级别就触发一次update:modelValue事件把当前已选的编码数组传出去。这其实就是 Vue 组件开发里最经典的思想数据流是单向的父组件传进来子组件通过事件通知父组件去改。你不需要在子组件里维护一份能随时改的modelValue副本只需要在合适的时机“抛出”变更事件。想通这一层你会发现实现三级联动根本没有多复杂。2. 核心细节解析与实操要点 —— 数据接口与联动逻辑2.1 数据组织选区划编码比选“名字”更靠谱这是整个组件的第一决定数据用“值”还是“编码”我说一个最实际的痛点用户选了“广东省”你如果存的是“广东省”这个字符串那么假设以后区划改名了比如某些市改名前后的叫法变了历史数据怎么处理数据库中存的“广东省”变了怎么办所以几乎约定俗成的做法是用行政区划编码类似440000存储稳定、唯一、不随名称变化而修改。数据的基本结构我按常见做法用字典树{ 440000: { name: 广东省, children: { 440100: { name: 广州市, children: { 440106: { name: 天河区 }, 440103: { name: 荔湾区 } } } } } }这种结构的好处是在通过编码查名字时可以直接沿着 key 逐层向下找。数据量大了也能很快定位。如果你的数据是从接口读取的后端给的是扁平数组每条有id、parentId、name你需要在组件内部或工具函数里把它转成这个树结构。构造方法也很简单遍历一次以parentId为 key 挂到对应节点的children上即可。我通常会在组件的属性data中同时兼容这两种格式传入数组也行传入树形字典也行。兼容逻辑放在组件初始化时统一处理这样在使用时可以更灵活。2.2 联动逻辑何时清空、何时加载、何时触发事件点击“省”下拉框时需要把这个省的children作为城市列表点击“市”时把市的children作为区县列表。这个“常识”操作其实是联动逻辑的核心但也是 bug 高发区。我的处理思路是这样的用户在“省”下拉框选了新值比如从“广东省”切到“湖南省”此时保存当前的省编码从数据树中找到该省的children赋给cityList清空当前已选的市、区编码因为省都换了市和区肯定不匹配了触发change事件传入[新省编码, null, null]父组件可以自行决定是否清空表单里依赖省市区的后续字段比如街道。用户在“市”下拉框选了新值找到该市的children赋给districtList清空当前已选的区编码触发事件[省编码, 新市编码, null]。用户在“区”下拉框选了值不加载新数据触发[省编码, 市编码, 新区编码]一个完整的选择流程结束。这套逻辑写成函数大约只有 30 行但最容易被忽略的是“清空后续级别”这个操作。很多新手写联动只更新下一级选项列表却忘了把已经选中的下一级值清掉最后提交表单时就会出现“省是广东、市是湖南长沙”这种诡异数据。为了避免这个 bug我在编码时做了个抽象用一个统一的方法handleSelectChange(type, value)方法内部直接判断“当前变更影响哪些后续级别”然后一次性清空并更新。如果你也想封装得更优雅可以继续把“定位 children”这件事做成一个函数getChildren(code)由它处理树上下的遍历代码会干净很多。2.3 回显逻辑由外到内、要防循环组件的第二核心点是“回显”。如果你编辑一个表单后台返回了[440000, 440100, 440106]组件需要自动把三个下拉框分别设为广东省、广州市、天河区。回显步骤也很直观用省编码找到广东省加载它的子级广州市...再用市编码找到广州市加载它的子级天河区...最后用区编码选中天河区。但这里有一个 Vue 开发中的经典坑——死循环你在 watchmodelValue时回显同时用户操作时触发update:modelValue而父组件又会把新值可能是同一份数组传回来。如果不加判断每次回显又会触发一次“赋值操作”的副作用导致事件重复触发。经验做法是回显操作时只修内部状态不触发update:modelValue事件仅在用户主动选择时才向外触发事件。我在组件里设计了一个programmaticUpdate标志位回显之前置为true事件触发前检查这个标志位。一旦从外部赋值导致回显就忽略这次变更的“广播”。3. 实操完整可运行的“静态数据版”三级联动组件3.1 组件源码基于 Element Plus Vue 3template div classarea-picker el-select v-modelprovinceCode :placeholderprovincePlaceholder classarea-select clearable changeonProvinceChange el-option v-foritem in provinceList :keyitem.code :labelitem.name :valueitem.code / /el-select el-select v-modelcityCode :placeholdercityPlaceholder classarea-select clearable :disabledcityDisabled changeonCityChange el-option v-foritem in cityList :keyitem.code :labelitem.name :valueitem.code / /el-select el-select v-modeldistrictCode :placeholderdistrictPlaceholder classarea-select clearable :disableddistrictDisabled changeonDistrictChange el-option v-foritem in districtList :keyitem.code :labelitem.name :valueitem.code / /el-select /div /template script setup import { ref, watch, computed } from vue const props defineProps({ modelValue: { type: Array, default: () [] }, data: { type: [Object, Array], required: true }, // 允许只显示省市/两级或省一级 level: { type: Number, default: 3 // 1省2省市3省市县 }, provincePlaceholder: { type: String, default: 请选择省份 }, cityPlaceholder: { type: String, default: 请选择城市 }, districtPlaceholder: { type: String, default: 请选择区县 } }) const emit defineEmits([update:modelValue, change]) // ---------- 数据预处理 ---------- // 转成树形结构支持数组或对象 function normalizeData(data) { if (Array.isArray(data)) { const tree {} const list data // 假设后端给了 { id: 440000, parentId: null, name: 广东省 } const map {} list.forEach(item { map[item.id] { ...item, children: {} } }) // 构造树 list.forEach(item { const node map[item.id] if (item.parentId map[item.parentId]) { map[item.parentId].children[item.id] node } else { tree[item.id] node } }) return tree } // 本身已经是树 return data || {} } const treeData normalizeData(props.data) // ---------- 内部状态 ---------- const provinceCode ref() const cityCode ref() const districtCode ref() const provinceList Object.keys(treeData).map(code ({ code, name: treeData[code].name })) const cityList ref([]) const districtList ref([]) const cityDisabled computed(() cityList.value.length 0) const districtDisabled computed(() districtList.value.length 0) // ---------- 事件处理 ---------- let programmaticUpdate false function getChildren(tree, code) { return tree[code] tree[code].children ? tree[code].children : {} } function onProvinceChange(code) { // 清空下级 cityCode.value districtCode.value cityList.value [] districtList.value [] if (!code) { emit(update:modelValue, []) emit(change, []) return } const children getChildren(treeData, code) cityList.value Object.keys(children).map(c ({ code: c, name: children[c].name })) const val [code, null, null] emit(update:modelValue, val) emit(change, val) } function onCityChange(code) { districtCode.value districtList.value [] if (!code) { const val provinceCode.value ? [provinceCode.value, null, null] : [] emit(update:modelValue, val) emit(change, val) return } const provinceChildren getChildren(treeData, provinceCode.value) const children getChildren(provinceChildren, code) districtList.value Object.keys(children).map(c ({ code: c, name: children[c].name })) const val [provinceCode.value, code, null] emit(update:modelValue, val) emit(change, val) } function onDistrictChange(code) { const val [provinceCode.value, cityCode.value, code] emit(update:modelValue, val) emit(change, val) } // ---------- 回显 ---------- watch( () props.modelValue, (val) { if (!val || !val.length) { programmaticUpdate true provinceCode.value cityCode.value districtCode.value cityList.value [] districtList.value [] programmaticUpdate false return } programmaticUpdate true const [pCode, cCode, dCode] val if (pCode treeData[pCode]) { provinceCode.value pCode cityCode.value districtCode.value cityList.value [] districtList.value [] if (cCode) { const pChildren getChildren(treeData, pCode) const cChildren getChildren(pChildren, cCode) cityList.value Object.keys(pChildren).map(c ({ code: c, name: pChildren[c].name })) cityCode.value cCode if (dCode cChildren[dCode]) { districtList.value Object.keys(cChildren).map(d ({ code: d, name: cChildren[d].name })) districtCode.value dCode } } else { const pChildren getChildren(treeData, pCode) cityList.value Object.keys(pChildren).map(c ({ code: c, name: pChildren[c].name })) } } programmaticUpdate false }, { immediate: true, deep: true } ) /script style scoped .area-select { width: 200px; margin-right: 8px; } /style我这段代码在开发环境实测过可以直接跑。它保留了最基本的“从静态数据回显、切换、清空下级”等能力。如果你想要支持“只选省市两级”和“只选省一级”可以在此基础上改动内部逻辑也可以按props.level去控制第三个下拉框是否渲染以及最终返回值长度——这点我建议在实际项目里作为扩展点去加不会破坏现有逻辑。3.2 如何在父组件中使用父组件里基本只需要关心v-model绑定的值template area-picker v-modelarea :dataareaData changehandleAreaChange / /template script setup import { ref } from vue import AreaPicker from /components/AreaPicker.vue const area ref([]) const areaData { /* 省市区树形数据 */ } function handleAreaChange(val) { // 这里可以把 val 直接提交给后端 // 也可以再拼接省市区名字 console.log(change, val) } /script使用时的核心规则是父组件永远只传modelValue接收子组件的change结果。至于顺序、格式、是否展示样式都由父组件决定。这样组件的复用性就体现在了 10 分钟接入一个新页面。如果你还需要把编码转换成“名字”建议父组件里做一个codeToName(code)的查找函数或者直接在父组件中把areaData做成响应式对象利用代码逻辑联动找名。我们不建议把命名逻辑全塞进联动组件——因为“名字”一般只用于展示不要在组件里做太多“业务展示”的侵入式设计。3.3 如何在服务端返回“扁平数组”时适配后端接口很多情况下不会直接给你一颗树更常见的返回结构是[ { id: 440000, parentId: null, name: 广东省 }, { id: 440100, parentId: 440000, name: 广州市 }, { id: 440106, parentId: 440100, name: 天河区 } ]你需要在请求到数据后对它进行预处理。我的建议是在父组件里写一个formatAreaData(flatList)函数把扁平列表转换为组件需要的树结构。不要把它放在组件内部因为有些页面可能不止一个地方要用这棵树选区和选择器都需要。格式转换的核心逻辑在前面normalizeData里已经给了实际使用中你可以把它抽成一个公共工具函数。具体步骤先构造一个Map以id为 key预先给每个节点加上children: {}再遍历一次把每个节点的引用挂到其父节点的children上父节点不存在的挂到顶层 key。这样以后不管后端返回什么样的扁平数组你都能转成树来用。如果你的数据量比较大比如全国 3000 区县也不用每次组件实例都重新转换一遍建议在父组件中做成 Promise 或全局 store 缓存只转换一次。4. 常见问题与排查技巧实录4.1 场景一外部修改modelValue后组件下拉框没有联动更新这个坑最典型。比如你的父组件在提交前因为某个校验不通过把已经选择的area重置成[]然后再填入一组合法编码。此时如果组件里 watch 没有处理好比如没有使用deep: true或没有immediate就会出现“内部状态还是旧值”的情况。排查思路其实很简单检查父组件绑定的v-model有没有正确关联到子组件的modelValue检查子组件里 watch 是否监听了props.modelValue并且是否使用了{ deep: true, immediate: true }如果你用数组它不是深层的但以防万一结构变复杂建议保留deep检查回显时是否把状态都正确重置了一遍。我在实际开发中发现很多时候问题出在你只处理了“新增选项”却忘了把旧选项清掉。回显函数里的第一件事一定是要重置cityList和districtList然后再重新填充。4.2 场景二更改“省”时填好的“市/区”没被清空这个问题其实是 4.1 的反面用户已经选好“省、市、区”突然把省改成另一个表单里市区的旧值还留在提交结果里造成数据错误。我的建议是在代码里统一封装“清空下级”的逻辑而不是在onProvinceChange和onCityChange里各写一遍。这样以后如果需求变成“切换省时如果新省的第一个市与旧省市同名则自动保留”你可以只改一个地方。如果你在多个方法里复制了清除逻辑改起来就是灾难。4.3 场景三下拉框渲染慢尤其是有几千条区划时全国的省市区数据加起来差不多 3000~4000 条理论上不算大但如果你直接在模板里把每一层的所有选项全部渲染出来浏览器就会同时遍历多个数组渲染大量 DOM。最简单有效的做法是只在用户选择上级后才生成下级的下拉选项——这其实已经是联动组件天然具备的优化。我在几个大型后台项目里只有在“打开页面时把所有区县全部渲染”的情况下才真的出现了几百毫秒的卡顿。另外一个优化技巧是给el-option的value绑定编码字符串不要绑定整个对象。对象作为 value 会导致每次比较value currentValue时都要深比较性能损耗在你打开下拉框时会呈指数级体现。4.4 场景四Element Plus 下拉框clearable清空后事件怎么处理很多项目需要用户能把选中的省市清掉。Element Plus 的clearable会在清空时把绑定的值变成undefined或空字符串。这时你的change回调会收到空值要对这个分支做处理。我的处理方案是在onProvinceChange/onCityChange/onDistrictChange中判断!code时触发一次“清空后续所有值”的update:modelValue。这是联动组件很容易漏掉的细节——用户清空省时市和区也要跟着清空。你的父组件拿到[]后表单校验的状态才能保持一致。4.5 场景五回显时区划编码是旧的国家行政区划调整现实中偶尔会有区划调整比如某个县改成了市辖区或者某两个镇合并。如果你的数据库存的是旧编码而后端返回的新数据里已经没有这个编码了这时组件在watch回显时会发现treeData[pCode]是undefined。我的策略是在回显时不要直接return而是尝试“容错降级”——如果省级编码无法匹配但省名匹配成功就把省先选中如果市编码也无法匹配但子级里存在“名字”匹配就按名字匹配一遍。这在真实项目里能显著减少用户提交“数据不可用”的情况。不过这属于锦上添花优先级不高但值得在你做回显时给设计留个扩展点。5. 进一步扩展动态加载、UI 优化与 TypeScript 支持5.1 从“一次加载全部”到“按需懒加载”如果你的省市区数据是后端动态维护的百万级甚至更大传统的“一次加载全部”就不现实了。你可以把三个下拉框的选项源改为异步加载组件挂载时只加载所有省parentId null用户选择省时按省编码请求城市列表选择城市时再请求区县列表。代码改动很简单核心是不要再用treeData一次性生成provinceList而是在onProvinceChange里调用一个loadCityList(provinceCode)的异步函数拿到数据后填充cityList。这样最终得到的代码反而更简单因为没有“树形查找”这一步了。缺点是每次切换省都要等待接口返回需要配合v-loading才能避免用户快速连续操作导致的数据错乱。异步加载时我会特别小心处理“竞态问题”——用户快速从广东切到湖南再切到四川可能上一次接口返回比下一次晚导致最终展示的市列表是属于广东的。解决方式很简单在方法外面用一个requestId做计数或者记录最后一次点击的省编码接口返回时校验一下编码是否还是当前选中的值不一致则丢弃。5.2 样式优化与 UX 细节默认的 Element Plus 三级下拉框宽度是 200~220px 左右视觉上会有“三段独立”的观感。有个小技巧三个下拉框如果共用一条等宽的样式会让它们像是在数量的局限里自动排列。如果想要更紧凑还可以把它们合并成“整体感”更强的无缝样式——中间不留 margin左右下拉框加圆角中间的用普通边框。这个在 PC 后台管理里相当常见。交互上还有一个小优化当上一级为空时下一级不只是禁用可以加上轻量提示文案比如 placeholder 替换为“请先选择省份”。降低用户的困惑感。我是通过计算属性根据cityList.length动态改 placeholder 来做的成本非常低但产品反馈会好很多。5.3 给组件补上 TypeScript 类型如果你写的项目直接用 TS建议不要省略这层功夫。给组件加上defineProps的interface定义interface TreeItem { name: string children?: Recordstring, TreeItem } type AreaData Recordstring, TreeItem interface AreaPickerProps { modelValue: string[] data: AreaData level?: 1 | 2 | 3 provincePlaceholder?: string cityPlaceholder?: string districtPlaceholder?: string }之后在代码里definePropsAreaPickerProps()IDE 类型提示和校验会友好很多。这也是每个成熟的业务组件库都在做的事花 10 分钟换来后续一年里团队的“无脑使用”还是很划算的。5.4 做成长效组件沉淀到团队在你完成这个组件后我发现很容易把它继续优化成团队级通用组件。比较好的沉淀方式是把省市区数据独立成 JSON 文件不要和组件写在一起方便后续更新组件只保留“联动逻辑 UI”职责不掺和业务写一个简单 README记录支持的数据格式、事件、扩展思路在测试环境搭一个 demo 页展示不同 level、不同数据源静态/异步/扁平转树。这样后续再有同事做表单页面直接拖组件进去绑定数据即可节省的时间成本非常可观。而且你写的组件有足够的文档代码逻辑也清晰后续维护的人不用猜你的意图。6. 最后说点个人心得这个组件写完之后我再回头去看之前用过的第三方库最大的感受是复用的核心不是“拿来即用”而是“能被你掌控”。自己写一个三级联动组件技术难度其实不高真正的坑集中在数据格式统一、联动时清空上级值、回显防循环这几个地方。你在踩过这几个坑之后再遇到类似的多级选择场景比如产品类目的三级分类、组织架构的部门选择都能很快做出通用的多级联动组件。从长远看这种“底层能力”比复制某一段代码的价值大得多。希望这篇文章能帮你少走一点弯路把省市区选择器的实现变成一项信手拈来的基本功。