Skip to content

11|递归组件 ​

第二章:组件模型与通信纵览 / 11。树的每个节点结构相同,组件就可以渲染自己;真正的难点是叶子终止、深度控制、稳定身份与事件逐层返回。

最小可运行文件树 ​

ts
export interface TreeNodeData {
  id: string
  label: string
  children?: TreeNodeData[]
}
vue
<script setup lang="ts">
import { computed } from 'vue'
import type { TreeNodeData } from './tree-types'

const props = withDefaults(defineProps<{
  node: TreeNodeData
  depth?: number
  maxDepth?: number
}>(), {
  depth: 0,
  maxDepth: 5
})

const emit = defineEmits<{
  select: [id: string]
}>()

const canDescend = computed(() =>
  props.depth < props.maxDepth && Boolean(props.node.children?.length)
)
</script>

<template>
  <li>
    <button type="button" @click="emit('select', props.node.id)">
      {{ props.node.label }}
    </button>

    <ul v-if="canDescend">
      <TreeNode
        v-for="child in props.node.children"
        :key="child.id"
        :node="child"
        :depth="props.depth + 1"
        :max-depth="props.maxDepth"
        @select="emit('select', $event)"
      />
    </ul>
    <small v-else-if="props.node.children?.length">已到最大深度</small>
  </li>
</template>
vue
<script setup lang="ts">
import { ref } from 'vue'
import TreeNode from './TreeNode.vue'
import type { TreeNodeData } from './tree-types'

const selectedId = ref('未选择')
const root: TreeNodeData = {
  id: 'root',
  label: '文档',
  children: [
    { id: 'guide', label: '指南' },
    {
      id: 'api',
      label: 'API',
      children: [{ id: 'component-api', label: '组件 API' }]
    }
  ]
}
</script>

<template>
  <ul>
    <TreeNode :node="root" @select="selectedId = $event" />
  </ul>
  <p>选中:{{ selectedId }}</p>
</template>

TreeNode.vue 能在自己的模板中写 <TreeNode>,因为 SFC 会从文件名推导组件名。若显式导入另一个也叫 TreeNode 的组件,导入名优先,自引用就会指错对象;给导入起别名即可避免冲突。

渲染调用怎样展开和返回 ​

对示例数据,渲染过程可以写成:

text
TreeNode(root, depth=0)
├─ TreeNode(guide, depth=1) → children 为空,停止
└─ TreeNode(api, depth=1)
   └─ TreeNode(component-api, depth=2) → children 为空,停止

每次调用都创建独立组件实例,并收到自己的 node、depth。终止来自两个条件:当前节点没有子节点,或 depth 已到 maxDepth。v-if 不只是视觉隐藏,也是阻止继续创建下一层实例的门。

深层事件为什么要逐层转发 ​

点击 component-api 时,最深实例先发 select。组件事件不冒泡,所以它的直接父节点必须监听并再次 emit;这一步在每层递归模板中重复,最终 App 才收到 id。

若忘记 @select="emit('select', $event)",根节点点击仍然有效,深层节点却没有结果。这种“第一层测试通过”最容易掩盖递归通信断点。

key 表示节点身份,不是数组位置 ​

使用 child.id 能在插入、删除和排序后保留正确实例身份。用数组下标作 key 时,节点位置变化可能让局部展开状态或输入草稿附着到错误数据。合同应要求同一兄弟列表中的 id 唯一且稳定。

三种终止失败 ​

数据形成环 ​

ts
const node: TreeNodeData = { id: 'a', label: 'A', children: [] }
node.children!.push(node)

这不是树,而是带环图。若没有 maxDepth,组件会不断创建自身直到栈或渲染资源耗尽。UI 层可以保留深度上限,但数据入口仍应拒绝环或先转成真正的树。

深度虽有限但过大 ​

几千层的合法链式树也会造成严重渲染成本和不可用的界面。深度上限、折叠加载或虚拟化是产品边界,不要只依赖“最终有叶子”。

空数组判断写错 ​

空数组是真值,v-if="node.children" 仍会渲染空 <ul>。使用 node.children?.length 才表达“至少有一个子节点”。

递归组件的状态放在哪里 ​

单节点展开状态可以存在每个 TreeNode 实例中;若需要“一次只展开一条路径”、跨节点搜索或统一持久化,状态更适合由父级按 id 管理。无论放哪,数据节点 id 都是连接状态与视图的稳定键。

练习:最多三层的评论树 ​

实现 CommentNode.vue 与父级:

  • 数据类型为 { id: string; author: string; text: string; replies?: Comment[] },输入约定为无环树,同级 id 唯一。
  • 文件通过名称 CommentNode 自引用;空 replies 不渲染空列表。
  • depth 从 0 开始,maxDepth 固定为 2;更深处显示“更多回复未展开”。
  • 点击任意已渲染评论都发出 select(id),每层必须显式转发,父级最终显示所选 id。
  • 至少验证叶子、三层路径、超过深度的数据和深层点击四种情况。

参考答案:查看第 2 章第 11 题参考答案。

通关自检:你能画出一个三层节点的组件实例树,并标出递归停止点、每层 key 与事件返回路径。

用心记录每一步成长