ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Vue递归组件从入门到实战:树形结构渲染核心方法与避坑指南

Vue递归组件从入门到实战:树形结构渲染核心方法与避坑指南

递归组件这东西,我第一次接触的时候没少吃亏。当时在做一个组织架构树,数据是后端返回的多层级结构,每个部门下面还可能挂子部门,子部门下面又有子部门。我第一反应是用v-for硬写几层循环,结果数据一变深,代码直接写不下去,后来才狠下心来把递归组件彻底搞明白。折腾一圈之后发现,这东西其实一点也不玄学,核心就两个问题:一个是怎么让组件“自己调自己”,另一个是“什么时候停下来”。只要这两点想清楚,树形菜单、评论区楼中楼、文件夹树列表,全都能一套代码通吃。

今天这篇就围绕递归组件的实现思路、手写步骤、应用场景和避坑经验展开,把我踩过的坑和最后沉淀下来的写法一并分享出来。

1. 递归组件是怎么一回事,以及什么时候该用它

1.1 先弄明白“递归”在组件里到底长什么样

递归在算法里很常见,一个函数自己调用自己,配合终止条件层层返回结果。前端组件里的递归,说白了就是组件在自己的模板里引用自身。

举个最直白的例子:一个叫TreeNode的组件,它的模板里出现了<TreeNode :data="node.children" />这种写法。当TreeNode在渲染一个节点的时候,如果发现这个节点还有子节点,就再创建一个TreeNode去渲染这些子节点;新创建的TreeNode如果又发现自己的节点还有子节点,就继续往下套。这就是递归组件。

听起来是不是有点像套娃?打开一层还有一个,而且每一层长得一模一样。这种写法的最大优势在于:你根本不需要提前知道树有几层,数据传进来多深,组件就能渲染多深。

它和普通的循环渲染最大的区别就在这里。用v-for做多层级展示时,你通常得提前规划好最多支持几层:一层套一层写死结构,一旦数据超过了预期深度,页面就显示不全。而递归组件没有这个上限,它把“节点的渲染方式”和“子节点的渲染方式”定义成一模一样,然后用数据本身的结构去驱动层级的延伸。

1.2 什么场景下应该优先考虑递归组件

我用下来的感受是,只要一个页面里的数据是“树形结构”,递归组件就大概率是最合适的方案。典型场景包括:

  • 组织架构树:公司、部门、小组之间的层级归属。
  • 权限菜单:后台管理系统里的多级导航菜单。
  • 评论楼中楼:帖子下面的一级评论、二级回复、三级回复。
  • 文件目录:文件夹内部嵌套子文件夹。
  • 多级分类:电商后台的商品类目,比如”数码>手机>智能手机“这种。

判断标准其实很简单:**你的数据结构里有没有children这种嵌套字段?**如果有,而且层数不可控,那么递归组件就能帮你省掉一大半重复代码。反过来,如果数据是扁平的列表,层数是明确的,那用普通循环就好,没必要强行上递归。

2. 从零实现一个最基础的递归组件

2.1 关键的第一步:给组件定义name

递归组件能成立,有个硬性前提:组件必须知道自己叫什么。因为递归要求在模板里通过组件名引用自身,如果组件没有名字,这行代码就没法解析。

在Vue 2和Vue 3的选项式API里,直接在export default里面写name字段就行:

export default { name: 'TreeNode' }

然后在模板里这样用:

<template> <TreeNode :data="child" /> </template>

到了Vue 3的组合式API,写法稍有变化。使用<script setup>时,组件默认以文件名作为名字,但文件名在递归场景下不一定可靠,稳妥的做法是用defineOptions显式声明:

<script setup> defineOptions({ name: 'TreeNode' }) </script>

这一步看起来不起眼,但真的很容易漏。我见过不少第一次写递归组件的朋友,模板、数据、样式全都写好了,结果页面白屏,控制台报”Failed to resolve component: TreeNode“,然后一脸懵。排查到最后就是少了这个name。

2.2 第二步:设计递归的出口,没有出口就是死循环

递归函数没有终止条件会栈溢出,递归组件没有终止条件会直接在渲染阶段把页面卡死。这个出口通常就是数据里判断“还有没有子节点”。

