el-tree父级半选状态控制:手动接管父子联动
在后台管理系统里做权限树、组织架构树十有八九会撞上 el-tree 那个父子节点联动的“老毛病”默认情况下子级全部选中时父级自动全选子级选中一部分时父级显示半选。这个逻辑本身没毛病但真正追着你想骂人的是——半选的父级在数据上其实是“未选中”状态视图层却透着一股“好像选中了”的既视感等你回显数据或者提交结果时半选父级要不要传给后端全选父级算不算用户主动勾选这些破事一个接一个。这篇就来解决标题里那两个最扎心的问题半选框的父级到底该不该选中、全选框的父级什么时候才算选中。核心目标是让数据中子级不全选中时父级不进入选中态也就是把半选状态控制权抢回来同时实现“子级全部选中时父级才自动选中”。我会直接给三种由简到繁的处理思路重点讲透最后那种手动接管父级状态的做法附完整可复用代码适合正在被 el-tree 回显和勾选状态折磨的开发同学直接抄作业。1. 问题本质el-tree 的父子联动到底做了什么1.1 默认规则里的全选与半选从哪来el-tree 在没有特殊配置时check-strictly默认是 false也就是父子节点勾选状态互相关联。当你勾选某个叶子节点树内部会沿着它一路向上找父节点然后按一套固定算法重新计算每个父级的状态如果某个父节点的所有直接子节点都被勾选那这个父节点就会变成 checked 状态checkbox 显示为全选如果只有部分子节点被勾选父节点会变成 indeterminate 状态checkbox 显示为一条横杠也就是半选如果没有任何子节点被勾选父节点就是未选中。这套逻辑本身没什么问题但它的运算发生在 el-tree 内部的 store 里由 TreeStore 自动调度完成。问题在于真实业务场景下我们对“父级状态”的理解往往和这套默认机制不太一样尤其是涉及到回显、编辑、部分权限下发时默认机制会给我们制造一些看起来特别别扭的结果。1.2 半选在数据层与视图层的错位很多人第一次被坑是在提交数据时发现的。el-tree 的getCheckedNodes和getCheckedKeys返回的永远只是真正处于 checked 状态的节点半选节点不会出现在结果里。也就是说界面上父级明明显示成一个横杠看着像“选了一半”但你拿到的数据里根本没有这个父级。这一下就让前后端对账出了问题。前端展示时半选父级有反馈用户也默认这个父级“参与选择了”可提交的数据里却没有它。如果后端恰好是按父级权限码去鉴权那这个父级对应的权限就丢了。反过来另一种情况更头疼当你调用setCheckedKeys回显一份“父级 checkedtrue、子级部分选中”的脏数据时el-tree 会把父级强行置为全选态并且它自动联动子级导致界面上一大片节点都被勾上根本不是你想要的初始状态。1.3 典型业务场景权限树、组织架构树和回显修复这种问题在几种场景里尤其明显。第一种是权限分配树菜单和按钮层级混在一起用户只允许勾选最末级权限父级菜单只是分组容器既不应该被选中也不应该出现半选干扰。第二种是组织架构树部门下面挂岗位很多系统只允许选择岗位叶子部门只是展示层级如果部门跟着变成半选视觉上会显得整个部门都被选中了容易误导操作人。第三种是编辑回显数据表里存的是一组选中的节点 key其中可能混着父级 key。回显时 el-tree 父子联动会把父级的状态扩散到所有子级导致回显结果比实际数据“多选”了一大片。这三个场景背后其实是同一个诉求把父级节点选中状态的决策权从默认联动机制手里拿回来改由我们自己的规则控制。要么让父级彻底不参与勾选要么只允许“全部子级选中时父级才选中”要么至少要做到“半选状态可控、可清理”。2. 解决思路盘点先别急着写代码2.1 方案一禁用父级只让叶子节点参与勾选如果你的业务就是“父级只作为分组永远不应该被选中”那最干净的做法是让父级节点在树里直接失去勾选能力。具体实现是利用 el-tree 的props配置给父级节点动态设置 disabled。disabled 为 true 时父节点前面的 checkbox 会变成灰色不可点击状态天然不会全选也不会半选更不会进入任何勾选结果。叶子节点保持正常可勾选即可。template el-tree reftree :datatreeData node-keyid show-checkbox check-strictly :propstreeProps / /template script export default { data() { return { treeData: [], treeProps: { label: label, children: children, disabled: (data) data.children data.children.length 0 } }; }, mounted() { // 回显时只设置叶子节点父级天然不参与 this.$refs.tree.setCheckedKeys([111, 121]); } }; /script这里有几个细节要注意。第一建议同时把check-strictly设为 true否则即使父级 disabled当所有子级都选中时默认联动机制仍可能尝试更新父级状态虽然 disabled 节点不会被勾上但半选状态还是可能出现。第二回显时setCheckedKeys传入的数组里只放叶子节点 key不要把父级 key 放进去了不然把父级 key 设置成选中状态虽然界面不显示但可能污染后续数据。第三提交时用getCheckedKeys(true)第二个参数传 true 表示只取叶子节点这样结果里永远不会带父级。这个方案的优点是实现量最小、状态最干净缺点也很明显父级一旦 disabled用户想通过勾选父级实现“一键全选子级”的操作就没了交互上少了一截。2.2 方案二CSS 屏蔽半选视觉仅做展示层修正有时候我们不是想要改变数据逻辑只是受不了那个半选横杠的视觉干扰。比如编辑展示时用户觉得“父级半选”容易误导希望父级要么全选要么完全空着不要那种中间状态。这种情况可以只动 CSS。/* 隐藏半选横杠让半选父级看起来像未选中 */ .el-tree-node__content .el-checkbox.is-indeterminate .el-checkbox__inner { background-color: #fff; border-color: #dcdfe6; } .el-tree-node__content .el-checkbox.is-indeterminate .el-checkbox__inner::after { content: none; }这段 CSS 做的事情很简单把半选状态的背景色去掉边框恢复成普通未选中的灰色同时把 element 内置的横杠伪元素移除。这样父级在视觉上就是完全未选中的样子但 onClick 事件、getCheckedKeys、内部状态全都不会变。说实话这个方案我不推荐作为长期方案因为它本质上是在“骗眼睛”代码审查时别人很难一眼看懂你为什么要覆盖这个样式。而且你只是隐藏了视觉半选节点仍然存在于内部状态里后续接手的人如果没有意识到这一点迟早会踩坑。我更愿意把这个方案定位为“临时止血”而不是治本。2.3 方案三切断默认联动手动接管父级选中与半选前两个方案一个是直接禁用父级一个是只做表面功夫都不能够同时满足“半选可控、全选自动、数据干净”这三个要求。真正能解决标题问题的是第三种方案把 el-tree 默认的父子联动关掉然后在我们自己的代码里重新定义父级状态的计算规则。具体思路分三步。第一步设置check-strictly为 true。这样一来点击任意节点时只有节点自身的 checked 状态会变化el-tree 内部不会再自动去碰它的父级和子级。我们就拿到了所有节点状态的控制权。第二步在check事件回调里从当前发生变化的节点开始自底向上遍历它的所有父级。对每个父级节点检查它的全部直接子节点如果子节点全部处于 checked 状态就把父级设为 checked如果只有一部分子节点处于选中或半选状态就把父级设为半选如果子节点全都没选中就把父级设为未选中。第三步回显数据时在setCheckedKeys之后主动触发一次全量校正把树里所有父级节点按照最新子级状态重新计算一遍清除脏数据带来的错误全选和错误半选。这个方案看起来复杂其实每次处理的规则是固定的而且完全透明出了问题随时能查。2.4 三个方案怎么选方案核心思路优点缺点适用场景禁用父级check-strictly disabled实现简单父级彻底退出勾选无法一键全选子级交互受限只允许选叶子节点的权限树CSS 屏蔽半选覆盖半选样式改动量小不动数据逻辑内部状态仍在容易误导后继者只接受临时救急手动接管父级check-strictly 自定义向上更新状态完全可控半选由自己定代码量稍大需要理解节点对象需要精确控制父级选中态的复杂业务我个人在实际项目里绝大多数情况都直接用方案三哪怕业务只是“选叶子”这种场景方案三也能覆盖而且它保留了未来扩展“父级可一键全选”的可能性。3. 核心方案落地手动控制父级选中与半选3.1 开启 check-strictly 并解释为什么必须关掉默认联动方案三的第一步是在 el-tree 上配置check-strictly。这个属性中文含义是“是否严格遵循父子不互相关联”默认 false。当我们把它设为 true 时相当于告诉 el-tree你别帮我管父子关系了我自己来管。这步是后面所有操作的前提。如果不关掉默认联动就算你在 check 里手动改父级状态也会被 el-tree 内部紧接着执行的联动逻辑覆盖掉永远达不到可控的目的。我把这个属性放在所有配置的第一位就是因为它决定了整个方案的数据流走向。template el-tree reftree :datatreeData node-keyid show-checkbox check-strictly :propsdefaultProps checkhandleCheck / /template注意树的数据结构里父级节点的 children 字段必须是一个数组哪怕里面只有一个子节点也要用数组包起来。el-tree 判断一个节点是不是父级依据就是 props 里配置的 children 字段是否存在且有内容。3.2 在 check 中自底向上同步父级状态用户勾选或取消某个节点时会触发check事件。回调参数里有两个值第一个是当前节点的 data第二个是内部的 checkedInfo里面包含已选节点和半选节点的 key 列表。在这个方案里我们主要用第一个参数data 拿回来之后通过getNode(data)拿到的才是真正的节点对象它有 parent、childNodes、checked、indeterminate 这些属性。为什么一定要在 check 里做同步而不是用 watch因为 check 是勾选行为发生后最及时的钩子而且它能拿到具体是哪个节点变化了我们只需要从这个节点向上递归处理不需要全量遍历整棵树。如果数据量不大全量遍历也行但向上递归是更清晰地表达“子级影响父级”这一种方向性的变化。处理函数的核心逻辑如下methods: { handleCheck() { // 等 el-tree 内部把当前节点的 checked 状态存储好再处理 this.$nextTick(() { this.correctParents(); }); }, // 从所有节点里找到父级逐一校正 correctParents() { const store this.$refs.tree.store; const nodesMap store.nodesMap; Object.values(nodesMap).forEach(node { if (node.childNodes node.childNodes.length 0) { this.correctNodeStatus(node); } }); }, // 校正单个父节点的 checked 和 indeterminate correctNodeStatus(node) { const childNodes node.childNodes; const allChecked childNodes.every(child child.checked); const hasChecked childNodes.some(child child.checked || child.indeterminate); const targetChecked allChecked; const targetIndeterminate !allChecked hasChecked; this.$refs.tree.setChecked(node.data.id, targetChecked, false); node.indeterminate targetIndeterminate; } }每次 check 事件后我选择的是全量校正所有父级。可能有人会觉得这样有点笨但实际体验下来树节点数量在几百个以内时这点遍历性能完全无感而且代码逻辑最简洁不会遗漏任何一条路径。如果你的树有几千上万个节点再考虑改成从当前节点向上递归的版本那个版本需要多写一个递归函数本质上只是把correctParents里的全量循环改成自底向上的链式调用。3.3 回显数据时如何初始化父级状态回显是一个单独的场景很多人是在这里发现父级状态不对的。原因前面说过setCheckedKeys是把一组 key 逐个设置成 checked它不会按照我们想要的方式去清理父级。所以正确做法是分两步。第一步正常调用setCheckedKeys把数据里保存的节点回显到树上。第二步调用一次correctParents()把树里所有父级按子级真实状态重算一遍。这个顺序不能反如果先清理父级再回显回显时又把父级状态覆盖掉了。methods: { // 假设接口返回的权限 key 数组为 dataKeys initCheckedKeys(dataKeys) { this.$refs.tree.setCheckedKeys(dataKeys || []); this.$nextTick(() { this.correctParents(); }); } }这里有一个容易被忽视的细节setCheckedKeys在check-strictly为 true 时会把传入的所有 key 直接设置成 checked包括那些“父级节点 key”。也就是说如果你的数据里脏数据是父级 checkedtrue 但子级没勾全回显时父级还是会变成选中态。但这没关系紧接着调用的correctParents会按照子级实际勾选情况把父级重新拉回未选中或半选态相当于把脏数据洗白了一遍。另外如果回显的数据里只包含叶子节点 key也没有问题。correctParents会主动把那些叶子节点的父级标记成半选或全选展示上完全符合你对父子状态的预期。3.4 完整可运行代码示例我把上面所有环节串起来给一份可以直接贴到项目里改改就用的完整示例。树结构我就用最简单的部门-岗位两层举例实际情况换成你自己的数据就行。template div el-tree reftree :datatreeData node-keyid show-checkbox check-strictly :propsdefaultProps checkhandleCheck / el-button typeprimary clickgetResult获取选中结果/el-button /div /template script export default { name: CustomTree, data() { return { treeData: [ { id: 1, label: 研发中心, children: [ { id: 11, label: 前端组, children: [ { id: 111, label: 张三 }, { id: 112, label: 李四 } ] }, { id: 12, label: 后端组, children: [ { id: 121, label: 王五 }, { id: 122, label: 赵六 } ] } ] } ], defaultProps: { label: label, children: children } }; }, mounted() { // 模拟回显张三被选中李四未选中 this.initCheckedKeys([111]); }, methods: { handleCheck() { this.$nextTick(() { this.correctParents(); }); }, initCheckedKeys(keys) { this.$refs.tree.setCheckedKeys(keys || []); this.$nextTick(() { this.correctParents(); }); }, correctParents() { const nodesMap this.$refs.tree.store.nodesMap; Object.values(nodesMap).forEach(node { if (node.childNodes node.childNodes.length 0) { this.correctNodeStatus(node); } }); }, correctNodeStatus(node) { const childNodes node.childNodes; const allChecked childNodes.every(child child.checked); const hasChecked childNodes.some(child child.checked || child.indeterminate); const targetChecked allChecked; const targetIndeterminate !allChecked hasChecked; this.$refs.tree.setChecked(node.data.id, targetChecked, false); node.indeterminate targetIndeterminate; }, getResult() { const checkedNodes this.$refs.tree.getCheckedNodes(); const halfCheckedNodes this.$refs.tree.getHalfCheckedNodes(); console.log(全部选中的节点, checkedNodes.map(n n.id)); console.log(半选节点, halfCheckedNodes.map(n n.id)); } } }; /script这份代码跑起来以后行为是只勾选 111 时11 这个父级会半选1 这个祖父级也会半选当 111 和 112 都勾上时11 变成全选当 11 和 12 两组下面四个岗位全勾上时1 才会变成全选。取消任何一个岗位它的父级和祖父级会相应回退。这正是标题里说的“子级不全选中时父级不选中即半选”的精确控制结果。3.5 为什么每一步都要这么处理好多人会有疑问既然默认行为也能算半选全选为什么非要绕一圈手动算区别在于“默认行为是一刀切手动行为是按业务规则来”。默认行为里父级选中就一定会联动子级全部选中这在某些业务里意味着误操作自定义逻辑里父级选中只是“子级全部选中”的镜像结果不会反过来影响子级。另一个关键点是setChecked(nodeKey, checked, false)的第三个参数。这里的 false 表示不递归子节点只改当前节点本身。这个参数非常重要如果不传或传 true会引发新一轮父子联动把子级状态又改飞了。你在写自己的校正函数时务必把这个参数固定为 false。至于node.indeterminate targetIndeterminate这一步它是在 setChecked 之外单独补的一手。因为 el-tree 没有官方 API 直接设置一个节点的半选状态而 setChecked 只会处理 checked所以半选状态需要我们自己往节点对象上写。这样写虽然有点“侵入内部对象”的味道但它能保证视图层立即刷新el-tree 在渲染 checkbox 时读取 node.indeterminate 来决定是否显示半选横杠。注意在部分 old element-ui 版本里直接修改 node.indeterminate 可能不会触发视图刷新。如果发现界面上的半选横杠没更新可以在correctParents最后加一句this.$forceUpdate()强制触发当前组件重新渲染。实测下来这个兜底办法简单有效。4. 常见问题与排查技巧实录4.1 为什么 setChecked 之后父级状态又变了这是我自己做得最多的一种排查场景。现象是明明在 check 里手动 setChecked 把父级设置好了结果界面上的父级还是保持原来的旧状态或者过了一秒又被“打回原形”。十有八九是因为项目里 el-tree 没设置 check-strictly父子联动仍然生效。el-tree 的默认联动不仅会响应点击也会响应 setChecked 的调用。你在 check 里改父级的同时内部的联动逻辑也基于当前的子级状态重新计算了一次父级两边打架谁后执行谁就赢。解决方式就是严格按上面的方案来先把 check-strictly 设成 true再谈手动控制。没关默认联动之前任何手动修改父级状态的代码都是脆弱的。4.2 直接改 node.indeterminate 不生效怎么办这个问题的具体原因前面提过el-tree 的 node 对象不是 Vue 响应式数据Vue 侦测不到它的属性变化所以视图不会自动更新。尤其在旧版本里改完 node.indeterminate 之后界面纹丝不动。我的处理顺序是先用setChecked修改 checked 状态这本身会触发该节点重渲染然后在下一行写 node.indeterminate再补一个this.$forceUpdate()作为强制刷新兜底。如果项目里用了 Vue 2$forceUpdate 在组件实例上直接可用如果是自定义封装组件确保调用的是当前树的所属组件实例。这套组合目前没遇到失效的情况。4.3 getCheckedKeys 结果不够干净用我给出的方案处理过之后getCheckedKeys 返回的数组里会有父级节点吗会有而且这是正常的。当某个父级的所有子节点全部被勾选时我们的手动逻辑会把父级也置为 checked所以它当然会出现在 getCheckedKeys 结果里。但这往往不是业务想要的。大多数权限接口只希望提交叶子节点 id不需要父级 id 陪跑。推荐提交时用getResult() { // 第二个参数 leafOnly 设为 true只返回叶子节点 const leafKeys this.$refs.tree.getCheckedKeys(true); // 如果还需要半选节点做特殊处理 const halfKeys this.$refs.tree.getHalfCheckedKeys(); console.log(提交用叶子key, leafKeys); console.log(半选key, halfKeys); }getCheckedKeys 的第二个参数 leafOnly传 true 之后会从结果里过滤掉所有非叶子节点。这样既不会误传父级 id也保留了我们辛苦维护好的半选状态用于回显。4.4 父级点击展开收起会干扰勾选状态吗el-tree 默认情况下点击父级节点的 label 文字会同时完成两件事切换展开/收起状态以及触发展开节点时的勾选联动。如果用户只是习惯性点击父级文字想展开目录结果把父级底下一排子节点全勾上了这种体验是非常惊悚的。在这个方案里因为 check-strictly 已经为 true父级点击不会联动子级勾选这个风险天然解除。但你最好再加一个属性:expand-on-click-nodefalse让节点文字只负责展开收起勾选只通过显式点击 checkbox 触发。用户交互会清楚很多。4.5 常见问题速查表现象可能原因解决方案父级状态改了又被覆盖未关闭默认联动设置 check-strictly 为 true半选横杠不更新node 非响应式修改后调用 $forceUpdate提交数据里混入父级 idgetCheckedKeys 没过滤叶子使用 getCheckedKeys(true)点击父级文字导致误勾选expand-on-click-node 默认开启将其设为 false回显后父级全选错乱脏数据混入 checked keyssetCheckedKeys 后重新 correctParentssetChecked 牵连子级deep 参数传了 true固定传 false5. 一点经验之谈这个手动接管父级状态的方案我前后在好几个后台项目里都用了。最先是在一个权限分配模块里当时被“回显后父级全选、保存后又缺父级”这种来回拉扯折磨了两天。后来想明白一件事el-tree 默认的父子联动面向的是通用需求它不可能读懂每个业务里“父级”这个角色的特殊地位。与其在它算好的状态上修修补补不如彻底关掉它把状态计算这块逻辑收回到自己手里。代码实现其实并不复杂真正的难点在于理解 Node 对象里 checked 和 indeterminate 这两个属性谁优先、谁联动谁。一旦把这两者的关系想清楚后面无论遇到多少层层级嵌套的树都是同一套递归逻辑反复应用而已。如果你正在做一个层级很深、节点很多的组织树也可以把全量遍历优化成自底向上的递归更新原理不变性能更好。希望这篇能帮少走点弯路。