11|递归组件
第二章:组件模型与通信纵览 / 11。树的每个节点结构相同,组件就可以渲染自己;真正的难点是叶子终止、深度控制、稳定身份与事件逐层返回。
最小可运行文件树
export interface TreeNodeData {
id: string
label: string
children?: TreeNodeData[]
}<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><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 的组件,导入名优先,自引用就会指错对象;给导入起别名即可避免冲突。
渲染调用怎样展开和返回
对示例数据,渲染过程可以写成:
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 唯一且稳定。
三种终止失败
数据形成环
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 与事件返回路径。