假设后端返回的数据长这样:

{ "id": 1, "name": "技术部", "children": [ { "id": 2, "name": "前端组", "children": [ { "id": 3, "name": "A小组" } ] }, { "id": 4, "name": "后端组" } ] }

最基础的判断就是这样:

<template> <li class="tree-node"> <span>{{ node.name }}</span> <ul v-if="node.children && node.children.length" class="tree-children"> <TreeNode v-for="child in node.children" :key="child.id" :node="child" /> </ul> </li> </template>

每次渲染时都检查当前节点是否存在children,并且children数组里还有没有内容。只有满足这两个条件才会继续递归调用,否则就停在这一层,向上返回。

2.3 一个可以直接跑起来的最小示例

为了避免纸上谈兵,我贴一个完整的、能放到项目里直接跑通的基础文件:

<!-- TreeNode.vue --> <template> <li class="tree-node"> <span class="node-label">{{ node.name }}</span> <ul v-if="node.children && node.children.length" class="tree-children"> <TreeNode v-for="child in node.children" :key="child.id" :node="child" /> </ul> </li> </template> <script> export default { name: 'TreeNode', props: { node: { type: Object, required: true } } } </script> <style scoped> .tree-node { list-style: none; padding-left: 16px; } .node-label { display: inline-block; padding: 4px 8px; cursor: default; } .tree-children { padding-left: 16px; } </style>

父组件里只要把树的根节点传进去就行:

<template> <ul> <TreeNode :node="treeData" /> </ul> </template> <script> import TreeNode from './components/TreeNode.vue'; export default { components: { TreeNode }, data() { return { treeData: { id: 0, name: '总部', children: [ { id: 1, name: '研发中心', children: [ { id: 2, name: '前端组' }, { id: 3, name: '后端组' } ] }, { id: 4, name: '运营部' } ] } }; } }; </script>

这个例子虽然简单,但已经把递归组件的三个关键点全覆盖了:name定义、props传值、children条件判断。后面所有复杂的树形组件,都是在这个基础上加功能而已。

3. 递归组件的进阶应用与方案选型

3.1 树形菜单如何加上展开和折叠

很多后台系统的菜单是树形结构,但没有展开折叠功能其实是不完整的。因为整个树一次性全部展开,稍微深一点就会让页面变得非常长,交互体验很差。

在递归组件内部实现展开折叠,操作思路是这样的:每个节点维护一个expanded状态,点击节点时切换这个状态,只有展开状态下的节点才渲染它的子节点。

需要注意的一个坑是:不要直接修改props传进来的数据。递归组件的数据是通过props一层层传下来的,如果直接在子组件里改node.expanded,虽然有时候看起来能生效,但vue的单项数据流会被破坏,而且当父组件重新渲染或数据刷新时,这个字段很容易被覆盖。

正确的做法是在组件内部基于props做一份本地状态:

<script setup> import { ref, watch } from 'vue'; const props = defineProps({ node: { type: Object, required: true } }); const expanded = ref(false); function toggle() { expanded.value = !expanded.value; } </script>

模板里这样处理:

<template> <li> <span @click="toggle">{{ node.name }}</span> <ul v-if="expanded && node.children && node.children.length"> <TreeNode v-for="child in node.children" :key="child.id" :node="child" /> </ul> </li> </template>

这样每个节点都拥有独立的展开状态,互不干扰。如果你希望默认展开第一层,可以在初始化的时候判断一下当前是哪一层,或者用一个defaultExpandLevel的prop来控制。

3.2 数据量大的时候,递归组件还吃得消吗

递归组件实现起来方便,但它有一个天然的特性:所有节点不管在不在可视区域内,都会被一次性渲染出来。数据量小的时候感觉不到,一旦层级多、节点多,比如上千个节点的组织架构,页面就会明显卡顿。

有几个优化手段,我实际用下来觉得性价比比较高:

第一,懒渲染。节点只有在处于展开状态时才渲染子节点,收起状态下的子树完全不渲染。这种方案对展开范围小的场景特别有效,用户默认只看到第一层,点开哪层才渲染哪层。

第二,数据扁平化。把树形结构拍平成一维数组,配合分页或者虚拟滚动。这样做牺牲了递归渲染的便利性,但性能上限最高,适合那种层级很深、单层节点又极多的文件管理场景。

