☰
级联选择器不对称规则实现:一级多选二级单选方案
2026/10/14 7:23:30 网站建设 项目流程

级联选择器(Cascader)是后台管理系统里的常客:省市区、机构层级、商品类目,凡是“两级以上路径”的选择场景,拖一个级联组件基本就能收工。但是业务不会一直按省市区这种“对称规则”出牌,我这周就接了一个典型改造:一级分类可以多选,二级分类却只能单选。需求来自一个运营配置页,运营账号要能挂到多个店铺上,但每个店铺只能指定一个主营类目,提交到后端的数据必须是“店铺A-类目X、店铺B-类目Y”这种一一对应的关系。第一版我偷懒直接用级联组件的多选模式,结果二级类目能勾出好几个,运营一提交就被校验拦下。折腾了两轮,把实现方案彻底换掉才算干净。这篇就把“一级多选、二级单选”这个需求的完整实现思路、核心代码和踩坑过程摊开讲,给同样被不对称级联折磨的同学一个可以直接抄作业的方案。

1. 需求拆解:一级多选、二级单选到底在选什么

1.1 真实业务场景:谁会在系统里遇到这种需求

这种“上层并列、下层互斥”的需求最常出现在资源配置型页面。我把高频场景列一下,你八成能对号入座:

  • 运营范围映射:一个运营账号可以覆盖多个店铺、多个站点、多个区域(一级),但每个店铺、站点、区域下面只能绑定一个主营类目、一个默认负责人、一个投放策略(二级)。选多了就是脏数据。
  • 权限模板配置:给角色分配多个数据域(一级),同一个数据域下只能选一个权限档位(二级)。比如“华东区”只能选“只读”或“读写”,两个都选,业务上说不通。
  • 活动报名与投放设置:可复选多个活动场次、多个投放位置(一级),每个场次或投放位下面再单选一个套餐、一个策略(二级)。

这类需求表面上是“能不能多选”的问题,内里其实是两种关系的组合:一级之间是并列关系,选中多少都不冲突;二级在同一父级下面则是互斥关系,复数选择没有业务含义。理解了“并列 + 互斥”这两个词,后面所有代码都围绕这四个字展开。

1.2 需求本质:非对称级联规则

常规级联是“对称”的:每一层要么全单选,要么全多选。三级省市区、四级商品类目,绝大多数组件都能直接覆盖。但“一级多选、二级单选”把两种语义硬塞进同一个控件,属于典型的非对称级联,拆开看有两层:

  • 第一层是“父级集合”语义:最终结果里有几个父级,界面上就应该出现几个独立分支,彼此不干扰;
  • 第二层是“父级内单选”语义:同一个父级下切换子级时,应该是 radio 的互斥表现,而不是 checkbox 的叠加表现。

把这两层语义拆开之后你会发现,真正难的其实不是数据结构,而是交互层面:一个控件既要支持多选,又要在部分层级做互斥单选,组件库里没有这种“局部语义”开关。

1.3 为什么主流级联组件的原生多选做不到

拿常用的级联组件(比如 el-cascader)来说,它的多选配置是全局的:

  • multiple: true一开,所有层级节点都变 checkbox,任何层级都能勾多个;
  • checkStrictly: true可以让父级和子级解耦,不强制“选父即选子”,但它只管父子联动,管不了同层互斥;
  • change事件拿到的只是“被勾选节点的集合”,组件内部没有任何约束能做到“同一个父节点下最多保留一个子节点”。

所以结论很直接:组件能用的部分只有“画界面 + 收集路径”,“二级互斥”这一条必须自己补。补的方式不同,就分化出下面三条路线。

2. 方案选型:三条实现路线,我最后选了哪条

2.1 路线A:继续用级联多选组件,事件里做拦截

这是很多人第一反应会走的路:组件继续开multiple,在change里把同一父级下的多个子级过滤成最后一个。代码量不大:

handleChange(paths) { const map = {}; const parentOnly = []; paths.forEach((path) => { if (path.length === 2) { map[path[0]] = path; // 同一个一级下,后选中的覆盖先选中的 } else { parentOnly.push(path); } }); this.result = [...Object.values(map), ...parentOnly]; }

但这个方案有个绕不过去的问题:v-model 里的值虽然只剩一个子级了,界面上的 checkbox 还勾着好几个。用户看到的和实际提交的值对不上,运营很快就会来问“我明明勾了两个,怎么只提交了一个”。后面我用插槽自定义节点,把二级勾选框替换成仿 radio 的样式,再通过节点方法做互斥,才算把界面补偿回来。改造完能用,但代码开始依赖组件内部 API,维护起来心里不踏实。具体做法和缺陷我在第 4 节展开。

2.2 路线B:一级用多选下拉,二级动态生成单选组

第二版我换了个思路,干脆不跟级联组件的多选语义较劲:一级自己做多选下拉(要搜索、要折叠标签都行),选中几个父级,下面就把“父级标签 + 二级 radio 组”渲染成几个独立区域。用户看到的东西非常直白:

  • 上方是一个多选下拉,负责选父级;
  • 下方每个选中的父级对应一个小区域,区域内用 radio 单选一个二级。

这个方案完美贴合前面说的“并列 + 互斥”:一级并列用 checkbox 语义,二级互斥天然用 radio 语义,全程不用碰内部 API。状态都是自己维护,初始回显、清空、校验、后端数据格式转换全都可控。我最后选的就是这条路,第 3 节把完整组件代码贴出来。

2.3 路线C:动级联面板的 DOM,把第二层 checkbox 强制改成 radio

开发群里有人会问“能不能用 CSS 把第二层的 checkbox 关掉,画个 radio 上去”。能做,但我不建议在生产环境这么干,原因很现实:

  • 级联面板是组件内部结构,不同版本的 DOM 层级、class 命名不一致,CSS 选择器很容易被版本升级一波带走;
  • 第二层的勾选和取消还是得靠组件内部 store 方法驱动,底层状态没有 radio 语义,稳定性全靠人肉维护;
  • 一旦叠加搜索过滤、懒加载、自定义节点这些场景,面板 DOM 结构变化,“哪一列是第二层”都不好判断。

这条路线我称它是“最后的避风港”,当作思路了解即可,真上线建议绕开。

2.4 选型结论与对比

三条路线横向对比,结论就很清楚了:

维度路线A:多选+事件拦截路线B:自组装复合组件路线C:面板DOM改造
实现成本低中中高
交互还原度中(界面易残留多选态)高(radio 天然互斥)高
可维护性中高低
升级兼容性一般,依赖组件API高,只依赖基础组件差
适合场景快速交付、一次性页面长期维护的业务表单演示Demo,不推荐

结论:没有历史包袱就直接选路线 B;页面已经用了级联多选又不想动太多,可以看第 4 节的路线 A 进阶版。接下来先讲推荐方案。

3. 完整实现:自研 HybridCascader 组件搞定一级多选二级单选

3.1 数据结构与接口约定

先把输入输出约定清楚,后面代码好读很多。组件接收的options沿用树形结构:

options: [ { value: 'store_a', label: '华东旗舰店', children: [ { value: 'cat_phone', label: '手机通讯' }, { value: 'cat_pc', label: '电脑办公' }, { value: 'cat_home', label: '家用电器' } ] }, { value: 'store_b', label: '华北直营店', children: [ { value: 'cat_cloth', label: '服饰鞋包' }, { value: 'cat_food', label: '食品生鲜' } ] } ]

v-model绑定数组,每个元素是一个“父级 → 子级”配对:

[ { parentValue: 'store_a', childValue: 'cat_pc' }, { parentValue: 'store_b', childValue: 'cat_cloth' } ]

组件再暴露几个开关:

  • allowParentOnly:是否允许只选一级、不带二级。默认false,选了父级就必须给个二级;
  • defaultFirstChild:父级选中后是否自动选第一个子级。默认true,交互上能少点一次鼠标;
  • disabled:整组件禁用;
  • parentPlaceholder:一级下拉占位文案。

为什么要把这两个开关做成 props?因为业务上“二级必填”和“允许一级汇总统计”是两套完全不同的规则,写死在组件里,换一个项目就要改源码。做成配置项,一份代码通吃两个方向。

3.2 组件骨架与核心模板

组件模板长这样,这里是 Vue 2 + Element UI 版本:

<template> <div class="hybrid-cascader"> <el-select v-model="selectedParents" multiple filterable collapse-tags :disabled="disabled" :placeholder="parentPlaceholder" @change="onParentChange" > <el-option v-for="item in options" :key="item.value" :label="item.label" :value="item.value" :disabled="item.disabled" /> </el-select> <div v-for="pv in selectedParents" :key="pv" class="hybrid-cascader__block" > <div class="hybrid-cascader__parent-label"> {{ getParentLabel(pv) }} </div> <el-radio-group :value="childMap[pv] || ''" :disabled="disabled || getParentDisabled(pv)" @input="onChildSelect(pv, $event)" > <el-radio v-for="child in getChildren(pv)" :key="child.value" :label="child.value" :disabled="child.disabled" > {{ child.label }} </el-radio> <el-radio v-if="allowParentOnly" label="__NONE__"> 仅到一级 </el-radio> </el-radio-group> </div> </div> </template>

两个地方提醒一下:

  • Element UI 的el-radio的label是选项值,不是显示文案,很多人第一眼会弄混;新版组件已改成value,语义清楚一些;
  • __NONE__是我定义的空值占位,表示“仅到一级”,对外输出时统一转成空字符串,不让业务感知到这个内部约定。

3.3 核心逻辑:父级变更、子级互斥、值输出

组件内部状态只有两个:selectedParents(一级选中集合)和childMap(一级 → 二级映射)。所有业务规则都在这两个状态的转换里。

export default { name: 'HybridCascader', props: { value: { type: Array, default: () => [] }, options: { type: Array, required: true }, allowParentOnly: { type: Boolean, default: false }, defaultFirstChild: { type: Boolean, default: true }, disabled: { type: Boolean, default: false }, parentPlaceholder: { type: String, default: '请选择一级分类(可多选)' } }, data() { return { selectedParents: [], childMap: {} }; }, created() { this.applyValue(this.value); }, watch: { value: { immediate: true, handler(list) { this.applyValue(list); } } }, methods: { findParent(value) { return this.options.find((item) => item.value === value); }, getParentLabel(value) { const item = this.findParent(value); return item ? item.label : value; }, getChildren(value) { const item = this.findParent(value); return (item && item.children) || []; }, getParentDisabled(value) { const item = this.findParent(value); return item ? Boolean(item.disabled) : false; }, applyValue(list) { const parents = []; const map = {}; (list || []).forEach((pair) => { if (!pair || pair.parentValue == null) return; if (!parents.includes(pair.parentValue)) parents.push(pair.parentValue); if (pair.childValue === '__NONE__' || pair.childValue) { map[pair.parentValue] = pair.childValue || '__NONE__'; } }); this.selectedParents = parents; this.childMap = { ...map }; this.fillFirstChildForEmptyParents(); }, fillFirstChildForEmptyParents() { if (!this.defaultFirstChild) return; const map = { ...this.childMap }; this.selectedParents.forEach((pv) => { if (map[pv]) return; const children = this.getChildren(pv); if (children.length && !this.allowParentOnly) { map[pv] = children[0].value; } }); this.childMap = map; }, onParentChange(newParents) { const list = Array.isArray(newParents) ? newParents : []; const removed = this.selectedParents.filter((pv) => !list.includes(pv)); if (removed.length) { const map = { ...this.childMap }; removed.forEach((pv) => delete map[pv]); this.childMap = map; } this.selectedParents = list; this.fillFirstChildForEmptyParents(); this.emitValue(); }, onChildSelect(parentValue, childValue) { this.childMap = { ...this.childMap, [parentValue]: childValue }; this.emitValue(); }, emitValue() { const result = this.selectedParents .filter((pv) => this.allowParentOnly || this.childMap[pv]) .map((pv) => ({ parentValue: pv, childValue: this.childMap[pv] === '__NONE__' ? '' : (this.childMap[pv] || '') })); this.$emit('input', result); }, clear() { this.selectedParents = []; this.childMap = {}; this.emitValue(); }, getValidationError() { if (!this.selectedParents.length) return '请至少选择一个一级分类'; if (!this.allowParentOnly) { const missing = this.selectedParents.filter((pv) => !this.childMap[pv]); if (missing.length) { return `以下一级分类尚未选择二级分类:${missing.map(this.getParentLabel).join('、')}`; } } return ''; } } };

关键逻辑拆开讲:

  • onParentChange:用户增删一级时,先把被移除一级对应的二级映射清掉,否则会出现“父级没了、二级还残留”的脏数据;新选中的一级按配置自动补第一个子级,最后统一emit。
  • onChildSelect:radio 天然互斥,同一父级下选 A 就顶掉 B,这里只做“写入映射 + 输出”,完全不用担心多选残留。
  • emitValue:输出前过滤,allowParentOnly为false时,没有二级值的父级直接不进结果数组,避免提交空childValue;__NONE__对外转成空字符串,后端收到parentValue + childValue=''就理解为“仅到一级”。
  • clear:同时重置两个状态再emit,只清selectedParents忘了childMap是常见 bug,下次选中同一父级时二级会“幽灵式”复活。

3.4 初始值回显与外部更新

编辑页回显是容易翻车的点。组件对外是 v-model,父组件异步拿数据后才传数组进来,所以不能只在data里初始化一次。上面代码用watch监听value,immediate保证首次同步。

这里要警惕循环问题:组件emit input会更新父组件的value,父组件再传回来触发watch再走一次applyValue。还好applyValue是幂等的——把相同数组重新解析成相同状态,不会死循环。如果确认只有编辑回显这一个异步场景,也可以去掉deep,只在必要位置手动调用同步方法,省一点性能开销。

3.5 表单校验与清空

表单校验建议按两种姿势接入:

  • 简单场景:提交时手动调用组件实例上的getValidationError(),返回值非空就是错误信息,直接在页面顶部提示;
  • 组件库表单体系:在rules里写自定义validator,validator里读组件实例状态,下面是示意:
rules: { category: { validator: (rule, value, callback) => { if (!this.$refs.picker.selectedParents.length) { callback(new Error('请至少选择一个一级分类')); return; } const err = this.$refs.picker.getValidationError(); if (err) { callback(new Error(err)); return; } callback(); }, trigger: 'change' } }

清空动作可以直接调用clear(),也可以由父组件把value置空数组触发watch同步。两种方式底层逻辑一致,看页面习惯选一种就好。

3.6 体验细节:折叠标签、只读、无子级父级

有几个体验细节,实际项目里经常被问:

  • 一级数量多时,给el-select加collapse-tags,所有标签折叠成一行,弹层里还能看到完整列表,比一排 tag 清爽得多;
  • 某父级没有children又不允许只选一级时,这个父级选进来后下方 radio 组是空的,体验很差。建议在options预处理时给无子级父级加disabled,或者直接过滤掉不放进下拉;
  • 需要只读展示时,别让用户面对一整排禁用控件。用selectedParents.map渲染成纯文本列表“华东旗舰店 / 电脑办公”,出问题少,样式也干净。

4. 快速改造版:用级联组件多选模式做拦截

4.1 配置与核心代码

如果团队里已大量使用级联组件且不想自研,路线 A 的“事件拦截”能很快上线。基础配置:

<el-cascader v-model="pathList" :options="options" :props="cascaderProps" clearable filterable @change="handleChange" />
cascaderProps: { multiple: true, checkStrictly: true, emitPath: true }

两个关键开关解释一下:

  • multiple: true打开多选,否则只能选一个父级;
  • checkStrictly: true解除父子勾选联动,否则勾选“店铺A”会自动把它的所有子类目一并勾上,二级单选直接失去意义;
  • emitPath: true保证change拿到完整路径数组,方便按“一级+二级”重新组装。

4.2 二级单选联动如何做

事件拦截只解决“输出值”,解决不了“画面上还勾着多个二级”。要连界面一起修正,需要用到级联组件的默认插槽,把第二级节点的渲染替换成仿 radio,并在点击时通过节点方法做互斥:

<el-cascader v-model="pathList" :options="options" :props="cascaderProps" popper-class="multi-single-cascader" @change="handleChange" > <template #default="{ data, node }"> <template v-if="node.level === 1"> <el-checkbox :checked="node.checked" :indeterminate="node.indeterminate" @click.prevent="toggleFirst(node)" /> <span>{{ data.label }}</span> </template> <template v-else> <span class="second-radio" :class="{ 'is-active': node.checked }" @click.prevent="toggleSecond(node)" > {{ data.label }} </span> </template> </template> </el-cascader>
methods: { toggleFirst(node) { node.doCheck(!node.checked); }, toggleSecond(node) { if (node.checked) { node.doCheck(false); return; } // 同父级互斥:先清掉当前父级下所有兄弟节点的勾选 const siblings = node.parent.childNodes; siblings.forEach((sib) => { if (sib !== node && sib.checked) sib.doCheck(false); }); node.doCheck(true); } }

再配合一段 CSS 把第二级原生 checkbox 藏起来:

.multi-single-cascader .el-cascader-panel .el-scrollable:nth-child(n+2) .el-checkbox { display: none; }

4.3 这个方案的三个明显缺点

方案 A 改造完确实能用,但我在联调阶段被坑了几次,集中在三点:

  • 第一,node.doCheck是组件内部方法,不在公开文档里。同一版本能跑,升级组件库后是否不变全靠运气,必须回归面板交互;
  • 第二,CSS 按面板的列位置做样式隐藏,一旦存在“第一级叶子节点”、懒加载等面板列数不固定的场景,隐藏规则会错位;
  • 第三,级联面板的勾选态由组件内部统一管理,每次都要同步“内部状态、输出值、用户预期”三份数据,代码量少但心智负担大,后续接手的人很难快速看懂。

所以我把这个方案定位成“快速交付备用方案”。页面要长期维护、反复迭代,老老实实走第 3 节的自组装方案更省心。

5. 常见问题与排查实录

5.1 勾选状态和 v-model 对不上

用方案 A 最容易遇到:change里过滤后,组件内部的勾选集合还包含被过滤掉的二级,导致 v-model 和界面不一致。排查思路:先确认emitPath、checkStrictly是否生效,再确认change里拿到的路径是纯叶子值还是完整路径。如果只要求输出值正确、界面能接受,用过滤结果回填即可;如果界面也要一致,必须走 4.2 的节点方法同步。

5.2 二级选中后面板不关闭,用户以为没选上

多选级联面板在multiple模式下默认不自动关闭,用户点完二级看到面板还开着,会以为没点中。两个缓解办法:

  • 在节点里加自定义对勾图标,配合仿 radio 的 active 样式,让“已选中”在面板里非常醒目;
  • 需要选完即走的场景,可以监听change后调用关闭面板方法(同样依赖内部 API),或者干脆用自组装方案,二级本来就是普通 radio,天然没有这个问题。

5.3 一级选项特别多,下拉和渲染都卡

自组装方案里一级是多选el-select,数据量上来后一定要开filterable搜索。二级如果每个父级下面也有几十个 radio,别让一个区块竖排几十个单选项,可以改成可折叠勾选列表或者带搜索的次级下拉。一级列表超过几千条时,el-select本身也会吃力,需要换虚拟滚动方案,不过这属于通用下拉性能问题,跟级联语义关系不大。

5.4 后端要的不是嵌套数组,是扁平父子对

这是数据格式兼容里最常见的坑。级联组件默认输出路径数组,比如[['store_a','cat_pc'], ['store_b','cat_cloth']],而后端往往要求[{parentValue, childValue}]这种扁平结构。我在组件里统一成扁平结构输出,并在必要时提供转换方法:

toBackend(paths) { return paths.map((path) => ({ parentValue: path[0], childValue: path.length > 1 ? path[1] : '' })); }

接口边界定清楚之后,前端内部怎么改都不影响后端。

5.5 升级组件库版本后,面板定制全部失效

方案 A 的两个依赖点(节点内部方法、面板列样式)在升级组件库后都可能失效。我在一个老项目上就碰到过:组件库做了一次大升级,面板 DOM 层级变化,CSS 选择器命中位置全乱。处理办法是把所有针对面板的定制选择器和方法封装在一个独立文件里,升级时只改这个文件,并预留 2 到 3 个冒烟用例跑一遍:选父级、二级互斥、回显清空。方案 B 只依赖多选下拉和单选按钮组这两个基础组件,几乎不受这种升级冲击,这也是我最终推荐它的另一个原因。

6. 验收清单与上线前自查

6.1 建议的验收用例表

方案 B 上线前,我习惯把下面这些用例当成验收标准,基本能覆盖 80% 的回归场景:

编号操作预期结果
1不选一级直接进入下一步二级区块不显示,校验提示“请至少选择一个一级分类”
2选择一级A、一级B出现两个独立二级区块,互不干扰
3在A区块选二级X,再选YX 自动取消,只保留 Y
4取消一级A的勾选A 区块消失,结果里不再有 A 及其二级
5重新勾选一级A按defaultFirstChild自动补第一个二级,或保持为空
6allowParentOnly开启可选中“仅到一级”,对外输出childValue为空串
7编辑页异步回显一级选中项和二级 radio 选中项与保存值一致
8全部清空结果数组为空,校验提示恢复
9禁用状态下操作下拉和 radio 都不可点
10搜索一级关键词可过滤出一级选项,二级区块不受影响

6.2 容易被漏掉的边界场景

除了表里的用例,还有两个点最容易漏:

  • 提交前一定要用getValidationError或自定义 validator 再查一遍,防的是childValue为空串的数据被发出去;
  • 测二级互斥时,重点测“先选二级 → 删父级 → 再选父级”这条链路,历史上脏数据基本都是这条链路产生的。

回头复盘这个需求,最值钱的教训不是代码本身,而是“别跟组件的语义拧着来”。组件原生给的是全层多选,你要的是“一层多选、二层单选”,硬用拦截和内部方法拧出来的方案,短期能跑,长期会持续消耗你和你同事的精力。把语义拆分清楚,一级用多选组件、二级用单选组件,各管各的,反而成了最短路径。后来我又照着这个思路做了一版“父级单选、子级多选”的变体,套路完全一致:先确认每一层到底该是 radio 还是 checkbox,再决定用组件原生能力还是自组装。如果你手头也有这种对称不上的级联需求,建议先花十分钟把层级语义列出来再动手写代码,多半能少走一整天弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询