一、先搞懂递归组件到底是什么

很多人第一次听到“递归组件”会觉得是个很玄的概念,其实说白了就是“自己调用自己的组件”。举个生活里的例子:你给朋友拍一张照片,照片里的你手里拿着这张照片,照片里的照片里又有你拿着照片……只要有个“别再继续拍”的规则,就不会无限循环下去。递归组件就是这个道理,核心要解决两个问题:一是组件怎么自己调用自己,二是怎么停止这个循环。

二、从简单场景练手:树形菜单的递归实现

先拿大家最熟悉的树形菜单来练手,树形菜单的特点是:一级菜单下面可能有二级,二级下面可能有三级,层级数不确定。要实现它,就得用递归组件。

2.1 先明确需求和数据结构

我们要做的树形菜单,点击父菜单能展开/折叠子菜单,子菜单的层级不限。首先得定义好菜单的数据结构,比如每个菜单项都有名称、是否展开、子菜单列表这几个属性。

2.2 核心实现思路

首先要做一个基础的菜单项组件,这个组件要能渲染自己的名称,还要能判断有没有子菜单:如果有子菜单,就调用自己来渲染子菜单;如果没有子菜单,就停止。这里的关键是“子菜单的渲染用父组件自己”,也就是组件自引用。

2.3 完整代码示例(技术栈:Vue 3)

首先是父组件,用来初始化数据和渲染根级菜单:

<template>
  <!-- 渲染根级菜单,传入根菜单数据 -->
  <MenuNode :menuData="rootMenu" />
</template>

<script setup>
// 导入我们写的递归菜单项组件
import MenuNode from './MenuNode.vue'

// 定义根菜单数据,层级不限
const rootMenu = [
  {
    name: '前端开发',
    open: false, // 控制是否展开子菜单
    children: [
      {
        name: 'Vue框架',
        open: false,
        children: [
          { name: 'Vue 3', open: false, children: [] },
          { name: 'Vue 2', open: false, children: [] }
        ]
      },
      {
        name: 'React框架',
        open: false,
        children: [
          { name: 'React 18', open: false, children: [] },
          { name: 'React 17', open: false, children: [] }
        ]
      }
    ]
  },
  {
    name: '后端开发',
    open: false,
    children: [
      { name: 'Java', open: false, children: [] },
      { name: 'Python', open: false, children: [] }
    ]
  }
]
</script>

然后是核心的递归组件MenuNode.vue,这个组件自己调用自己:

<template>
  <div class="menu-node">
    <!-- 渲染当前菜单项的名称,点击切换展开状态 -->
    <div class="menu-title" @click="toggleOpen">
      <!-- 如果有子菜单,显示展开/折叠的箭头 -->
      <span v-if="menuData.children.length > 0">
        {{ menuData.open ? '▼' : '▶' }}
      </span>
      {{ menuData.name }}
    </div>

    <!-- 只有当菜单展开且有子菜单时,才渲染子菜单 -->
    <div class="menu-children" v-if="menuData.open && menuData.children.length > 0">
      <!-- 这里是关键:用自己这个组件渲染子菜单,也就是自引用 -->
      <MenuNode 
        v-for="child in menuData.children" 
        :key="child.name" 
        :menuData="child" 
      />
    </div>
  </div>
</template>

<script setup>
// 接收父组件传过来的当前菜单数据
const props = defineProps({
  menuData: {
    type: Object,
    required: true
  }
})

// 切换菜单的展开状态
const toggleOpen = () => {
  props.menuData.open = !props.menuData.open
}
</script>

<style scoped>
.menu-node {
  margin-left: 20px; /* 每一层缩进,区分层级 */
}
.menu-title {
  cursor: pointer;
  padding: 4px 0;
}
.menu-children {
  margin-top: 4px;
}
</style>

2.4 这里的关键细节

  1. 组件自引用MenuNode.vue里又引入了自己,这就是递归的核心,相当于子菜单用和父菜单一样的逻辑渲染。
  2. 终止条件:只有当menuData.open为真且menuData.children.length > 0时,才会继续渲染子菜单。如果没有子菜单,或者菜单没展开,就停止递归,不会无限循环。

三、进阶场景:组织架构图的递归实现

树形菜单的逻辑搞懂了,组织架构图其实就是换了个样式的递归组件,核心逻辑完全一样——只不过组织架构图是展示层级关系,样式上可能用连线、圆角框来区分层级,而不是简单的文字和箭头。

3.1 组织架构图的核心逻辑

组织架构图的每个节点是一个人员/部门,每个节点下面可能有子部门/子人员,层级同样不确定。所以还是用递归组件:每个节点组件渲染自己的信息,然后调用自己渲染子节点。

3.2 组织架构图的代码示例(技术栈:Vue 3)

首先是父组件,定义组织架构数据:

<template>
  <!-- 渲染根节点,这里是公司CEO -->
  <OrgNode :orgData="ceo" />
</template>

<script setup>
import OrgNode from './OrgNode.vue'

// 组织架构数据,层级不限
const ceo = {
  name: '张三',
  position: 'CEO',
  children: [
    {
      name: '李四',
      position: '技术总监',
      children: [
        {
          name: '王五',
          position: '前端经理',
          children: [
            { name: '赵六', position: '前端开发', children: [] },
            { name: '钱七', position: '前端开发', children: [] }
          ]
        },
        {
          name: '孙八',
          position: '后端经理',
          children: [
            { name: '李九', position: '后端开发', children: [] }
          ]
        }
      ]
    },
    {
      name: '周十',
      position: '产品总监',
      children: [
        { name: '吴十一', position: '产品经理', children: [] }
      ]
    }
  ]
}
</script>