第三,异步加载。只有展开节点时才向后端请求它的子节点数据,前端拿到数据后再动态渲染。这种方案适合大规模动态树,但需要和接口设计配合,每次展开都有一份单独的请求。

我自己在实际项目里的经验是:能懒渲染就先懒渲染,别急着上虚拟滚动。很多卡顿问题其实不是渲染量太大,而是不需要渲染的节点也被白白渲染了。只要把折叠状态下的子树彻底裁掉,性能提升往往立竿见影。

3.3 递归组件与动态组件、异步组件的组合玩法

还有一种场景是树里的节点类型并不完全相同。比如搭建页面生成器时,一个容器节点下面可能挂文本组件、图片组件、表单组件等不同类型的子节点。这种时候,递归组件负责“层级遍历”,动态组件<component :is="...">负责“节点类型分发”,两者搭配起来非常顺手。

伪代码思路是这样的:

<template> <li> <component :is="resolveComponent(node)" :node="node" /> </li> </template>

如果某一层的节点数据是异步加载的,也可以结合defineAsyncComponent来做异步组件的按需加载。不过异步递归有一个需要注意的地方:异步组件加载期间组件还没就绪,如果此时父节点已经渲染了子组件占位符,而异步组件加载失败或返回空,整个链路可能报错。稳妥的做法是在加载完成后通过v-if控制子树的出现时机。

4. 递归组件实操中的关键细节与经验心得

4.1 数据预处理:先把后端返回的数据洗干净

树形数据的质量,直接决定递归组件的成败。我在项目中遇到最多的问题是以下两类数据形态。

一类是children字段为null而不是不存在的状况。

{ "id": 2, "name": "前端组", "children": null }

这种情况如果不做处理,node.children.length会直接抛错。用node.children?.length这种可选链写法能规避,但更稳妥的做法是在数据进入组件之前做一个统一的清洗,把所有null替换成空数组,保证后续逻辑统一。

另一类是扁平数据。比如后端直接返回了一个一维列表,每个节点用parentId来标识父子关系:

[ { "id": 1, "name": "总部", "parentId": null }, { "id": 2, "name": "研发中心", "parentId": 1 }, { "id": 3, "name": "前端组", "parentId": 2 } ]

这种数据理论上不能直接喂给递归组件用,需要先在外部把它转换成树形结构。我常用的转换函数是这样的:

function buildTree(list, parentId = null) { return list .filter(item => item.parentId === parentId) .map(item => ({ ...item, children: buildTree(list, item.id) })); }

这个函数会遍历整个列表,把每个节点下面挂上它的子节点数组。递归组件拿到的是最终生成的树,而不是原始扁平列表。

4.2 key的写法不能随便糊弄

写递归组件时,v-for循环里为每个递归子组件设置的key非常重要。因为递归组件是多层级嵌套的,如果key不稳定,Vue在更新时就会发生错位复用,表现行为就是展开一个节点的时候,其他节点的内容也跟着变。

一个好的习惯是使用数据的唯一ID作为key。比如:

<TreeNode v-for="child in node.children" :key="child.id" :node="child" />

如果数据本身没有ID,也可以用node.id + '_' + node.name这类拼接方式作为兜底,但千万不要用v-for的index作为key。树形结构经常会涉及插入、删除、排序,用index做key会让组件状态错乱,展开状态互相串台。

4.3 子组件想操作兄弟节点或者父级节点怎么办

递归组件的一大特点是:每个层级的组件只掌握自己的局部数据。但如果想要实现“删除当前节点并刷新整棵树”“新增一个子节点到某个父节点下面”这类操作,局部数据就不够用了。

这种时候有两条路可以走。

一条路是使用emit向上抛事件。子组件通过$emit通知父组件,父组件收到了再处理数据。但因为树可能有好几层,直接emit只能到最近的父级,如果中间隔了好几层,事件传递链路就会变得很绕。

另一条路是借助provide/inject。在树的根层把公共方法注入下去,所有层级的递归组件都能直接调用。

我个人更推荐后者。因为树的层级深度不可控,provide/inject可以无视中间层直达源头,代码写起来也干净。比如在根组件里维护整棵树的数据:

