恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
el-tree懒加载树初始化自动展开下级节点的完整实现方案
首页
资讯中心
/
el-tree懒加载树初始化自动展开下级节点的完整实现方案
el-tree懒加载树初始化自动展开下级节点的完整实现方案
发布时间:2026/10/6 4:42:20
前阵子在改一个后台权限管理页面树用的 el-tree接口是纯懒加载load 方法按父节点 id 去后端拿子级数据。产品提了一个听起来很常见的需求页面初始化之后不能只显示一个光秃秃的根节点至少能看到根节点下面的第一级最好还能自动展开到第二级。我第一反应是这还不简单default-expanded-all 不就完事了等实际动手才发现懒加载模式下树的展开逻辑跟静态树完全是两码事代码怎么写都不生效最后翻了源码才想明白问题在哪。这篇文章就完整记录一下这个场景的完整解法el-tree 用懒加载方式加载数据怎样初始化就能显示根节点和下级节点。代码基于 Element UI 2.x 验证Element Plus 大部分场景可以直接平移结尾会提到差异点。1. 懒加载树的根节点和我们想的不一样level 0 是个虚拟节点1.1 初始化时 el-tree 到底做了什么想解决初始化显示下级这个问题得先搞清楚 el-tree 在懒加载模式下初始化时内部干了什么。很多人第一次写懒加载树时会以为 load 方法只会在点击节点展开时触发其实不是。组件里写了如下配置时el-tree reftreeRef lazy :loadloadNode :propstreeProps node-keyid /el-tree 内部会创建一个 TreeStoreTreeStore 构造时会 new 一个 level 为 0 的虚拟根节点然后立刻调用一次 loadNode 去加载这个虚拟根节点的子节点。也就是说页面一打开load 方法就已经执行了一次而且这一次执行里你拿到的 node.level 0node.data 是空的。loadNode(node, resolve) { console.log(node.level) // 第一次执行时是 0 console.log(node.data) // 第一次执行时是 undefined }所以你看到的根节点其实是第一次 load 返回的数据渲染出来的第一层节点。这个机制带来的一个重要结论是懒加载模式下初始化时能看到根节点这件事本身不需要你做任何操作是 el-tree 自动完成的。真正的问题出在下一层——这些根节点默认是折叠状态它们的子节点不会自动加载。1.2 为什么点开小箭头才加载下级树节点内部有几个关键状态loaded、loading、expanded、childNodes。一个节点如果从来没有被加载过loaded 是 falsechildNodes 是空数组。当你点开箭头展开它时树的内部逻辑才会走 load 链路把接口返回的数据 resolve 给这个节点子节点才算真正挂到这个节点下面。这个展开动作在用户交互层面是点击箭头在代码层面其实就是调用节点实例的 expand() 方法。expand() 的执行并不是无脑把 expanded 置为 true它会先判断当前节点的 loaded 状态loaded 为 true说明子节点已经拿过了直接展开 DOMloaded 为 false说明还没请求过子节点先去执行 load 方法拿到数据拿到之后再展开。这里有一个非常容易踩的坑你调用了 expand()但数据是异步回来的展开这个动作要等接口返回之后才真正完成。很多人在这里写同步逻辑就会遇到明明调了 expand树的 UI 却没有展开的诡异现象。1.3 初始化显示下级节点的本质是什么把上面两个机制串起来你就明白了所谓初始化就显示根节点和下级节点本质是在第一次 load也就是虚拟根节点那一层返回之后对第一层节点逐个调用 expand()让它们各自触发自己的 load 去加载下一级。如果需要再显示一级那就得等这些第一层节点的 load 都返回之后再继续对第二层节点调用 expand()。这是一层套一层的递归流程不是一开始看起来那么简单的一个配置项。想清楚了这一点实现方案就呼之欲出了。2. 最直接的自动展开写法利用 load 回调后执行 expand2.1 方案 A在 loadNode 里根据 level 判断并展开子节点最容易想到的方案就是直接在 load 函数里动刀。因为第一次 load 执行时 node.level 0resolve 返回根节点数据之后node.childNodes 里已经有第一层节点了。此时只要对 childNodes 里的每个节点再调用 expand()就能触发它们去加载自己的子节点。loadNode(node, resolve) { fetchTreeData(node.level, node.data).then((list) { resolve(list) // 根节点数据加载完成后自动展开第一层 if (node.level 0) { this.$nextTick(() { node.childNodes.forEach((child) child.expand()) }) } }) }这段代码我实际试过在大多数场景下是能跑通的。注意两点一是 resolve 之后不要立刻操作 childNodes最好包一层 nextTick等树内部把 resolve 的数据处理成节点二是这里对 child 调 expand()只会触发第一层节点的子节点加载如果产品要求再往下一层你还需要在这些 child 的 load 里继续处理下一级。这个方案的优点是直观、简单适合我只需要固定展开一层的场景缺点是和 load 逻辑耦合在一起如果 load 函数是公用的、多个业务场景共用就会被迫掺杂是否自动展开的判断后面维护起来有点难受。2.2 方案 Bmounted 后从 store.root 拿 childNodes 逐个展开另一个方案是绕开 load 函数直接操作树实例。el-tree 组件实例上有一个 store 对象store.root 就是那个虚拟根节点它的 childNodes 就是页面渲染出来的第一层节点。mounted() { this.$nextTick(() { const treeRef this.$refs.treeRef const root treeRef treeRef.store treeRef.store.root if (!root) return root.childNodes.forEach((child) child.expand()) }) }这个写法比方案 A 干净不少load 函数保持纯粹专心做数据拉取就行。自动展开的逻辑独立在 mounted 生命周期里后续想调整展开策略不需要动 load 函数。不过它同样只解决了第一层。如果要求展开到第二层你就得等这批 expand 触发的 load 全部返回之后再对第二层节点逐个 expand。这个 recursion 的手写工作量就来了。2.3 两个方案的取舍我把这两种方案做了一个简单的对比方便你按实际场景选对比维度方案 A在 load 里展开方案 Bmounted 展开侵入性高与 load 逻辑耦合低不影响 load代码量少写起来快少写起来快固定展开一层合适合适展开多层需要load内继续判断代码越来越乱需要递归但逻辑独立复用性差换页面要重新写中等可抽方法我的建议是如果只是临时做个页面方案 A 完全够用如果这个树组件会在多个页面复用或者后续大概率要调整展开层级别偷懒直接上递归方案也就是下面要说的方法。3. 递归展开任意层级从异步加载未完成的坑到一次成型3.1 为什么展开后马上递归子节点会失败我在写递归展开时犯过最典型的错误就是同步思路写异步代码。第一版我是这么写的// 错误示例 root.childNodes.forEach((firstLevelNode) { firstLevelNode.expand() // 这时候第一层节点才刚触发 loadchildNodes 还是空的 firstLevelNode.childNodes.forEach((secondLevelNode) { secondLevelNode.expand() // 这里什么都拿不到 }) })console 打出来firstLevelNode.childNodes 是空数组。原因前面说过firstLevelNode.expand() 调用后el-tree 才去执行 load接口数据没回来之前childNodes 不会凭空出现。所以下一步递归必须等当前节点的 loaded 变为 true 之后再操作不能同步往下走。这个异步等待是整个递归方案的核心难点也是最容易写出 bug 的地方。我在排查时还试过各种奇奇怪怪的写法比如直接改 node.expanded true比如手动调 store.loadNode最后都绕了弯路。最稳妥的还是下面这种轮询等待。3.2 轮询等待器不要写死 setTimeout要等待一个懒加载节点完成加载最朴素的想法是 setTimeout 固定等几百毫秒再继续。但接口快慢不是固定的写死 300ms接口慢的时候拿不到数据接口快的时候又白白多等体验很差。更靠谱的做法是轮询检查 node.loaded 的状态或者检查 node.childNodes.length 是否大于 0。我自己封装了一个简单的等待器waitChildrenLoaded(node, timeout 8000) { return new Promise((resolve, reject) { const start Date.now() const timer setInterval(() { if (node.loaded) { clearInterval(timer) resolve() } else if (Date.now() - start timeout) { clearInterval(timer) reject(new Error(node load timeout)) } }, 50) }) }这个函数做的事情很简单每隔 50ms 检查一次节点是否加载完成最多等 8 秒。node.loaded 是 el-tree 内部在 resolve 执行完之后置为 true 的用它做判断比检查 childNodes 更可靠因为某些叶子节点接口会返回空数组childNodes 虽然为空但 loaded 已经是 true这时候也应该视为加载完成可以继续。为什么 50ms 这个间隔太短会增加无谓的 setInterval 开销太长又让展开过程看起来有明显的卡顿感50ms 是一个视觉上几乎无感、CPU 开销也可忽略的值。如果你接口特别慢可以把 timeout 调大或者直接在 load 方法里把 reject 场景处理掉。3.3 递归展开函数expandNodeDeep有了等待器递归展开就顺理成章了。我最后定稿的递归函数长这样async expandNodeDeep(node, maxDepth, depth 1) { // 叶子节点或者达到深度上限停止 if (!node || node.isLeaf || depth maxDepth) return // 未加载且未在加载中触发加载 if (!node.loaded !node.loading) { node.expand() } try { await this.waitChildrenLoaded(node) } catch (e) { console.warn(节点加载失败, node.data, e) return } // 加载完成后确认展开状态 if (node.loaded !node.expanded) { node.expand() } // 继续递归子节点 const children node.childNodes || [] for (const child of children) { await this.expandNodeDeep(child, maxDepth, depth 1) } }用法很简单找到第一层节点后挨个调用async expandToDepth(maxDepth 2) { const root this.$refs.treeRef?.store?.root if (!root) return for (const child of root.childNodes) { await this.expandNodeDeep(child, maxDepth) } }这个方案有一个细节值得说明我在递归子节点时用了 for 循环 await而不是 forEach。因为 forEach 里的 async 函数不会被 await 等待会导致所有层级的递归同时涌出去失去对展开节奏的控制。for 循环顺序执行每一层的所有节点都处理完才会进入下一层。3.4 空子节点、叶子节点和接口异常的兜底递归方案里容易忽略的边界情况有三种第一种是叶子节点。el-tree 判断叶子有两种方式接口返回 isLeaf 字段或者 properties 里配置了 isLeaf没配置 isLeaf 时如果接口对叶子节点返回空数组子节点为空递归自然停止。但如果接口不返回 isLeaf 字段叶子节点仍然会触发一次 load拿到的 childNodes 为空虽然不影响展示但会多发一个请求。我建议接口能加 isLeaf 就加上省一次无效请求。第二种是接口异常。比如某个节点加载失败Promise reject 了waitChildrenLoaded 会一直等到超时。所以我在 load 方法里会做一层兜底接口异常时 resolve 一个空数组不让错误扩散loadNode(node, resolve) { fetchTreeData(node.level, node.data) .then((list) resolve(list || [])) .catch(() resolve([])) }第三种是展开深度。不要设计成无上限展开哪怕产品说全部展开也建议设成 3 到 4 层因为每展开一层就是一批新请求层级太深容易把接口打崩。4. default-expanded-keys 和 setCurrentKey 在懒加载里的真实表现4.1 default-expanded-keys 并不是完全无效而是有前提很多熟悉静态树的同学第一反应是用 default-expanded-keys 来实现初始化展开。我只能说这个属性在懒加载模式下效果非常有限但也不是完全不能用取决于你传的 key 是否在节点加载前就已经注册。el-tree 的 default-expanded-keys 是在创建节点时逐个检查的如果某个节点被创建出来时它的 key 在这个数组里就自动标记为展开。懒加载模式下未加载的节点根本不会创建所以你没法在初始化时传给一个还没有出现在树里的深层节点的 key。但它对第一层节点是有效的——如果你知道第一层节点的 id把它放进 default-expanded-keys根节点 load 完成后el-tree 会自动展开这个第一层节点从而触发它的子节点加载。el-tree lazy :loadloadNode :default-expanded-keys[1, 2] node-keyid /这段代码里如果 id 为 1 和 2 的节点在第一层它们是能自动展开的。但第二层、第三层的 node 在展开前不存在default-expanded-keys 帮不上忙。所以它适合根节点 id 固定且我明确知道的场景不适合通用递归展开。4.2 setCurrentKey 拿不到未加载节点初始化选中怎么做懒加载还碰过一个问题需求要求初始化时默认选中某个二级节点。我一开始直接在这个树组件 mounted 里写 this.$refs.treeRef.setCurrentKey(targetKey)结果控制台报找不到节点。原因还是同一个目标二级节点在初始化时根本没有被加载出来setCurrentKey 内部是通过 key 去 nodesMap 里找节点的找不到自然设置不了。要解决它必须先确保目标节点的祖先链被逐层加载出来让目标节点真实存在于树里再调 setCurrentKey。我是在递归展开器的基础上加了一个检查逻辑每展开一层就查一次目标 key 是否已经出现async expandUntilKey(targetKey, maxDepth 5) { const root this.$refs.treeRef?.store?.root if (!root) return false for (const child of root.childNodes) { await this.expandNodeDeep(child, maxDepth) const node this.$refs.treeRef.store.getNode(targetKey) if (node) { this.$refs.treeRef.setCurrentKey(targetKey) return true } } return false }这段代码的思路是先尽量展开到目标层级每次展开完一层后查一次目标节点在不在树里在的话立刻设置选中并终止递归。好处是目标路径上不需要的兄弟节点也可能被展开数量有限时可以接受如果目标 key 在很深层级你可以配合知道目标节点父链的信息只展开路径上的节点而不是整层全展开请求量会小很多。4.3 大规模自动展开的请求治理自动展开的功能做完之后容易被忽略的是接口压力。假设第一层有 30 个节点初始化展开到第二层意味着页面打开瞬间会并发发出 30 个子节点请求后端如果是个慢接口瞬间就跪了。如果你要展开的层级不算太深建议控制并发节奏。我试过两种治理方式一是分批展开。把第一层节点切成小份比如每批 5 个节点展开完一批休息 100ms 再继续async expandTreeBatched(batchSize 5, interval 100) { const root this.$refs.treeRef?.store?.root if (!root) return for (let i 0; i root.childNodes.length; i batchSize) { const batch root.childNodes.slice(i, i batchSize) await Promise.all(batch.map((node) this.expandNodeDeep(node, 2))) await new Promise((r) setTimeout(r, interval)) } }二是让后端支持批量查询。前端把多个父节点 id 传过去后端一次性返回这批父节点下所有的子节点数据前端再按父节点分组塞进 resolve。这个方案请求数最少但需要后端配合不是所有团队都能立刻改接口。还有一种情况是不需要全部展开只需要展开某条路径。比如从详情页跳回列表页要根据当前选中的部门高亮并展开它所在的分支就没必要把整棵树全展开只需从根节点逐层找到目标节点的祖先路径一路展开过去即可。这种定向展开的请求量要小得多。5. 把自动展开封装成组件方法完整代码可直接复用5.1 完整封装以方法组形式放进组件把前面几节的方案整合起来我最后在项目里用的是这样一组方法你直接复制到需要用的组件里即可methods: { // 等待子节点加载完成 waitChildrenLoaded(node, timeout 8000) { return new Promise((resolve, reject) { const start Date.now() const timer setInterval(() { if (node.loaded) { clearInterval(timer) resolve() } else if (Date.now() - start timeout) { clearInterval(timer) reject(new Error(node load timeout)) } }, 50) }) }, // 递归展开单个节点到指定深度 async expandNodeDeep(node, maxDepth, depth 1) { if (!node || node.isLeaf || depth maxDepth) return if (!node.loaded !node.loading) { node.expand() } try { await this.waitChildrenLoaded(node) } catch (e) { console.warn(节点加载失败, node.data, e) return } if (node.loaded !node.expanded) { node.expand() } const children node.childNodes || [] for (const child of children) { await this.expandNodeDeep(child, maxDepth, depth 1) } }, // 从根节点开始把整棵树展开到指定深度 async expandTreeToDepth(maxDepth 2) { await this.$nextTick() const root this.$refs.treeRef?.store?.root if (!root) return for (const child of root.childNodes) { await this.expandNodeDeep(child, maxDepth) } } }mounted 里直接调用即可mounted() { this.expandTreeToDepth(2) }maxDepth 传 2 表示显示根节点加下一级也就是页面打开后用户能看到两层节点。传 3 就是三层。这个参数建议从业务需求出发不要默认给很大值。5.2 一个完整的业务组件示例为了让你更容易对照我贴一个简化版的完整组件template el-tree reftreeRef lazy :loadloadNode :propstreeProps node-keyid node-clickhandleNodeClick / /template script export default { name: LazyExpandTree, data() { return { treeProps: { label: name, children: children, isLeaf: isLeaf } } }, mounted() { // 初始化展开到第二层 this.expandTreeToDepth(2) }, methods: { loadNode(node, resolve) { const params node.level 0 ? { parentId: null } : { parentId: node.data.id } fetchTreeData(params).then((list) { resolve(list || []) }).catch(() { resolve([]) }) }, handleNodeClick(data) { console.log(selected, data.id) }, waitChildrenLoaded(node, timeout 8000) { // 同上 }, async expandNodeDeep(node, maxDepth, depth 1) { // 同上 }, async expandTreeToDepth(maxDepth 2) { // 同上 } } } /script实际项目中 fetchTreeData 换成你自己的接口就行。如果用的是 Element Plus组件是 el-treeAPI 基本一致node.expand()、node.loaded、store.root.childNodes 这些结构在 Plus 里仍然存在方法可以直接平移。5.3 使用经验什么时候主动放弃自动展开写了几个月的懒加载树我个人体会是自动展开是个看起来简单、做起来要谨慎的功能不是所有场景都应该无脑展开。如果你面对的树第一层就有几十上百个节点又要展开两层以上我在 4.3 里说的请求压力绝对不是危言耸听。还有一个隐蔽问题如果接口返回的数据量很大自动展开出来的节点会让第一次渲染的 DOM 特别多页面交互会有明显卡顿。这种场景下我会直接跟产品沟通能不能默认只展开根节点或者干脆显示一个展开全部按钮让用户自己点把选择和代价交给用户而不是替用户做决定。如果确实必须展开我一般会遵循两条原则一是 maxDepth 限制在 2 到 3不贪多二是能定向展开路径就不整层展开。写到最后我最大的感受是懒加载树的自动展开本质上是一场和异步加载的配合战搞懂了 node.loaded、childNodes、expand() 这三者之间的先后顺序就不会再被为什么展开没生效这种问题卡住。你照着这篇的递归写法试一遍如果还有问题优先去看 loadNode 里 resolve 之后你对节点做了什么以及当前组件用的 element-ui 版本是否符合预期基本都能定位出来。