首页 / 资讯中心 / 文章详情

el-tree半选与全选的父级处理逻辑:从原理到提交回显的完整方案

el-tree半选与全选的父级处理逻辑:从原理到提交回显的完整方案 ★ FEATURED ARTICLE
做后台管理系统时凡是要上权限树、菜单树、组织架构树这类组件el-tree 的复选框状态总是最容易让人翻车的地方。尤其是“子级不全选时父级显示半选”和“子级全选后父级也进入选中态”这两组行为如果不搞清楚它内部的状态机很容易在提交数据时把不该传的父级ID一起发给后端或者在回显数据时把父级状态弄成一团乱麻。这篇就围绕 el-tree 里半选框与全选框的父级处理逻辑把我实际项目里验证过的处理方式完整拆一遍。适合刚接触 el-tree、被 check-strictly / getCheckedKeys / getHalfCheckedKeys 这几个 API 绕晕的朋友也适合已经踩过坑、想要个稳妥方案直接抄作业的开发者。1. el-tree 半选与全选的底层逻辑拆解先说一个容易忽略的事实el-tree 的复选状态不是简简单单“勾选一个节点”那么简单。它内部维护了两套状态一套是“选中节点集合”一套是“半选节点集合”。父级节点到底是全选、半选还是不选取决于子级节点的勾选情况而这一切又由check-strictly属性决定。1.1 check-strictly 决定父子是否联动check-strictly默认是false表示父子节点严格联动勾选父节点所有子孙节点自动被勾选。取消父节点所有子孙节点自动被取消。子节点部分被勾选时父节点自动进入半选状态。如果把它设为true父子节点就完全独立勾选父节点不会影响子节点子节点勾选也不会让父节点出现任何半选或全选的联动效果。很多朋友一开始不理解为什么半选状态时父级“不选中”。其实在半选状态下父节点的checked属性是falseindeterminate半选标记是true。从语义上讲半选本身就不是“选中”它只是 UI 上的一个中间视觉状态。这个设计本意是好的但在实际项目中恰恰因为半选节点不算选中节点导致回显、提交时对父级ID的处理容易出现预期差。1.2 半选节点不算选中节点这是整个问题里最核心、也最反直觉的一点。el-tree 提供了两组方法getCheckedKeys()返回所有勾选的节点 key也就是checked true的节点。getHalfCheckedKeys()返回所有半选节点的 key也就是indeterminate true的节点。这两组数据是互斥的。半选父级虽然在页面上有一个“横杠”或“小方块”的视觉效果但它不会出现在getCheckedKeys()的结果里。反过来说getCheckedKeys()返回的节点里除了业务上真正勾选的叶子节点还会包含“因为所有子级都勾选而被自动联动选中”的父级节点。这就是标题里说的“全选框父级选中问题”的根源当你把某个父级的所有子级都勾选上父级会自动进入选中态随后它也会出现在getCheckedKeys()返回数组里。我在某个权限系统里就遇到过这种情况用户把某个菜单下的所有按钮权限都勾选后提交时接口拿到了一堆父级菜单ID后端直接报“父级权限不可直接分配”。当时第一反应是 el-tree 出 bug 了后来仔细一查发现是自己在提交时没有区分“半选父级”和“联动选中的父级”。1.3 原生联动下父级三种状态一览为了说清楚我把父子联动模式下父级可能出现的三种视觉状态和它对应的数据状态整理成一张表子级勾选情况父级视觉状态checked 状态是否在 getCheckedKeys 中是否在 getHalfCheckedKeys 中全部未勾选未选中空框false否否部分勾选半选横杠框false否是全部勾选全选对勾框true是否从这张表可以清楚看到原生行为其实已经满足了“子级不全选中时父级不选中、只显示半选”的需求。那为什么很多项目里还是会出问题问题往往出在后续两个地方一是勾选事件发生后有人手动把父级塞进了选中集合二是回显时传入的初始选中 key 里混入了父级ID。2. 需求拆解父级到底该不该进选中集合搞清楚底层机制之后下一步要做的是回到业务层面把“父级”这个角色的定位想清楚。不同业务树里父级的意义完全不同处理方式也完全不同。如果连这一步都没想明白后面写多少代码都是白搭。2.1 业务树里父级的两种定位我在项目里见过两类典型的树形数据。第一类父级只是“分组容器”本身没有业务ID或者有ID但根本不该提交。比如组织机构树里的“部门分组”权限树里的“菜单模块”。这种树里的叶子节点才是真正要在后端落库的数据父级纯粹是为了 UI 展示和操作方便。第二类父级自身就是一条需要独立保存的数据。比如分类树里父分类和子分类都对应数据库里的记录勾选父分类本身就是一种有效业务操作。这种情况下父级进入选中集合不但没问题反而是必须的。“父级该不该进选中集合”本质上是上面两种业务定位的取舍。而 el-tree 默认的联动逻辑天然把这两种情况混在一起子级全选时父级会进入选中集合不管你愿不愿意。2.2 思路A接受原生联动提交时做过滤对第一类业务树最务实、最简单的方式就是保留 el-tree 默认的父子联动能力让页面上的半选、全选效果完全交给组件自己算。提交时再单独做一层“父级ID过滤”从getCheckedKeys()的结果里把父级ID剔除掉。这个思路的好处是代码量小、不容易出错你能完全享受 el-tree 成熟的联动交互。缺点是父级在“子级全部选中”时还是会显示为全选框只是提交数据里不带它而已。如果产品经理要求“父级绝对不允许出现对勾最多只能半选”那思路A就满足不了你得看下面的思路B。2.3 思路B接管节点状态半选全选自己算如果有更严格的需求比如父级永远不允许进入提交数据父级最多只能显示半选哪怕子级全部选中也不能显示全选父子勾选要完全独立勾选父级时子级不动但父级仍要根据子级勾选情况显示半选。这种情况下继续依赖 el-tree 原生复选框就做不到了必须接管节点状态的渲染与计算。具体做法是去掉 el-tree 的show-checkbox改用 scoped slot 自定义每个节点的复选框用自己维护的checkedIds集合和递归计算出来的indeterminate状态来驱动显示。我先把这两种思路的选择标准总结一下判断条件推荐思路父级只是分组容器不提交父级ID思路A原生联动 提交时过滤父级有业务意义需要独立参与提交思路A直接使用 getCheckedKeys 即可父级永远不允许显示全选状态思路B自定义节点状态父子勾选需要完全独立但要展示半选思路B自定义节点状态3. 方案A落地原生联动模式下的提交与回显如果你的需求落在思路A那么恭喜实现成本和踩坑概率都会低很多。但即便是思路A也还有三个非常容易出错的细节提交时怎么过滤父级、回显时怎么防止父级被误选、以及getCheckedKeys(true)这个参数到底能不能用。3.1 基础树组件与数据准备先给一个最基础的 el-tree 复选组件el-tree reftreeRef :datatreeData show-checkbox node-keyid :props{ label: label, children: children } default-expand-all /对应的数据一般长这样treeData: [ { id: 1, label: 系统管理, children: [ { id: 11, label: 用户管理 }, { id: 12, label: 角色管理 } ] }, { id: 2, label: 业务模块, children: [ { id: 21, label: 订单模块 }, { id: 22, label: 售后模块 } ] } ]在这个结构里id: 1和id: 2是父级节点id: 11、id: 12、id: 21、id: 22是叶子节点。下面的代码都基于这个数据来演示。3.2 提交时过滤父级ID的完整写法提交数据时最核心的代码就这一段methods: { // 收集所有父级节点的 id collectParentIds(nodes, result []) { nodes.forEach(node { if (node.children node.children.length) { result.push(node.id); this.collectParentIds(node.children, result); } }); return result; }, handleSubmit() { const checkedKeys this.$refs.treeRef.getCheckedKeys(); const parentIds this.collectParentIds(this.treeData); const submitIds checkedKeys.filter(id !parentIds.includes(id)); // 此时 submitIds 只包含真实需要提交的叶子节点 console.log(submitIds); } }这段代码的逻辑很直白先把整棵树遍历一遍把所有“有 children 的节点ID”收集到parentIds里然后从getCheckedKeys()的结果中把这些ID过滤掉。这里有一个细节值得强调过滤依据是“节点是否有 children”而不是“节点是否在最底层”。因为有些业务树里中间层级也可能存在“没有 children 的空节点”这些节点在结构上算叶子但在业务语义上可能并不需要提交。如果你遇到这类需求可以把过滤条件改成“从提交结果中只保留业务上真正需要的节点类型”本质上还是一个filter的事。还有个小坑如果你在提交时用getCheckedKeys(true)它返回的是“叶子节点”的 key。听起来好像正好满足需求但注意这个leafOnly参数只认“没有子节点”这个结构特征如果你的树里有因为数据缺失而变成叶子的中间层级节点它照样会被返回。所以如果业务规则复杂直接用父级ID集合过滤更稳不要依赖leafOnly。3.3 回显数据前必须先过滤父级ID回显是另一个重灾区。很多项目的后端保存的是叶子节点ID比如之前提交后存了[11, 12, 21]。回显时直接这样写this.$refs.treeRef.setCheckedKeys([11, 12, 21]);父级状态让 el-tree 自动联动计算效果一般是正确的节点1的子级全部选中所以它自动变全选节点2只有一个子级选中所以它保持半选。但如果后端返回的数据里混入了父级ID比如因为之前某个版本把父级也存了数组变成了[1, 11, 12, 21]这时候直接用setCheckedKeys([1, 11, 12, 21])会出大事。setCheckedKeys在处理父级ID时会把父级ID当作“用户勾选了它”然后在父子联动模式下这个操作就等价于把该父级下的所有后代节点全部勾选。也就是说只要数组里混入了一个父级ID它下面的子级全部会被联动选中哪怕你后面又传了“未选中的叶子ID”也没有用。所以回显前加一道清洗逻辑是最好的习惯const savedIds [1, 11, 12, 21]; // 假设后端返回的数据 const parentIds this.collectParentIds(this.treeData); const cleanIds savedIds.filter(id !parentIds.includes(id)); this.$refs.treeRef.setCheckedKeys(cleanIds);这个清洗动作虽然简单但能避免非常多肉眼很难发现的回显异常。我见过有同事因为这个问题排查了一下午最后发现就是历史数据里混了一个父级ID进去。3.4 关于 leafOnly 参数的使用边界再单独说说getCheckedKeys(leafOnly)这个参数因为很多文档对它描述不够清楚。leafOnly传true时只返回叶子节点。问题是el-tree 对“叶子节点”的判断是“没有 children 或 children 为空数组”。这跟业务上的“叶子”并不总是一回事。举个例子某个分类层级数据里一级分类下只有二级分类二级分类下面可能暂时没有数据。那么二级分类节点children: []结构上就是叶子节点。但业务上二级分类是允许继续挂三级分类的它是一个“可能成为父级的节点”。当后端要求提交二级分类ID时用leafOnlytrue能正确拿到但如果后端要求提交的是“当前所有层级里真正最末端的节点ID”那leafOnlytrue的结果就是你要的。所以结论是leafOnlytrue可以用于“只要最末级节点”的场景如果不能确定树的层级结构是否稳定更推荐方案A里用父级ID过滤的方式语义更明确、更不容易被数据结构的变化影响。4. 方案B落地自定义节点勾选与半选状态计算如果需求已经超出了思路A能处理的范畴比如产品经理白纸黑字写着“父级最多只能显示半选任何情况下不得出现对勾”那就得上思路B。这个方案的核心是不再依赖 el-tree 的show-checkbox完全接管节点的选中状态和半选状态计算。4.1 用 scoped slot 替换原生复选框先看模板怎么写el-tree reftreeRef :datatreeData node-keyid :props{ label: label, children: children } default-expand-all template #default{ data } el-checkbox :model-valueisChecked(data) :indeterminateisIndeterminate(data) change(val) handleCheck(data, val) {{ data.label }} /el-checkbox /template /el-tree注意我没有加show-checkbox因为一旦加了el-tree 会渲染自己的复选框slot 里的内容会被挤到旁边状态也很难统一。去掉之后节点区域完全由我自定义el-checkbox的勾选状态完全由自己控制。对应地script 部分需要一个存储选中ID的数组和一个存储节点状态映射的缓存对象data() { return { treeData: [...], checkedIds: [], // 所有被勾选的叶子节点ID stateMap: {} // key 为节点id值为 { checked, indeterminate } }; }4.2 递归计算半选与全选状态因为 el-tree 不再帮我们计算半选所以必须自己写递归逻辑。我的计算规则是这样的叶子节点checked checkedIds 包含自身idindeterminate false。父节点所有直接子级中只要全部是“选中且非半选”则父级为全选如果有一部分选中或半选但又不是全部则父级为半选。对应代码如下methods: { calcNodeState(node) { if (!node.children || node.children.length 0) { return { checked: this.checkedIds.includes(node.id), indeterminate: false }; } const childStates node.children.map(child this.calcNodeState(child)); const allChecked childStates.every(s s.checked !s.indeterminate); const anyChecked childStates.some(s s.checked || s.indeterminate); return { checked: allChecked, indeterminate: anyChecked !allChecked }; }, updateStateMap() { const walk nodes { nodes.forEach(node { this.stateMap[node.id] this.calcNodeState(node); if (node.children node.children.length) { walk(node.children); } }); }; walk(this.treeData); }, isChecked(data) { const state this.stateMap[data.id]; return state ? state.checked : false; }, isIndeterminate(data) { const state this.stateMap[data.id]; return state ? state.indeterminate : false; } }calcNodeState是方案B的心脏。它每次遍历都会递归计算所有节点的状态逻辑上最清晰代码量也最少。这里要注意一个细节对父节点来说我只关心它的后代叶子节点选择情况而不关心父级自身的ID是否在checkedIds里。这也是为什么calcNodeState里对“父节点”分支完全忽略node.id本身。这么做是为了满足“父级永远不进入选中集合”的需求父级即使因为子级全选而显示全选框它的ID也依然不会被老老实实写进checkedIds只要你别在点击事件里手动加进去。4.3 点击交互时的后代联动逻辑自己接管了复选框点击事件也要自己处理。这里有一个关键逻辑点击父节点时要么全选所有后代叶子要么全不选。methods: { collectLeafIds(node) { const ids []; const walk n { if (!n.children || n.children.length 0) { ids.push(n.id); } else { n.children.forEach(walk); } }; walk(node); return ids; }, handleCheck(node, val) { const isLeaf !node.children || node.children.length 0; if (isLeaf) { if (val) { this.checkedIds.push(node.id); } else { this.checkedIds this.checkedIds.filter(id id ! node.id); } } else { const leafIds this.collectLeafIds(node); const currentState this.stateMap[node.id]; const shouldCheck !currentState.checked; if (shouldCheck) { // 全选所有后代叶子 leafIds.forEach(id { if (!this.checkedIds.includes(id)) { this.checkedIds.push(id); } }); } else { // 取消所有后代叶子 this.checkedIds this.checkedIds.filter(id !leafIds.includes(id)); } } // 状态集变化后更新整棵树的半选/全选映射 this.stateMap {}; this.updateStateMap(); } }这段代码里shouldCheck根据当前父节点的状态决定是选中还是取消。如果父节点当前是半选点击它应该优先触发“全选动作”如果已经是全选点击则触发“全部取消”。这个交互习惯和 el-tree 原生行为保持一致用户上手没有学习成本。如果你还需要“父级自身也进入选中集合”在shouldCheck分支里加一行this.checkedIds.push(node.id)就行。但注意一旦加了父级在子级全选时会被记录在checkedIds里提交时记得另行处理。我通常会在该项目里用一个includeParent开关来控制这个行为避免不同业务树之间相互影响。4.4 回显与提交在方案B里的实现方案B的回显比方案A更简单粗暴因为不再依赖setCheckedKeys直接把后端返回的叶子ID塞进checkedIds就行methods: { setChecked(ids) { this.checkedIds ids.slice(); this.stateMap {}; this.updateStateMap(); } }父级状态会在updateStateMap()里自动算出来。哪怕后端返回的数组里有父级ID也没关系因为calcNodeState对父节点不看它自身是否在checkedIds里父级只是根据后代叶子的状态显示半选或全选。提交的逻辑同样简单methods: { handleSubmit() { console.log(this.checkedIds); } }如果业务要求提交时排除某些父级ID自己再按方案A里的父级集合思路过滤一遍。因为这时候checkedIds本来就只存了叶子ID大概率连过滤都不需要。方案B看起来比方案A多写了不少代码但它换来的是对“半选、全选、选不选中”的完全控制。指令再严格、交互再特殊都能在这个框架里满足。5. 常见问题与排查速查表不管是思路A还是思路B实际开发里都会遇到一些高频问题。我把自己踩过的坑和排查经验整理成速查表方便你对照定位。5.1 高频场景速查表现象可能原因处理方式父级永远不出现半选要么全选要么不选check-strictly被设成了true父子联动被关闭去掉check-strictly或改为方案B手动计算半选半选父级提交到了后端父级被手动setChecked或setCheckedKeys加入了选中集合确认提交时只使用getCheckedKeys()并用父级ID集合过滤一遍回显时父级被错误全选所有子级都选中了回显数据里混入了父级ID回显前过滤父级ID再调用setCheckedKeys点击父级取消勾选子级全部被取消这是 el-tree 默认的父子联动行为不算 bug如果不符合业务要求改用check-strictlytrue或方案B方案B中更新半选状态后 UI 不刷新Vue 2 对动态添加对象属性响应式检测不到用this.stateMap {}; this.updateStateMap();重新赋值或使用$set自定义复选框点击没有反应scoped slot 里没有绑定change或者被外层事件拦截检查el-checkbox的change是否绑定并避免节点点击事件覆盖树节点超过2000个时方案B卡顿每次点击都全量递归计算所有节点状态改为只计算点击节点到根节点的路径状态或加缓存5.2 控制台状态体检方法排查 el-tree 复选问题时只看页面视觉是不够的我一般会在控制台直接打印内部状态const tree this.$refs.treeRef; console.log({ checkedKeys: tree.getCheckedKeys(), halfCheckedKeys: tree.getHalfCheckedKeys(), halfCheckedNodes: tree.getHalfCheckedNodes() });通过这三行你能立刻看出半选父级和全选父级到底被组件放进了哪个集合。很多时候页面上的显示和你提交的数据不一致就是因为你在某个环节把节点手动塞进了一个错误集合。方案B里我一般会写一个临时的 debug 方法methods: { debugTree() { console.log(checkedIds:, this.checkedIds); console.log(stateMap:, this.stateMap); } }然后可以在页面里临时加个按钮调用一下看状态映射是否符合预期。排查完再删掉就好。5.3 大数据量下的性能优化方案B的calcNodeState每次都会递归整棵树节点少的时候无所谓但树一大了就会有性能问题。我实际测过大约 2000 个节点的树每次点击触发全量递归页面会有明显卡顿感。优化思路有两个方向。第一个方向是缓存计算结果。只需要在updateStateMap里先判断某个节点的子级状态是否发生变化如果整棵子树都没有受到当前点击影响就跳过这部分递归。实现上可以给每个节点加一个“脏标记”点击叶子时只往根节点方向更新路径上的状态。第二个方向是减少递归深度。把树形数据扁平化成Mapid, node结构父级状态直接通过查表计算子级状态省去深度遍历的开销。对大规模数据效果明显。不过话说回来后台管理系统里树数据超过 1000 个节点的场景其实不多。如果真到了需要做深度优化的时候我建议优先考虑把树改成懒加载的load模式而不是一次性渲染全部节点效果比任何计算优化都来得直接。最后顺带分享一个我个人的习惯无论用方案A还是方案B我都会在组件里单独封装一个getSubmitIds()方法所有提交场景都走这一个出口。这样就算以后业务规则调整、只需要改这一处逻辑而不用全局搜索getCheckedKeys的调用点。相比那些把获取选中逻辑散落在各个页面的写法这种收敛方式能让后续维护轻松很多。
阅读完成 · 觉得有帮助?
咨询建站