☰
el-tree懒加载树初始化自动展开下级节点的完整实现方案
2026/10/6 4:42:19 网站建设 项目流程

前阵子在改一个后台权限管理页面,树用的 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 ref="treeRef" lazy :load="loadNode" :props="treeProps" node-key="id" />

el-tree 内部会创建一个 TreeStore,TreeStore 构造时会 new 一个 level 为 0 的虚拟根节点,然后立刻调用一次 loadNode 去加载这个虚拟根节点的子节点。也就是说,页面一打开,load 方法就已经执行了一次,而且这一次执行里你拿到的 node.level === 0,node.data 是空的。

loadNode(node, resolve) { console.log(node.level) // 第一次执行时是 0 console.log(node.data) // 第一次执行时是 undefined }

所以你看到的"根节点",其实是第一次 load 返回的数据渲染出来的第一层节点。这个机制带来的一个重要结论是:懒加载模式下,初始化时能看到根节点这件事本身不需要你做任何操作,是 el-tree 自动完成的。真正的问题出在下一层——这些根节点默认是折叠状态,它们的子节点不会自动加载。

1.2 为什么点开小箭头才加载下级

树节点内部有几个关键状态:loaded、loading、expanded、childNodes。一个节点如果从来没有被加载过,loaded 是 false,childNodes 是空数组。当你点开箭头展开它时,树的内部逻辑才会走 load 链路,把接口返回的数据 resolve 给这个节点,子节点才算真正挂到这个节点下面。

这个"展开"动作,在用户交互层面是点击箭头,在代码层面其实就是调用节点实例的 expand() 方法。expand() 的执行并不是无脑把 expanded 置为 true,它会先判断当前节点的 loaded 状态:

  • loaded 为 true,说明子节点已经拿过了,直接展开 DOM;
  • loaded 为 false,说明还没请求过子节点,先去执行 load 方法拿到数据,拿到之后再展开。

这里有一个非常容易踩的坑:你调用了 expand(),但数据是异步回来的,展开这个动作要等接口返回之后才真正完成。很多人在这里写同步逻辑,就会遇到"明明调了 expand,树的 UI 却没有展开"的诡异现象。

1.3 "初始化显示下级节点"的本质是什么

把上面两个机制串起来,你就明白了:所谓"初始化就显示根节点和下级节点",本质是在第一次 load(也就是虚拟根节点那一层)返回之后,对第一层节点逐个调用 expand(),让它们各自触发自己的 load 去加载下一级。

如果需要再显示一级,那就得等这些第一层节点的 load 都返回之后,再继续对第二层节点调用 expand()。这是一层套一层的递归流程,不是一开始看起来那么简单的一个配置项。想清楚了这一点,实现方案就呼之欲出了。

2. 最直接的自动展开写法:利用 load 回调后执行 expand

2.1 方案 A:在 loadNode 里根据 level 判断并展开子节点

最容易想到的方案,就是直接在 load 函数里动刀。因为第一次 load 执行时 node.level === 0,resolve 返回根节点数据之后,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 方案 B:mounted 后从 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 里展开方案 B:mounted 展开
侵入性高,与 load 逻辑耦合低,不影响 load
代码量少,写起来快少,写起来快
固定展开一层合适合适
展开多层需要load内继续判断,代码越来越乱需要递归,但逻辑独立
复用性差,换页面要重新写中等,可抽方法

我的建议是,如果只是临时做个页面,方案 A 完全够用;如果这个树组件会在多个页面复用,或者后续大概率要调整展开层级,别偷懒,直接上递归方案,也就是下面要说的方法。

3. 递归展开任意层级:从异步"加载未完成"的坑到一次成型

3.1 为什么展开后马上递归子节点会失败

我在写递归展开时犯过最典型的错误,就是同步思路写异步代码。第一版我是这么写的:

// 错误示例 root.childNodes.forEach((firstLevelNode) => { firstLevelNode.expand() // 这时候第一层节点才刚触发 load,childNodes 还是空的 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 :load="loadNode" :default-expanded-keys="[1, 2]" node-key="id" />

这段代码里,如果 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 ref="treeRef" lazy :load="loadNode" :props="treeProps" node-key="id" @node-click="handleNodeClick" /> </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-tree,API 基本一致,node.expand()、node.loaded、store.root.childNodes 这些结构在 Plus 里仍然存在,方法可以直接平移。

5.3 使用经验:什么时候主动放弃自动展开

写了几个月的懒加载树,我个人体会是:自动展开是个"看起来简单、做起来要谨慎"的功能,不是所有场景都应该无脑展开。

如果你面对的树第一层就有几十上百个节点,又要展开两层以上,我在 4.3 里说的请求压力绝对不是危言耸听。还有一个隐蔽问题:如果接口返回的数据量很大,自动展开出来的节点会让第一次渲染的 DOM 特别多,页面交互会有明显卡顿。这种场景下,我会直接跟产品沟通,能不能默认只展开根节点,或者干脆显示一个"展开全部"按钮让用户自己点,把选择和代价交给用户,而不是替用户做决定。

如果确实必须展开,我一般会遵循两条原则:一是 maxDepth 限制在 2 到 3,不贪多;二是能定向展开路径就不整层展开。写到最后我最大的感受是:懒加载树的自动展开,本质上是一场和异步加载的配合战,搞懂了 node.loaded、childNodes、expand() 这三者之间的先后顺序,就不会再被"为什么展开没生效"这种问题卡住。你照着这篇的递归写法试一遍,如果还有问题,优先去看 loadNode 里 resolve 之后你对节点做了什么,以及当前组件用的 element-ui 版本是否符合预期,基本都能定位出来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询