provide() { return { deleteNode: this.deleteNode, addNode: this.addNode }; }

子孙组件里这样接收:

inject: ['deleteNode', 'addNode']

然后就能像调用自己的方法一样处理跨层级的数据操作,同时不破坏数据的单向流动。

4.4 层级深度超过20层之后的隐性问题

递归组件在层级特别深的时候,还容易踩到CSS和布局相关的坑。padding-left一层层叠加下去,到第15层时,节点文本可能已经挤到屏幕外面去了。这种情况不是递归逻辑出错,而是样式设计没有考虑深层级场景。

处理办法通常是用max-depth之类的prop去限制最大渲染层数,超出规定深度后不继续渲染子树,而是显示一个“层级过深”的占位文本。或者干脆调整样式策略,只在视觉上缩进固定的像素数,避免无限累加。

我在一个文件管理项目里,就曾经出现用户上传了一个嵌套了300多层的文件夹,递归组件直接把浏览器标签页卡了几分钟的案例。后来加上了最大渲染层数的限制,虽然无法完整展示那么深层级的内容,但页面至少能正常打开,再配合懒加载去按需渲染,体验才算是救回来了。

5. 递归组件常见问题与排查方法

5.1 页面直接白屏,控制台报“Maximum call stack size exceeded”

出现这个报错,基本可以确定是递归没有正确终止。排查步骤按照下面这个顺序来:

先看模板里的终止条件是否写对。最常见的写法失误是v-if条件变成了v-else-if的分支逻辑,或者把node.children.length误写成children.length。这两种情况都可能导致条件永远为真。

再看数据本身是不是有环。比如一个节点的children里包含了它自己,或者孙子节点的children里又指向了爷爷节点,那递归组件就会在同一个节点上无限循环。排查方法很简单:拿到数据后console.log(JSON.stringify(treeData))看一下,如果有环,JSON.stringify会直接报错提示“circular structure”。

5.2 递归组件可以正常渲染,但控制台一直报“Failed to resolve component”

这个报错我前面提到过,十有八九是组件没有定义name,或者script setup下没有用defineOptions声明组件名。还有一个细节容易漏:递归组件的文件名和name不一致时,模板里如果硬编码了和文件名一样的标签,在某些构建环境下也能正常工作,但一旦改名或者引用方式变了,问题就暴露出来。所以规范的做法是始终保持name和标签名一致。

5.3 树数据更新后,组件没有正确刷新

这个问题通常出现在直接修改props数据、或者用index作key这两个场景。直接改props会绕过Vue的响应式追踪,可能导致视图没更新;用index作key会导致组件复用混乱,看起来就是“数据是对的,但界面是错的”。

正确做法是保持单向数据流:数据从外部传入,内部状态只用于控制展开收起这类本地逻辑。需要修改树数据的操作,统一通过事件或方法从根源更新。

5.4 我给这类问题整理了一份排查速查表

现象大概率原因处理方案
页面白屏,栈溢出报错递归出口条件不对或数据成环检查v-if条件,console打印数据确认无环
Failed to resolve component缺少name声明补上name或defineOptions
展开状态错乱key用了index换成数据的唯一id
修改数据视图不刷新直接改了props通过事件/方法从根源修改数据
层数很深,页面卡顿全部节点一次性渲染懒渲染、最大层级限制、异步加载
只渲染一层就停了外层ul包裹结构不正确确认递归标签层级与模板结构一致

写在后面的一点个人体会

递归组件这个东西,我一开始觉得它很高级,后来写多了反而觉得它就是“树形数据的指定解法”。只要数据结构是树,递归组件就永远值得优先考虑。不过它也确实有边界:数据量涨到一定规模、层级深到一定程度,光靠递归硬扛就不够了,该上懒加载、该做数据扁平化,都不能手软。

真遇到不确定的场景,我的习惯是先花两分钟把数据结构打印出来看一看,再决定是递归组件还是扁平方案。数据结构看清楚,方案往往自己就浮出来了。这几个坑踩过一遍之后,后面再写树形组件基本都是顺着一条路走:定义好name、保证有出口、处理好key、给足样式余量。做到这四点,递归组件就能用得很稳。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进