做中后台系统的人,几乎都会在某个版本里被el-tree的勾选逻辑绕晕一遍。标题里这个问题我拆开说:默认父子联动模式下,你勾选几个子节点,父节点只显示半选(checked是false,但复选框上有一条横杠);可一旦把子节点全部勾上,父节点就变成了完整勾选,getCheckedNodes()里会多出父节点。业务上经常希望“子级没全选时,父级不进入选中数据,只保留半选状态”,真正提交数据时又要把全选导致的父级冗余过滤掉。这篇文章就把半选框与全选框的前因后果讲透,附上能直接抄的过滤、回显和提交代码。
1. 业务里最常见的两个“别扭”:半选父级拿不到,全选父级被多传
1.1 只勾部分子级时,父级明明半选却不进选中列表
先还原一下默认场景。假设权限菜单树长这样:
treeData: [ { id: 'system', label: '系统管理', children: [ { id: 'user', label: '用户管理' }, { id: 'role', label: '角色管理' }, { id: 'menu', label: '菜单管理' } ] } ]页面里用了最普通的写法:
<el-tree ref="tree" :data="treeData" show-checkbox node-key="id" :props="{ label: 'label', children: 'children' }" />我在某个中后台项目里第一次做权限分配时,只勾选了“用户管理”和“角色管理”,然后打开控制台看数据,结果是这样的:
tree.getCheckedNodes() // 返回 [{id:'user'}, {id:'role'}] // 半选的“系统管理”不在里面 tree.getHalfCheckedNodes() // 返回 [{id:'system'}]也就是说,el-tree的半选状态只能通过getHalfCheckedNodes()拿到,getCheckedNodes()永远只捞checked为true的节点。这带来的直接后果是:如果提交时只把getCheckedNodes()的结果传给后端,后端根本不知道“系统管理”处于部分授权状态,它只知道用户勾了user和role两个叶子节点。
有些权限模型里这不算问题,因为后端可以根据叶子反推父级。但如果你要做的是一套“父级半选也单独存储标记”的分级权限系统,父级id就必须跟着提交,否则回显时后端给不了你半选状态,前端也会丢失树的视觉一致性。
1.2 子级全部勾上后,父级又“被选中”地进提交数据
另一个方向的问题更隐蔽。当我把三个子节点全部勾选时,“系统管理”自动变成全选状态:
tree.getCheckedNodes() // 返回 [{id:'system'}, {id:'user'}, {id:'role'}, {id:'menu'}]这个行为在树形控件的设计上完全合理——子级全选、父级自然全选,这是父子联动的基本规则。但在真实业务里,它经常不符合预期。
还是拿权限菜单举例。后端接到前端提交的菜单权限id列表,如果前端把system也传过去,后端就会认为“系统管理”这个父级菜单被完整授权。问题来了:很多后台菜单树里,父级菜单只是一个分组壳子,真正的授权粒度是子级菜单和按钮。把父级id直接入库,轻则造成授权数据冗余,重则导致同一父级下的新增子级菜单被无差别放行,权限直接越界。
这两个现象虽然方向相反,本质却是一个:el-tree在父子联动模式下,父级是否checked完全由子级推导出来,它不会区分“用户手动勾选父级”和“子级全选导致父级被联动选中”。要么拿不到半选父级,要么多拿全选父级,全靠我们自己处理。
下面这张表可以直观对比不同勾选情况下各API的返回差异:
| 子级勾选情况 | 父级视觉状态 | 父级checked | 父级halfChecked | getCheckedNodes() | getHalfCheckedNodes() |
|---|---|---|---|---|---|
| 勾选1个 | 半选 | false | true | 不含父级 | 含父级 |
| 勾选2个 | 半选 | false | true | 不含父级 | 含父级 |
| 勾选全部 | 全选 | true | false | 含父级 | 不含父级 |
2. el-tree联动勾选的判定原理:半选、全选是怎么算出来的
2.1 check-strictly=false状态下的父子状态推导规则
el-tree默认的check-strictly是false,也就是“父子节点不严格关联”。这个模式下的状态推导规则是自下而上的:每勾选或取消一个节点,它所有祖先节点的状态都会被重新计算。
计算规则其实只有三条:
- 一个父节点所有直接子节点都处于checked,父节点变成checked
- 一个父节点部分直接子节点处于checked,父节点变成halfChecked
- 一个父节点没有直接子节点处于checked,父节点变成未勾选
注意“直接子节点”这几个字。el-tree在做状态推导时,只看当前节点下一层的children,不会递归到孙节点。我见过很多人在排查半选问题时,以为父级半选代表“整棵子树内有部分节点没选”,实际上它只代表“直接子级这一层没有全部选”。
举个例子:某父级下有两个一级子节点A和B,A下面还有两个二级子节点A1、A2。如果你把A1、A2全部勾选,又把B勾选,那么父级的两个直接子级A和B都处于checked状态,父级就会显示全选,哪怕A下面还有一堆没勾的孙子节点。这个细节在权限分配时非常重要——对父级来说,它只关心直接下属的完整性。
2.2 getCheckedNodes与getHalfCheckedNodes之间的数据差
之所以那么多人在这个问题上踩坑,是因为el-tree把“选中”和“半选”分成了两套独立的数据集合,分别维护:
- checked:节点是否被勾选
- halfChecked:节点是否处于半选状态
在父子联动模式下,一个节点可以是半选但checked为false,也可以是checked但halfChecked为false。具体到组件API:
getCheckedNodes(false):返回所有checked节点,注意这个返回值里既包含用户真正手动勾选的叶子,也包含因为子级全选而被自动置为checked的父级。你没法直接从返回值分辨它是“用户手动勾的”还是“联动带出来的”。getHalfCheckedNodes():返回所有halfChecked节点,也就是那些子级只勾了一部分的父级。没有被自动纳入checked集合。
这个设计本身是合理的,它把两种状态看成互斥关系。问题在于很多业务接口希望一次拿全“用户意图”。用户勾了两片叶子,父级是一个半选状态,但接口数据里没有父级位置,半选信息就凭空丢了。
2.3 leafOnly参数为什么能过滤父级却又不是万能解
el-tree的getCheckedNodes(leafOnly)和getCheckedKeys(leafOnly)既然叫leafOnly,官方说明是“是否只返回叶子节点”。很多人一看这名字,直接用getCheckedKeys(true)来过滤全选父级,省事。
这个做法在大多数情况下是对的,但有个隐蔽的坑:leafOnly的判断标准是“节点有没有children”,而不是“这个节点在业务上是不是叶子”。也就是说:
- 有children的节点:无论它checked与否,leafOnly都不返回
- 没有children的节点:一律当叶子返回,哪怕它在业务语义上是中间层级
比如一个只有两层的菜单树,父级是“页面组”,子级是“页面”,那么没有children的节点就是真正的页面,leafOnly过滤后的结果正好是你要的。但有些树数据里,中间层级的节点可能因为某些原因没有配置children(比如懒加载还没展开),或者有一个空目录节点,它没有子级,业务上却仍是一个“目录容器”,不该被当作可授权叶子。leafOnly会把它留下,你得再补一层业务判断。
所以leafOnly能解决大部分全选父级冗余的问题,但不要把它当成银弹。具体到代码里,我会建议用“叶子判断 + 业务字段判断”双保险。
3. 三种改造思路:让父级在半选与全选之间“听话”
3.1 方案一:提交前按叶子节点过滤,保留el-tree默认联动
这是最推荐的做法,改动量最小,风险最低。核心思路是:不改变el-tree默认的父子联动行为,只在提交数据时做一次清洗,把不需要的父级id过滤掉,同时把半选父级单独取出来提交。
具体操作就三步:
getCheckedKeys(false)拿到全部被选中的key(包括全选父级)- 用树数据过滤掉非叶子节点
getHalfCheckedKeys()拿到半选父级key,单独放进一个字段提交
这个方案有一个天然优势:el-tree内部的联动逻辑完全保留,用户界面上的半选、全选、取消联动都很正常,我们只是在下游做了一道闸门。而且代码逻辑简单直接,后期维护成本低,同事接手时不需要理解一整套自定义状态机。
它的限制也很明确:过滤后,用户手动勾选某个父级(连带全选所有子级)的情况,父级会被过滤掉,提交的数据里只有子级id。如果产品上允许“只授父级权限、子级全不授”这种操作,这个方案就挡不住了。要允许这种操作,得走到下面两个方案。
3.2 方案二:开启check-strictly后手动维护父子状态
check-strictly设为true后,el-tree的父子勾选状态完全解耦——勾选父级不会带出子级,勾选子级也不会影响父级。好处是所有勾选都来自用户主动操作,不会产生“联动带出来的父级”,提交的数据干净。
坏处也很明显:界面上的半选提示没有了。因为半选状态本来就是父子联动计算出来的,一旦解耦,el-tree不会自动帮你把父级变成半选。想要半选效果,得自己在每次勾选事件里重新计算所有父级状态,手动调用setChecked去纠正。
我在项目里整理过一个最小实现思路:
handleCheck() { const tree = this.$refs.tree const nodes = this.flattenTree(this.treeData) // 从深层往浅层遍历,更新每个父节点的状态 nodes .filter(node => node.children && node.children.length > 0) .reverse() .forEach(node => { const childIds = node.children.map(child => child.id) const checkedCount = childIds.filter(id => tree.getNode(id)?.checked).length if (checkedCount === 0) { tree.setChecked(node.id, false) } else if (checkedCount === childIds.length) { tree.setChecked(node.id, true) } else { // 半选状态无法通过 setChecked 直接设置 // 要么放弃UI半选,在提交数据中单独标识 halfCheckedKeys // 要么用额外类名自己画半选样式 } }) }写到这里你应该能感觉到,方案二的上手成本明显更高。它在代码控制力上是最强的,但因为el-tree没有公开设置halfChecked的API,半选UI反而成了短板。如果你只是为了“提交数据好处理”而开启check-strictly,那半选的视觉反馈基本相当于放弃了。
3.3 方案三:业务状态与树展示彻底解耦
第三种思路是干脆不让el-tree管理业务勾选状态,把它当一个纯展示组件用。所有勾选状态存到自己的data里,每次@check事件触发后重新计算整棵树的状态,再驱动视图刷新。
这个方案适合超大规模树、树节点跨页面联动、以及多个组件共用同一套选中状态的场景。比如左边是树,右边是已选列表,还要有搜索过滤、批量勾选功能,这时候业务状态独立成一份,反而比依赖el-tree内部状态灵活。
具体实现上,要给每个节点维护一个自己的选中状态对象,渲染时通过render-content或自定义插槽控制复选框,勾选事件只更新自己的状态。这个方案代码量最大,但在多端同步、后端驱动权限模型的场景里,摆脱组件自带状态以后所有行为都是可控的。
三种方案的取舍,用一张表说清楚:
| 对比维度 | 方案一:提交时过滤 | 方案二:check-strictly手动联动 | 方案三:状态完全解耦 |
|---|---|---|---|
| 代码改动量 | 小 | 中 | 大 |
| 半选UI提示 | 保留,由el-tree自动完成 | 基本放弃,需自定义 | 可以自定义 |
| 是否可提交父级权限 | 不可以(会被过滤) | 可以 | 可以 |
| 适用场景 | 大多数中后台权限树 | 需要父级独立授权 | 多组件共享、复杂交互 |
4. 实战代码:过滤全选父级、提交半选父级、修复回显
4.1 提交前清洗:保留叶子,附带半选父级
这里我给一份可以直接改改就用的提交数据构造方法。假设树数据里有业务字段nodeType,用来区分“菜单”和“按钮”,叶子节点可能是按钮,也可能是没有子级的菜单:
methods: { // 工具方法:按id在树中查找节点 findNode(nodes, id) { for (const node of nodes) { if (node.id === id) return node if (node.children) { const target = this.findNode(node.children, id) if (target) return target } } return null }, buildSubmitPayload() { const tree = this.$refs.tree if (!tree) return null // 1. 获取全量勾选key,包含联动带出的父级 const checkedKeys = tree.getCheckedKeys(false) // 2. 过滤:只保留没有children的节点 // 如果业务中允许“父级本身可授权”,需要在这里再加nodeType判断 const submitCheckedKeys = checkedKeys.filter(id => { const node = this.findNode(this.treeData, id) return node && (!node.children || node.children.length === 0) }) // 3. 获取半选父级key const halfCheckedKeys = tree.getHalfCheckedKeys() return { checkedKeys: submitCheckedKeys, halfCheckedKeys } }, handleSubmit() { const payload = this.buildSubmitPayload() // 提交给后端 // this.$http.post('/api/role/permissions', payload) } }这里有两个点要特意解释。
第一个是为什么我用getCheckedKeys(false)而不是getCheckedKeys(true)再手动过滤。因为leafOnly参数拿到的是“组件意义上的叶子”,而实际权限模型里允许授权的节点不等于组件叶子。比如一个菜单下面没有任何子菜单,它在组件里是叶子,但它本身可以被授权;一个目录节点下面暂时没有子级,它在组件里也是叶子,但它不应该被授权。所以手动遍历树数据判断children,或者再加业务字段判断,更可靠。
第二个是半选父级为什么要单独提交。后端拿到halfCheckedKeys后,可以精确知道哪些父级处于部分授权状态,配合叶子key一起做权限计算。如果只给叶子key,后端要自己从树结构里反推哪些父级是半选,一旦前后端树结构不一致,反推结果就全错了。
4.2 回显时只传叶子,父级半选会自动恢复
很多人回显权限树时,习惯把后端返回的所有key一股脑塞给setCheckedKeys。这里有个容易翻车的点:如果后端保存了父级id,而你回显时把这些父级id也传进setCheckedKeys,在父子联动模式下,父级会被当成“用户勾选了父级”,连带触发所有子级全选。结果是用户看到的树和当初提交时完全不同,权限莫名其妙被放大。
正确的回显姿势是:只把叶子节点key传给setCheckedKeys。叶子勾选之后,el-tree会自动向上推导父级的半选或全选状态,视觉和数据都能还原。
async loadRolePermission(roleId) { // 假设后端返回 { checkedKeys: [...], halfCheckedKeys: [...] } const res = await this.fetchRolePermission(roleId) const checkedKeys = res.checkedKeys || [] // 避免上次的勾选残留 this.$refs.tree.setCheckedKeys([]) // 只设置叶子节点key,父级状态交给联动推导 // 这里用上一节的 findNode 方法做过滤 const leafKeys = checkedKeys.filter(id => { const node = this.findNode(this.treeData, id) return node && (!node.children || node.children.length === 0) }) this.$nextTick(() => { this.$refs.tree.setCheckedKeys(leafKeys) }) }为什么非要$nextTick?因为el-tree必须等数据渲染完、节点实例创建好之后,setCheckedKeys才会生效。如果你把setCheckedKeys放在树数据赋值之后同步执行,节点还没创建,keys根本匹配不上。这个坑很隐蔽,但是报错率极高。
至于后端返回的halfCheckedKeys,在回显时其实不需要传给组件。只要叶子key正确,父级半选会自动被算出来。有一种例外情况我下一节单独说。
4.3 父级半选但子级全取消的异常分支
有一种数据形态会让上面的回显逻辑失效:后端存储的是父级半选标记,但对应的子级key一个都没有。比如业务模型里规定“半选父级意味着该分组下所有子权限都不可用”,只存了一个父级标记。回显时叶子key数组为空,setCheckedKeys([])清空所有勾选,父级半选自然也不会出现。
遇到这种需求,老实说更建议调整后端数据结构。因为el-tree的半选是“子级勾选推导出来的结果”,不是独立可设置的属性。在前端硬凑半选,只能往树里塞一个勾选的假子级,再想办法隐藏它,代码非常脆弱,属于拿胶带补飞机的做法。
如果一定要处理,可以这样绕过:回显时给半选父级下的任意一个真实子级设置checked为true,让父级重新进入半选状态,然后把整个勾选结果重新取出来,去掉那个假勾选的子级再存一遍。但这样做的代码可读性和维护性都很差。我的建议是,把半选父级的展示从“复选框半选”改成“后置标签渲染”,比如在父级右侧加一个“部分授权”的标记,完全绕开el-tree的状态模型。
5. 项目里容易翻车的几个细节与规避经验
5.1 懒加载树中leafOnly会误伤未展开节点
懒加载是el-tree的高频使用场景,很多团队喜欢在权限树里用懒加载,因为菜单层级深、节点多,一次性加载所有节点性能太差。
但懒加载模式下的getCheckedKeys(true)会有一个大坑:未展开的节点在树里没有真实的节点实例,它的children是undefined,leafOnly会把它们当成叶子节点。假设一个父节点下面有500个子节点还没加载,你勾了这个父节点,然后调用getCheckedKeys(true),得到的可能是“父节点本身”而不是那500个子节点。
我实际遇到的案例是:勾选父级后保存,后端收到的权限key里只有父级id,所有子级id全丢了。排查了很久才定位到是leafOnly在懒加载下失效。
解决思路有两种:
- 懒加载树不用leafOnly,改用
getCheckedKeys(false),然后在提交前用后端完整树数据做过滤。 - 如果树数据本身在提交时能从后端拿到全量,就先把全量数据缓存到前端,过滤逻辑跑在全量数据上,而不是组件节点上。
5.2 使用Element Plus时API的兼容差异
如果你在Vue 3项目里用的是Element Plus,大部分API和ElementUI是保持一致的,比如getCheckedKeys、getHalfCheckedKeys、setCheckedKeys这些方法名都没变。
有几个细节值得注意。Element Plus里check-strictly这个prop从ElementUI的连字符写法变成了驼峰checkStrictly,虽然模板里连字符写法一般也能兼容,但如果你在脚本里动态设置prop,务必用驼峰。
另外Element Plus某些版本对半选状态的维护时序有细微差别。我在某个基于Element Plus 2.2.x的项目里遇到过:回显时setCheckedKeys传叶子key,父级半选状态没有立刻刷新,需要在nextTick之后再调用一次强制刷新:
this.$refs.tree.setCheckedKeys(leafKeys) this.$nextTick(() => { // 某些Element Plus版本需要主动触发一次默认勾选刷新 const tree = this.$refs.tree tree.setCheckedKeys(tree.getCheckedKeys(false)) })这个方法不算优雅,但确实能解决版本差异导致的半选刷新延迟。遇到半选显示不对的时候,可以试试这个偏方。
5.3 后端数据结构设计建议
聊到这里顺便说说后端接口设计,因为前端过滤得再干净,后端数据结构设计不合理,前端照样拿它没办法。
我比较推荐后端接收这样的结构:
{ "checkedKeys": ["user", "role"], "halfCheckedKeys": ["system"] }checkedKeys放真正完整授予权限的叶子或授权节点,halfCheckedKeys放处于部分授权状态的父级。后端拿到这两份数据,可以精确恢复出整棵树的授权快照,不依赖前端传一整棵树的完整状态。
有些后端团队喜欢让前端“把整棵树的完整勾选状态都传过来”,包括每个节点的checked、halfChecked、unchecked,理由是“还原起来最省事”。这个方案听起来美好,但实现起来前端代码会很臃肿,而且树数据一变,老数据全部作废。基于key的提交方案,至少在树结构调整时,后端还能通过父级关系重新推算授权范围。
至于前端过滤时用getHalfCheckedKeys()拿到的半选父级,我建议提交前做一次去重校验:如果某个节点同时出现在checkedKeys和halfCheckedKeys里,说明状态冲突,优先保留checkedKeys里的结果,把半选标记去掉。这种冲突理论上不会出现,但树结构在编辑过程中被改来改去时,出现过脏数据,多一层防护总没坏处。