然后是递归组件OrgNode.vue

<template>
  <div class="org-node">
    <!-- 渲染当前节点的信息 -->
    <div class="org-info">
      <p class="name">{{ orgData.name }}</p>
      <p class="position">{{ orgData.position }}</p>
    </div>

    <!-- 只有有子节点时,才渲染子节点区域 -->
    <div class="org-children" v-if="orgData.children.length > 0">
      <!-- 这里是连线,用来连接父节点和子节点 -->
      <div class="line"></div>
      <!-- 子节点区域,横向排列多个子节点 -->
      <div class="children-container">
        <!-- 用自己渲染子节点,自引用 -->
        <OrgNode 
          v-for="child in orgData.children" 
          :key="child.name" 
          :orgData="child" 
        />
      </div>
    </div>
  </div>
</template>

<script setup>
const props = defineProps({
  orgData: {
    type: Object,
    required: true
  }
})
</script>

<style scoped>
.org-node {
  text-align: center;
  margin: 20px 0;
}
.org-info {
  display: inline-block;
  border: 1px solid #ccc;
  border-radius: 8px;
  padding: 10px 20px;
}
.name {
  margin: 0;
  font-weight: bold;
}
.position {
  margin: 5px 0 0 0;
  color: #666;
}
.org-children {
  position: relative;
}
/* 父节点到子节点区域的竖线 */
.line {
  position: absolute;
  top: -20px;
  left: 50%;
  transform: translateX(-50%);
  width: 1px;
  height: 20px;
  background: #ccc;
}
.children-container {
  display: flex;
  justify-content: center;
  gap: 40px; /* 子节点之间的间距 */
  margin-top: 20px;
}
</style>

3.3 这里的关键变化

组织架构图的递归逻辑和树形菜单完全一致,只是样式做了调整:用连线来体现层级关系,子节点横向排列。这也说明递归组件的核心是逻辑复用,样式可以灵活调整。

四、递归组件的核心细节:自引用和终止条件

递归组件的两个核心问题,一定要搞清楚,不然很容易出问题。

4.1 组件自引用的两种方式

组件自引用就是“在组件内部引入自己”,不同框架的实现方式略有不同:

  • Vue 3:可以直接在组件内部引入自己,因为Vue的组件名默认就是文件名,所以只要文件名和组件名一致,就能直接引入。
  • React:React的组件是函数,所以自引用的方式是在函数内部调用自己,比如:
    const OrgNode = ({ orgData }) => {
      return (
        <div>
          <div>{orgData.name}</div>
          {orgData.children.length > 0 && orgData.children.map(child => (
            <OrgNode key={child.name} orgData={child} />
          ))}
        </div>
      )
    }
    

4.2 终止条件的必要性

终止条件是递归组件的“刹车”,如果没有终止条件,组件会无限渲染下去,直到浏览器内存溢出。终止条件的核心是“当没有可继续递归的内容时,停止渲染子组件”,常见的终止条件有:

  • 没有子数据(比如children.length === 0
  • 父组件的状态限制(比如菜单没展开,就不渲染子菜单)
  • 层级限制(比如最多渲染3层,超过3层就停止)

举个反例:如果树形菜单的终止条件写错了,比如把v-if="menuData.open && menuData.children.length > 0"写成v-if="menuData.children.length > 0",那么只要有子菜单,就会一直渲染,哪怕菜单没展开,这样不仅会浪费性能,还可能出现逻辑错误。

五、递归组件的应用场景、优缺点和注意事项

5.1 应用场景

除了树形菜单和组织架构图,递归组件还适合所有“层级不确定的树状结构”场景,比如:

  • 文件目录树(比如电脑的文件夹结构)
  • 评论区(评论下面有子评论,子评论下面还有子评论)
  • 多级分类(比如电商的商品分类,一级分类下面有二级,二级下面有三级)
  • 权限菜单(不同角色的菜单层级不同)

5.2 技术优缺点

优点

  1. 代码简洁:不用为不同层级写不同的组件,一套逻辑搞定所有层级。
  2. 扩展性强:不管层级增加多少,只要数据结构不变,组件不用改。
  3. 逻辑复用:所有层级的渲染逻辑完全一致,维护起来很方便。

缺点

  1. 性能问题:如果层级很深,或者每个层级的子节点很多,递归渲染会占用较多的内存和CPU,可能导致页面卡顿。
  2. 调试困难:递归组件的调用链是层层嵌套的,出问题的时候很难定位到具体是哪一层的逻辑错了。
  3. 容易出现死循环:如果终止条件没写对,会导致无限渲染,直接让页面崩溃。

5.3 注意事项

  1. 一定要写对终止条件:这是递归组件的核心,写完一定要测试,比如把层级拉到最深,看会不会停止。
  2. 控制层级深度:如果层级特别深(比如超过10层),可以考虑加一个最大层级限制,超过的层级用“展开更多”的方式加载,避免一次性渲染太多内容。
  3. 优化性能:如果子节点很多,可以用虚拟滚动(比如Vue的vue-virtual-scroller)来优化,只渲染可见区域的节点,减少性能消耗。
  4. 加唯一标识:渲染子节点的时候一定要加唯一的key,比如上面示例里的key="child.name",避免框架在更新的时候出错。

六、文章总结

递归组件本质上是“用一套逻辑处理不确定层级的树状结构”,核心是组件自引用和正确的终止条件。从树形菜单到组织架构图,核心逻辑完全一致,只是样式和业务场景不同。它适合所有层级不确定的树状结构场景,能大幅简化代码,但也有性能和调试的问题,需要注意优化。