树组件递归与勾选联动重构
这次整理的对象来自 11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/iview-test 里的树组件 demo。和前几轮一样,我们不直接修改这个独立子项目,而是把里面最值得长期复用的核心思路整理成一份文档化重构方案。
这组代码虽然不大,但它已经覆盖了树组件最核心的几个问题:递归渲染、展开收起、勾选联动、父子节点状态同步,以及树根向外部抛出统一事件。只要把这几个点吃透,后面做目录树、权限树、组织架构树都会顺很多。
这次主要参考了下面这些文件:
11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/iview-test/src/components/tree/tree.vue11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/iview-test/src/components/tree/node.vue11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/iview-test/src/components/checkbox/checkbox.vue11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/iview-test/src/utils/assist.js11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/iview-test/src/views/tree.vue
原实现已经抓住了树组件的本质
tree.vue 负责接收外部 data,做一次深拷贝,然后把节点逐个交给 tree-node.vue 递归渲染。tree-node.vue 再负责每个节点的展开、勾选和对子节点的递归输出。
这套写法之所以值得沉淀,是因为它已经具备树组件最重要的三个基础特征:
- 节点结构天然递归
- 节点状态会向下传播
- 局部节点变化最终要回到整棵树的统一状态
换句话说,这个 demo 虽然小,但它已经不是“普通列表组件”,而是一个具备层级状态传播特性的基础组件。
当前最大的问题是树状态仍然分散在节点内部
原始实现里,TreeNode 通过 findComponentUpward(this, 'Tree') 找到根节点,再调用 tree.emitEvent() 向外发事件。勾选状态则主要通过两种方式同步:
- 向下:
updateTreeDown(data, checked)递归修改所有子节点 - 向上:监听
data.children,用checkedAll = !data.some(item => !item.checked)回推父节点选中状态
这个思路是成立的,但存在几个典型问题:
- 节点状态修改逻辑分散在每个递归节点实例里
- 父节点同步只处理了“全选 / 非全选”,没有处理中间态
- 根节点与子节点通信仍然依赖组件名查找
- 树的对外事件和整棵树状态之间还没有稳定协议
如果按 Vue 3 的最佳实践重构,更适合把整棵树的状态操作抽到 composable,让递归节点只做视图和事件入口。
推荐的重构边界
更适合长期沉淀的结构可以拆成下面几层:
components/tree/BaseTree.vue:树容器,负责接收数据、提供上下文、对外抛事件components/tree/BaseTreeNode.vue:递归节点组件,只负责展示和局部交互components/tree/TreeCheckbox.vue:树场景下的勾选控件composables/useTreeState.ts:树节点状态、展开收起、勾选联动composables/useTreeSelection.ts:选中、半选和父子传播逻辑types/tree.ts:节点类型、树上下文和事件类型
这样拆完以后,节点组件不再自己保存整套联动算法,而是只调用注入进来的树状态能力。
先把树节点模型稳定下来
原始 demo 直接在节点对象上读写 expand、checked 和 children,这在练习项目里没问题,但如果想让树组件长期可用,最好先把节点协议固定下来。
export interface TreeNodeItem {
id: string
title: string
expand?: boolean
checked?: boolean
indeterminate?: boolean
children?: TreeNodeItem[]
}
export interface TreeToggleEvent {
node: TreeNodeItem
tree: TreeNodeItem[]
}
export interface TreeCheckEvent {
node: TreeNodeItem
tree: TreeNodeItem[]
}这里最重要的一点,是把 indeterminate 明确加入模型。原始 demo 只处理了全选或不选,但真正可用的树组件通常都需要半选态。
树根状态应该统一收口到 useTreeState
原始 tree.vue 的 cloneData 思路是对的,因为树组件通常不应该直接改传入的外部数据。更进一步的做法,是把整棵树的可变副本、展开、勾选和事件抛出都收口到一个 composable 里。
import { shallowRef } from 'vue'
import type { TreeNodeItem } from '../types/tree'
import { cloneTree, toggleNodeExpand, toggleNodeCheck } from './useTreeSelection'
export function useTreeState(source: TreeNodeItem[]) {
const treeData = shallowRef<TreeNodeItem[]>(cloneTree(source))
function rebuild(data: TreeNodeItem[]) {
treeData.value = cloneTree(data)
}
function toggleExpand(nodeId: string) {
treeData.value = toggleNodeExpand(treeData.value, nodeId)
}
function toggleCheck(nodeId: string, checked: boolean) {
treeData.value = toggleNodeCheck(treeData.value, nodeId, checked)
}
return {
treeData,
rebuild,
toggleExpand,
toggleCheck
}
}这一步的价值非常大。节点组件以后不再直接深度修改整棵树,而是通过树状态中心发起更新。
递归节点应该缩回展示和事件职责
原始 node.vue 里同时做了三件事:
- 决定节点 UI 怎么渲染
- 控制展开收起
- 负责勾选联动算法
其中最后一项最适合继续下沉。更理想的 BaseTreeNode 应该只负责本节点的展示和把点击事件交给树状态层。
<script setup lang="ts">
import { inject } from 'vue'
import type { TreeNodeItem } from '../../types/tree'
import { treeContextKey } from '../../composables/treeContext'
const props = defineProps<{
node: TreeNodeItem
showCheckbox?: boolean
}>()
const tree = inject(treeContextKey)
</script>
<template>
<ul class="tree-ul">
<li class="tree-li">
<button
v-if="props.node.children?.length"
type="button"
class="tree-expand"
@click="tree?.toggleExpand(props.node.id)"
>
{{ props.node.expand ? '-' : '+' }}
</button>
<input
v-if="showCheckbox"
type="checkbox"
:checked="props.node.checked"
@change="tree?.toggleCheck(props.node.id, ($event.target as HTMLInputElement).checked)"
>
<span>{{ props.node.title }}</span>
<BaseTreeNode
v-for="child in props.node.children || []"
v-if="props.node.expand"
:key="child.id"
:node="child"
:show-checkbox="showCheckbox"
/>
</li>
</ul>
</template>这样以后不管勾选算法怎么升级,递归节点本身都不需要频繁改动。
父子勾选传播应该独立成一套纯函数
原始 updateTreeDown() 的方向是对的:父节点切换时,子树全部同步更新。但真正成熟的树组件还要处理“子节点变化后如何回推父节点”的问题,而且需要支持半选态。
这类逻辑最适合写成纯函数,而不是散落在组件 watch 里。
import type { TreeNodeItem } from '../types/tree'
export function cloneTree(nodes: TreeNodeItem[]): TreeNodeItem[] {
return nodes.map(node => ({
...node,
children: node.children ? cloneTree(node.children) : []
}))
}
function updateChildren(nodes: TreeNodeItem[], checked: boolean): TreeNodeItem[] {
return nodes.map(node => ({
...node,
checked,
indeterminate: false,
children: node.children ? updateChildren(node.children, checked) : []
}))
}
function syncParentState(node: TreeNodeItem): TreeNodeItem {
const children = node.children ?? []
if (!children.length) {
return node
}
const allChecked = children.every(child => child.checked)
const someChecked = children.some(child => child.checked || child.indeterminate)
return {
...node,
checked: allChecked,
indeterminate: !allChecked && someChecked,
children
}
}
export function toggleNodeExpand(nodes: TreeNodeItem[], nodeId: string): TreeNodeItem[] {
return nodes.map(node => {
if (node.id === nodeId) {
return {
...node,
expand: !node.expand,
children: node.children ? toggleNodeExpand(node.children, nodeId) : []
}
}
return {
...node,
children: node.children ? toggleNodeExpand(node.children, nodeId) : []
}
})
}
export function toggleNodeCheck(nodes: TreeNodeItem[], nodeId: string, checked: boolean): TreeNodeItem[] {
return nodes.map(node => {
const children = node.children ? toggleNodeCheck(node.children, nodeId, checked) : []
if (node.id === nodeId) {
return syncParentState({
...node,
checked,
indeterminate: false,
children: updateChildren(children, checked)
})
}
return syncParentState({
...node,
children
})
})
}这个版本把最复杂的部分抽离成纯函数后,组件层就可以只做调用,不再依赖深层 watch 推导状态。
provide/inject 比向上查找组件名更稳
原始 TreeNode 通过 findComponentUpward(this, 'Tree') 找到树根,这在 Vue 2 里很常见,但长期维护成本偏高,因为它依赖组件名和层级关系。
更适合现在的写法,是由 BaseTree 明确提供上下文给所有递归节点。
import type { InjectionKey } from 'vue'
import type { TreeNodeItem } from '../types/tree'
export interface TreeContext {
toggleExpand: (nodeId: string) => void
toggleCheck: (nodeId: string, checked: boolean) => void
getTree: () => TreeNodeItem[]
}
export const treeContextKey = Symbol('tree-context') as InjectionKey<TreeContext>在树根组件中提供:
<script setup lang="ts">
import { provide, watch } from 'vue'
import BaseTreeNode from './BaseTreeNode.vue'
import { treeContextKey } from '../../composables/treeContext'
import { useTreeState } from '../../composables/useTreeState'
import type { TreeNodeItem } from '../../types/tree'
const props = defineProps<{
data: TreeNodeItem[]
showCheckbox?: boolean
}>()
const emit = defineEmits<{
toggleExpand: [node: TreeNodeItem, tree: TreeNodeItem[]]
checkChange: [node: TreeNodeItem, tree: TreeNodeItem[]]
}>()
const tree = useTreeState(props.data)
watch(
() => props.data,
value => tree.rebuild(value),
{ deep: true }
)
provide(treeContextKey, {
toggleExpand(nodeId) {
tree.toggleExpand(nodeId)
},
toggleCheck(nodeId, checked) {
tree.toggleCheck(nodeId, checked)
},
getTree() {
return tree.treeData.value
}
})
</script>
<template>
<div class="tree-root">
<BaseTreeNode
v-for="node in tree.treeData"
:key="node.id"
:node="node"
:show-checkbox="showCheckbox"
/>
</div>
</template>这样节点树与根组件的关系就从“查找父组件”变成了“显式拿上下文”,可读性和稳定性都会更好。
对外事件协议也应该更明确
原始 tree.vue 里有一个明显值得改进的点:emitEvent() 没有声明参数,但内部却直接使用 eventName 和 data,这说明“对外事件协议”还没有真正收拢稳定。
更合理的方式是让树根组件显式在状态变化后抛出标准事件对象,例如:
toggleExpand(node, tree)checkChange(node, tree)- 以后还可以继续加
selectChange(node, tree)
一旦这层协议稳定,外部消费方就不需要关心树内部是怎么递归同步的,只需要消费稳定事件。
页面消费层应该尽量薄
原始 views/tree.vue 的方向其实是对的:页面层只传 data、show-checkbox 和回调函数。这个消费方式应该被保留。
重构后,页面层依然应该像这样使用树组件:
<script setup lang="ts">
import BaseTree from '../components/tree/BaseTree.vue'
const treeData = [
{
id: '1',
title: 'parent 1',
expand: true,
children: [
{
id: '1-1',
title: 'parent 1-1',
expand: true,
children: [
{ id: '1-1-1', title: 'leaf 1-1-1' },
{ id: '1-1-2', title: 'leaf 1-1-2' }
]
}
]
}
]
function handleToggleExpand(node: unknown, tree: unknown) {
console.log(node, tree)
}
function handleCheckChange(node: unknown, tree: unknown) {
console.log(node, tree)
}
</script>
<template>
<BaseTree
:data="treeData"
show-checkbox
@toggle-expand="handleToggleExpand"
@check-change="handleCheckChange"
/>
</template>这里最重要的原则是:页面负责传数据和接事件,不负责实现树内部联动。
这次重构真正沉淀下来的模式
这个树组件 demo 最值得学习的,不是“怎么递归渲染一个 ul/li”,而是“怎么管理一个具有层级传播特性的状态系统”。
这次真正沉淀下来的经验有六个:
- 树节点协议要先稳定,尤其是
checked / expand / indeterminate - 整棵树的可变副本应该集中在树状态中心里
- 递归节点组件应该尽量缩回展示与事件职责
- 父子勾选联动最好写成纯函数,而不是散落在 watch 中
provide/inject比按组件名向上查找更稳- 树的对外事件协议应该明确,而不是临时拼接
以后再整理类似的树形组件时,可以优先检查这几个问题:
- 勾选状态是否还只支持二元态,缺少半选态
- 父子联动是否仍然依赖组件内递归副作用
- 节点组件是否知道了太多整棵树的实现细节
- 树的外部事件是否足够稳定和可复用
只要这些问题还没有理顺,这个树组件通常就还没有真正达到可复用的基础组件级别。
