Skip to content

树组件递归与勾选联动重构

这次整理的对象来自 11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/iview-test 里的树组件 demo。和前几轮一样,我们不直接修改这个独立子项目,而是把里面最值得长期复用的核心思路整理成一份文档化重构方案。

这组代码虽然不大,但它已经覆盖了树组件最核心的几个问题:递归渲染、展开收起、勾选联动、父子节点状态同步,以及树根向外部抛出统一事件。只要把这几个点吃透,后面做目录树、权限树、组织架构树都会顺很多。

这次主要参考了下面这些文件:

  • 11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/iview-test/src/components/tree/tree.vue
  • 11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/iview-test/src/components/tree/node.vue
  • 11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/iview-test/src/components/checkbox/checkbox.vue
  • 11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/iview-test/src/utils/assist.js
  • 11Vue学习/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 直接在节点对象上读写 expandcheckedchildren,这在练习项目里没问题,但如果想让树组件长期可用,最好先把节点协议固定下来。

ts
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.vuecloneData 思路是对的,因为树组件通常不应该直接改传入的外部数据。更进一步的做法,是把整棵树的可变副本、展开、勾选和事件抛出都收口到一个 composable 里。

ts
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 应该只负责本节点的展示和把点击事件交给树状态层。

vue
<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 里。

ts
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 明确提供上下文给所有递归节点。

ts
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>

在树根组件中提供:

vue
<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() 没有声明参数,但内部却直接使用 eventNamedata,这说明“对外事件协议”还没有真正收拢稳定。

更合理的方式是让树根组件显式在状态变化后抛出标准事件对象,例如:

  • toggleExpand(node, tree)
  • checkChange(node, tree)
  • 以后还可以继续加 selectChange(node, tree)

一旦这层协议稳定,外部消费方就不需要关心树内部是怎么递归同步的,只需要消费稳定事件。

页面消费层应该尽量薄

原始 views/tree.vue 的方向其实是对的:页面层只传 datashow-checkbox 和回调函数。这个消费方式应该被保留。

重构后,页面层依然应该像这样使用树组件:

vue
<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 比按组件名向上查找更稳
  • 树的对外事件协议应该明确,而不是临时拼接

以后再整理类似的树形组件时,可以优先检查这几个问题:

  • 勾选状态是否还只支持二元态,缺少半选态
  • 父子联动是否仍然依赖组件内递归副作用
  • 节点组件是否知道了太多整棵树的实现细节
  • 树的外部事件是否足够稳定和可复用

只要这些问题还没有理顺,这个树组件通常就还没有真正达到可复用的基础组件级别。

共 20 个模块,1301 篇 Markdown 文档。