
用 Vue 写后台管理系统的朋友对 el-tree 一定不陌生。组织架构、权限分配、商品分类、文件目录几乎每个项目里都会有一棵或多棵树。el-tree 是 Element UI 提供的树形组件最初看起来很简单丢一个 data 数组进去树就出来了。但真正要用好它涉及的脑细胞比想象中多得多。这篇文章我想从最基础的标签写法开始讲一直讲到编辑回显、懒加载、自定义节点再到性能优化和筛选把我实际项目里踩过的坑和沉淀下来的处理方式全部梳理一遍。适合刚接触 el-tree 的初学者也适合被回显或懒加载问题折磨过、想找一套稳定做法的朋友。1. 先搞清楚 el-tree 的 data后面才不会乱1.1 树形数据不是平面数组很多新人第一次用 el-tree 时拿到的接口返回是个扁平列表比如[{ id: 1, parentId: 0, label: 总部 }, { id: 2, parentId: 1, label: 研发部 }]。他们直接把数组丢给:data结果树只有一层或者什么都不显示。原因是 el-tree 只认树形嵌套结构组件自己不会根据 parentId 去拼树。所以第一步永远是先把数据转成树。最笨但稳定的办法是这样function buildTree(list, parentId 0) { const tree []; const map {}; list.forEach((item) { map[item.id] { ...item, children: [] }; }); list.forEach((item) { const node map[item.id]; if (item.parentId parentId || (item.parentId null parentId 0)) { tree.push(node); } else if (map[item.parentId]) { map[item.parentId].children.push(node); } }); return tree; }这个函数循环两次第一次建map第二次根据 parentId 挂到对应父节点的 children 上。不递归也不会因为层级深而爆栈。父级 id 如果传的是 null入口参数就传null判断条件也改成item.parentId null。接口返回的字段名千奇百怪先统一在业务层转成{ id, label, children }再交给 el-tree后面维护起来会舒服很多。1.2 标签上的 props 可以把接口字段映射进来如果接口本身就是嵌套结构但字段不叫 label 和 children比如叫 name 和 childrenList不用硬改数据el-tree 提供了 props 映射el-tree :datatreeData :propsdefaultProps /data() { return { treeData: [], defaultProps: { label: name, children: childrenList } }; }props 就是告诉 el-tree显示什么字段当名称哪个字段装子节点。这个映射很关键它也决定了后面node.label、node.data的取值逻辑。如果数据里某个节点的 children 是空数组el-tree 会把它当成叶子节点处理不会再出现展开箭头。另一个容易忽略的是disabled字段。如果节点对象里有disabled: true在带复选框的树上这个节点就不可勾选。权限树里的只读节点经常这么用不需要额外写模板。1.3 node-key 务必设置node-key 是 el-tree 里容易被忽略但极其重要的属性。它相当于给每个树节点一个唯一的身份标识默认情况不设置的话组件内部只能靠对象引用去判断节点。后面所有涉及按照 key 找节点的操作比如setCheckedKeys、setCurrentKey、getNode都以 node-key 为基础。实际项目里最常见的写法el-tree :datatreeData :propsdefaultProps node-keyid /有几个细节要提醒id 必须唯一否则会出现勾选一个节点结果另一个节点也跟着变的问题。id 可以是字符串也可以是数字但不要一会儿字符串一会儿数字同一个值有两种类型也容易出奇奇怪怪的问题。如果接口给的是复合主键没有单字段 id可以在转树的时候拼一个key: parentId - code之类的新字段出来。node-key 不设置后面讲到的回显、拖拽、过滤很多功能都会变得不可控。所以我的习惯是只要页面里 el-tree 需要交互第一行就写上node-keyid。2. 把常用属性和事件串起来树不只是展示2.1 show-checkbox 与 check 事件一个后台系统里树最常见的用途是权限选择。加上show-checkbox之后每个节点前面会出现复选框默认父子联动也就是勾选父节点会自动勾选所有子节点取消父节点会一并取消子节点。el-tree reftreeRef :datatreeData :propsdefaultProps node-keyid show-checkbox checkhandleCheck /check 事件的回调参数有两个第一个是当前点击的节点数据第二个是勾选信息对象。对象里常见的有checkedKeys、checkedNodes、halfCheckedKeys、halfCheckedNodes。也就是说每次勾选组件都会告诉你当前哪些节点被勾选了哪些节点处于半选状态。这里有一个非常容易踩的坑默认父子联动时如果只勾选了某个父节点下的部分子节点父节点是半选状态它不会出现在getCheckedKeys()的返回值里。很多同学想在提交时拿到所有被选中的节点 id直接调this.$refs.treeRef.getCheckedKeys();发现半选父节点丢了导致后端收到的数据缺了几个父级。解决办法不是把所有半选节点也塞进去而是先和后端约定清楚权限提交到底要叶子节点还是父节点也要。我做过几个权限项目最终形成的惯例是后端根据叶子节点自动推导父节点权限前端只提交叶子即可。这样既不用处理半选父级逻辑也最清晰。如果后端坚持要完整路径那你再手动拼接const checkedKeys this.$refs.treeRef.getCheckedKeys(); const halfCheckedKeys this.$refs.treeRef.getHalfCheckedKeys(); const submitKeys [...checkedKeys, ...halfCheckedKeys];但是要小心拼接之后可能包含一些并非真实权限点的父级后端表结构里未必有对应记录。所以提交之前最好和接口文档逐一核对。2.2 展开、折叠与默认展开el-tree 有两个容易混淆的属性default-expand-all和default-expanded-keys。default-expand-all表示所有层级默认展开适合节点很少的场景。default-expanded-keys接收一个数组表示初始展开哪些节点。项目里我几乎不用default-expand-all尤其是数据量上千的时候一展开就是几千个 DOM 节点页面会卡到怀疑人生。更可控的方式是el-tree :datatreeData :propsdefaultProps node-keyid :default-expanded-keysdefaultExpandedKeys /data() { return { defaultExpandedKeys: [1, 3, 7] }; }这个数组的值对应 node-key 的字段值。如果树数据是异步加载的要等treeData赋值之后再一次设置defaultExpandedKeys否则组件渲染时找不到对应节点展开不生效。交互上还有个属性叫expand-on-click-node默认是 true也就是点击节点文字就会触发展开或折叠。很多后台表格场景里用户点节点是想选中而不是想展开这时候设为 false只有点箭头才展开操作逻辑会顺很多。2.3 高亮当前节点树形结构通常需要让用户知道当前选中的是哪一条。配合 highlight-current 和 current-node-key 可以实现el-tree :datatreeData :propsdefaultProps node-keyid highlight-current current-node-key2 /current-node-key 是初始高亮值动态切换的时候不要只改这个值建议通过 tree 实例方法this.$refs.treeRef.setCurrentKey(3);这个方法会更新高亮位置同时触发node-click吗官方设计里 setCurrentKey 不会主动触发 node-click所以如果你依赖点击事件做联动需要在设置完 current key 后自己调用对应的业务方法。另外 highlight-current 会让当前节点背景色变高亮但默认样式比较淡很多项目会覆盖成更明显的颜色。这个样式问题后面会专门讲。2.4 disabled 状态与只读场景el-tree 没有整棵树的 disabled 属性但可以在节点数据里加disabled: true。如果树同时开启了 show-checkbox这个节点前面的复选框就会变成灰色不能勾选。如果树没有 show-checkboxdisabled 一般不影响点击只是作为一个标记使用。我在某个项目里遇到过只读权限树的需求树要完整展示但用户不能勾选任何节点。当时没有逐节点加 disabled而是直接在 el-tree 外面包了一层容器再用 CSS 把 pointer-events 禁用掉。这样整个树不可交互视觉上也保持一致比每个节点手动改 disabled 省事得多。3. 编辑回显是 el-tree 最容易翻车的环节3.1 setCheckedKeys 要等节点渲染完编辑页面最常见的场景是弹窗里有一棵权限树打开弹窗后需要回显当前角色已勾选权限。很多人第一版写的是this.treeData res.data; this.$refs.treeRef.setCheckedKeys(checkedIds); // 没反应这不是 setCheckedKeys 写错了而是 el-tree 的 DOM 还没更新完。数据赋值之后组件要重新渲染树节点马上调用 setCheckedKeys 的时候节点还没渲染出来自然找不到对应的 key。通常会先处理成this.treeData res.data; this.$nextTick(() { this.$refs.treeRef.setCheckedKeys(checkedIds); });这个方案在绝大多数情况下都能解决。但如果树在弹窗里弹窗本身是用 v-if 控制的最好再加一个条件确认this.$refs.treeRef存在再执行。我的习惯是把这个逻辑封装成一个函数initTreeChecked() { this.$nextTick(() { if (this.$refs.treeRef) { this.$refs.treeRef.setCheckedKeys(this.checkedIds); } }); }如果设置了 checkedIds 之后再打开弹窗可以放在弹窗的 open 回调里执行。这样不会因为 DOM 生命周期问题导致回显失效。3.2 父子联动把回显搞复杂了父子联动默认开启后回显有一个大坑后端返回的权限 ids 往往同时包含父节点和子节点。如果你把这些 ids 一股脑全部 setCheckedKeys 进去父节点会因为被勾选而联动勾选所有子节点结果就是整棵子树全被选上界面状态和权限数据完全对不上。最稳妥的做法是回显前先和后端确认接口返回的 checkedKeys 到底是什么。如果返回的是叶子节点集合直接 setCheckedKeys 即可父节点会由组件自动计算半选或全选。如果返回的是全路径父节点 子节点回显时需要过滤掉父节点或者只 set 叶子节点。更省事的方案是让后端在接口里把返回数据统一成叶子节点。这个坑让我想起一次线上问题编辑角色权限时明明只勾了三个子权限一打开全变成全选。排查到最后发现是后端把父节点也返回了前端照单全收父子联动把所有子孙都选上。从那时起我在权限项目里就强制要求回显字段只返回叶子权限点关联校验交给后端。3.3 用 check-strictly 强行切断联动如果你无法控制后端返回格式或者业务上确实希望父子节点独立勾选el-tree 提供了check-strictly属性。el-tree :datatreeData :propsdefaultProps node-keyid show-checkbox check-strictly /加上这个属性后父节点和子节点之间不再联动。勾父节点不会自动勾子节点取消子节点也不影响父节点状态。这样回显时可以把后端返回的所有 ids 原样 setCheckedKeys不会出现意外全选的问题。但代价也很明显用户操作体验变了勾选父级不会把整个部门下面的人都带上。实际项目里如果业务允许按组织批量勾选用 check-strictly 后需要自己实现批量勾选逻辑。实现思路是在 check 事件里拿到当前节点手动找到它的所有子节点并设置勾选状态。复杂度会上去所以选用之前先评估业务场景。3.4 清空选中和重置表单弹窗关闭后再次打开树的勾选状态经常残留。因为 el-tree 组件实例可能被复用checkedKeys 还保留着上一次的记忆。重置表单时的正确操作this.$refs.treeRef.setCheckedKeys([]); this.$refs.treeRef.setCurrentKey(null);如果树数据本身也要重新加载我更推荐直接把 treeData 替换成新数组然后再执行上面的清空逻辑。只清空 checkedKeys 但保留旧数据可能出现数据还是上一份、勾选却清空了的中间状态。我在做分组管理时踩过这个坑切换分组后新分组的树数据已经赋值但勾选状态没清结果展示出来的是新分组 旧勾选。最后加了统一的 resetTree 方法把数据重置和勾选状态清除放在一起调用问题才稳定解决。4. 懒加载树大数据的正解与回显难点4.1 lazy 和 load 的基础关系当树的数据量很大一次性返回几千个节点页面渲染会明显变慢。这时可以用懒加载只有展开某个节点时才向后端请求它的子节点。el-tree reftreeRef :propstreeProps :loadloadNode lazy show-checkbox /data() { return { treeProps: { label: name, children: children, isLeaf: leaf } }; }, methods: { loadNode(node, resolve) { if (node.level 0) { fetchRootList().then((res) { resolve(res.data); }); } else { fetchChildren(node.data.id).then((res) { resolve(res.data); }); } } }核心规则是根节点的 level 是 0所以第一层请求要在node.level 0时发起其他层级的节点通过node.data.id去请求子级。懒加载数据每个节点都需要标记是否为叶子节点。官方推荐在 props 里通过 isLeaf 字段告诉组件这个节点没有子节点。如果接口返回的数据里没有 leaf 字段可以在加载完成后根据返回数组长度处理但更建议让后端在数据里加上这个标记。否则组件会认为所有节点都有展开箭头点一下发现没数据、再点又要重新请求交互会很奇怪。4.2 懒加载树的回显策略懒加载树最恼人的问题就是回显。前面说的setCheckedKeys要求节点已经渲染出来懒加载模式下所有子节点都没有加载直接 setCheckedKeys 根本找不到 key。一个务实建议是如果数据总量能控制在几百条完全没必要用懒加载直接用全量树回显轻松交互也顺畅。只有在数据量真的很大、必须按需加载时才考虑懒加载。如果真的要用懒加载回显我实践过的思路是这样的后端额外提供一个接口返回需要展开到目标节点的路径 key例如expandKeys: [1, 10, 103]。前端拿到这个路径后逐级触发展开先展开第一层节点等子节点 load 完成后再展开下一层。路径全部展开完毕最后调用setCheckedKeys把勾选状态装上去。这个过程需要在 loadNode 里同步保存节点引用或者在每个节点 load 完成后返回一个 Promise。没有统一的万能代码因为后端接口结构不同但思路一定是先展开路径再回显勾选。如果图省事也可以在懒加载树上不做回显改成展开到哪一层就临时加载哪一层用户自行展开查看权限状态。这不是最优解但在接口资源有限时勉强能用。4.3 懒加载请求失败的兜底懒加载接口一旦报错最常见的结果是节点一直转圈。因为 loadNode 没有调用 resolve组件不知道加载结束了。我一般会在接口 catch 里也调用一次 resolve并传入空数组loadNode(node, resolve) { if (node.level 0) { fetchRootList() .then((res) resolve(res.data)) .catch(() resolve([])); } else { fetchChildren(node.data.id) .then((res) resolve(res.data)) .catch(() resolve([])); } }这样节点会变成叶子不再转圈。用户点击后就算没有数据也不会白屏只是没内容而已。错误提示可以另行处理但组件本身要保证不卡死。4.4 别指望 default-expand-all 在懒加载下生效懒加载模式下default-expand-all是无效的。因为所有子节点都还没有加载组件不知道有哪些层可以展开。想在懒加载树里默认展开到某一层只能靠自己在 load 完成后调用节点的展开方法或者用前面说的路径展开方案。我刚开始用懒加载时也试过 default-expand-all结果打开页面只有根节点以为是 bug。后来才意识到懒加载的本质数据没请求树自然不知道下一层在哪里。5. 自定义节点让树服务于真实业务5.1 用插槽接管节点内容默认的 el-tree 节点只有一个 label 文本但业务里常常需要在节点上放按钮、图标、标签、状态点之类的东西。这时可以用作用域插槽el-tree :datatreeData :propsdefaultProps node-keyid template #default{ node, data } div classcustom-node click.stop span{{ node.label }}/span el-button v-ifnode.level 1 sizemini typetext click.stopaddChild(data) 新增 /el-button /div /template /el-tree插槽参数里的node是当前节点的内部对象包含label、level、expanded、checked、data这些常用属性。data则是你在 treeData 里放的那条原始数据。推荐显示节点名称用node.label而不是data.label因为 props 可能做过字段映射node.label 永远拿到的是最终显示值。按钮上的click.stop很重要。如果不阻止冒泡点击按钮会连带触发 el-tree 的节点点击逻辑比如把按钮当作选中当前节点来处理。5.2 右键菜单的挂载方式后台树里右键菜单也常见比如新增下级、修改、删除。el-tree 提供了node-contextmenu事件el-tree :datatreeData :propsdefaultProps node-keyid node-contextmenuhandleContextMenu /handleContextMenu(event, data, node) { this.menu.visible true; this.menu.left event.clientX; this.menu.top event.clientY; this.menu.currentData data; this.menu.currentNode node; }拿到坐标后用一个 fixed 定位的菜单组件渲染在对应位置。需要注意的是事件本身的默认右键行为要在树外层容器加contextmenu.prevent避免浏览器自带的右键菜单弹出来。使用插槽后如果自定义节点内部也有右键需求记得在鼠标事件里把contextmenu.stop写上否则右键事件会重复触发菜单位置也会乱套。5.3 样式覆写要针对组件内部类自定义节点常见的问题是写完插槽之后节点高度很矮、文字不对齐、展开箭头位置漂移。这是因为 el-tree 节点内容有自己的一层结构默认样式是组件内部写死的。直接在页面上写.el-tree-node__content {}如果组件是 scoped 样式还会发现不生效。Vue 2 项目里一般这样覆盖::v-deep .el-tree-node__content { height: 36px; line-height: 36px; } ::v-deep .custom-node { flex: 1; display: flex; align-items: center; justify-content: space-between; padding-right: 8px; }注意不要用!important满天飞。el-tree 的类名结构比较稳定覆盖 content、expand-icon、node 背景色这几个关键位置就够了。如果自定义插槽里放了按钮建议用 min-width 固定按钮尺寸否则展开箭头宽度会影响对齐。6. 性能、筛选和几个容易被忽略的方法6.1 数据量大时不要追求全部展开el-tree 本身没有虚拟滚动。组件把每个展开的节点都渲染成真实 DOM一旦几千个节点同时展开页面会有明显的卡顿。有些项目直接把default-expand-all打开遇到两千条数据就卡成幻灯片。我的经验是节点数在 500 以内全量展开还能接受超过 1000一定要用懒加载或者限制默认展开层级。比如只展开前两层用户需要时再手动往下展开。这个方案配合展开节点的高亮状态体验上并不差。如果业务上必须一屏展示所有节点且数据量很大el-tree 就不是最优解了。可以考虑换用带虚拟滚动的树表格组件或者和产品沟通减少默认展开层级。把一个组件用到不合适的地方再怎么优化都是事倍功半。6.2 filter 过滤时想保留父级路径el-tree 提供了filter-node-method属性配合filter方法做节点筛选。基础用法是el-input v-modelkeyword inputhandleFilter / el-tree reftreeRef :datatreeData :propsdefaultProps node-keyid :filter-node-methodfilterNode /handleFilter(value) { this.$refs.treeRef.filter(value); }, filterNode(value, data) { if (!value) return true; return data.label.includes(value); }这样写有个问题如果子节点命中了关键字但父节点没命中父节点也会被过滤掉导致整条路径消失。想保留父级链过滤函数要往子节点递归filterNode(value, data, node) { if (!value) return true; if (data.label.includes(value)) return true; const children node.childNodes || []; return children.some((child) this.filterNode(value, child.data, child)); }过滤输入框一般要加防抖尤其是树节点多的时候每次输入都触发全树过滤会非常消耗性能。用一个简单的 setTimeout 或者 lodash 的 debounce 都可以。6.3 方法名和参数别记混el-tree 实例方法很多我在项目里最常用的几个做一个清单方法作用注意点setCheckedKeys(keys)按照 key 数组设置勾选状态依赖 node-key要在节点渲染完成后调用getCheckedKeys(leafOnly)获取勾选节点的 key 数组leafOnly 为 true 时只返回叶子节点getHalfCheckedKeys()获取半选节点的 key 数组父子联动时父节点可能是半选setChecked(data, checked, deep)设置某个节点勾选状态data 传节点对象或 keysetCurrentKey(key)设置当前高亮节点不会触发 node-click 事件getNode(key)根据 key 获取节点实例节点不存在时返回 null面板里这几个方法都经常配合表单使用。我建议写之前先看一遍官方文档因为leafOnly这种布尔参数传错位置很难排查。6.4 我沉淀下来的几条土经验最后聊几条实际项目里积累下来的习惯。不算什么高深理论但每次都能帮我少走弯路。第一凡是表单里有树回显先和后端约定提交叶子节点即可。这个约定能省掉无数个半选、全选、父子联动的争论。第二树的 ref 命名不要和 data 数组名重名。比如数据叫 treeDataref 就叫 treeRef否则在 this.$refs 和 data 之间来回切换时容易写混。第三动态切换树数据之前先手动清空勾选和高亮再赋新值。不要依赖组件自动重置多一行代码能省一个 bug。第四接口慢的时候给树包一层 v-loading。el-tree 自己不会展示加载态用户看着空白树会以为页面坏了。第五如果树在多个页面都用抽成一个业务组件。把回显、提交、清空、懒加载全部封装进去每个页面只传数据和业务回调。这个改造前期有一点成本但权限类功能一旦复用收益非常明显。el-tree 说白了就是一个很成熟的树组件难的不是标签怎么写而是它跟异步数据、表单状态、用户交互揉在一起时怎么把边界理清楚。希望这篇内容能帮你把该踩的坑提前避开。