不试着自己封装一个element-UI吗?vue3+ts+tsx+vite封装一个树ui组件(element-ui,ant等相同效果)

树形结构相信大家在日常生活中都见过,它的特点是一层一层嵌套,比如文件系统。

以下是vite的源码目录结构(部分):

/vite
├── docs
├── packages
|  └── vite
|     ├── CHANGELOG.md
|     ├── LICENSE.md
|     ├── README.md
|     ├── api-extractor.json
|     ├── bin
|     |  ├── openChrome.applescript
|     |  └── vite.js
|     ├── client.d.ts
|     ├── package.json
|     ├── rollup.config.js
|     ├── scripts
|     |  └── patchTypes.ts
|     ├── src
|     |  └── client
|     |  |  ├── client.ts
|     |  |  ├── env.ts
|     |  |  ├── overlay.ts
|     |  |  └── tsconfig.json
|     ├── tsconfig.base.json
|     └── types
├── scripts
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── README.md
├── jest.config.ts
├── package.json
└── pnpm-workspace.yaml

1 组件需求

要实现的基础树组件效果如下:
不试着自己封装一个element-UI吗?vue3+ts+tsx+vite封装一个树ui组件(element-ui,ant等相同效果)_第1张图片

主要包含以下功能:

  1. 渲染嵌套树形结构
  2. 节点连接线
  3. 节点展开 / 收起
  4. 节点勾选
  5. 点击选择
  6. 自定义图标
  7. 默认状态
  8. 节点禁用
  9. 增删改操作
  10. 虚拟滚动(1s内渲染10万树节点)

2 树形结构的表示

由于Tree组件比较复杂,为了实现它的功能,首先要做的就是设计好它的数据结构。

interface ITreeNode {
  label: string;
  id?: string;
  children?: ITreeNode[];

  selected?: boolean; // 点击选中
  checked?: boolean; // 勾选
  expanded?: boolean; // 展开

  disableSelect?: boolean;
  disableCheck?: boolean;
  disableToggle?: boolean;
}

比如vite的源码目录结构,用ITreeNode结构表示就是:

[
  {
    label: 'docs',
    children: [...]
  },
  {
    label: 'packages',
    expanded: true,
    children: [
      ...,
      {
        label: 'vite',
        expanded: true,
        children: [
          ...,
          {
            label: 'README.md'
          },
        ]
      },
    ]
  },
  {
    label: 'scripts',
    children: [...]
  },
  {
    label: 'pnpm-workspace.yaml',
  },
  ...
]

这是一个嵌套结构,需要通过递归的方式来操作,很不方便,而且也很难使用虚拟滚动做性能优化。

所以需要设计一个扁平的内部数据结构,不妨就叫IInnerTreeNode

interface IInnerTreeNode extends ITreeNode {
  parentId?: string; // 父节点ID
  level: number;     // 节点层级
  isLeaf?: boolean;  // 是否叶子结点
}

内部数据结构在ITreeNode的基础上增加了以下字段:

  • parentId:由于这是一个扁平的数据结构,没有嵌套,只有一层,因此为了表达父子关系,需要给节点增加一个parentId,指向父节点
  • level:为了方便地知道当前节点所在层级,并在UI上通过缩进方式体现,需要增加level层级信息
  • isLeaf:叶子结点比较特殊,它没有孩子节点,也需要标识出来

vite的源码目录结构,用扁平结构表示如下(部分):

[
  {
    label: 'docs',
    id: 'node-1',
    level: 1,
  },
  {
    label: 'vite',
    id: 'node-2-1',
    parentId: 'node-2',
    expanded: true,
    level: 2,
  },
  {
    label: 'pnpm-workspace.yaml',
    id: 'node-4',
    level: 1,
    isLeaf: true,
  },
  ...
]

我们编写一个最简单的数据测试一下,docs/tree/index.md

# 树

:::demo Tree组件基本用法,传入
  ```vue
    
    
    ```
  :::

文档中注册菜单,docs/.vitepress/config.ts

{
  text: '数据展示',
  items: [{ text: 'Tree 树', link: '/components/tree/' }]
},

注册Tree组件,scripts/entry.ts

import TreePlugin, { Tree } from '../src/tree'

export { Tree }
const installs = [ TreePlugin ]

下面先简单渲染这个数据,tree.tsx

export default defineComponent({
  name: 'STree',
  props: treeProps,
  setup(props: TreeProps) {
    // 获取data
    const { data: innerData } = toRefs(props)
    return () => {
      return 
{ // 循环输出节点 innerData.value.map(treeNode => treeNode.label) }
} } })

此处会提示我们没有data属性,我们给TreeProps添加一个data类型声明,tree-type.ts

export const treeProps = {
  data: {
    type: Object as PropType>,
    required: true
  }
} as const

看看效果,很简陋,有待优化

不试着自己封装一个element-UI吗?vue3+ts+tsx+vite封装一个树ui组件(element-ui,ant等相同效果)_第2张图片

3 数据拍平

我们获取到的Tree组件data数据是由一堆有嵌套结构的TreeNode组成,而不是InnerTreeNode,这个转换需要我们自己来实现,怎么转换呢?

可以通过递归的方式,创建一个utils.ts的文件,里面编写generateInnerTree函数。

// tree/src/utils.ts
export function generateInnerTree(tree: ITreeNode[]): IInnerTreeNode[] {
  return tree.reduce((prev, cur) => {
    if (cur.children) {
      return prev.concat(cur, generateInnerTree(cur.children));
    } else {
      return prev.concat(cur);
    }
    }, []);
}

可以使用下这个极简版本的generateInnerTree,看下效果如何?

const tree = [
  {
    label: 'docs',
    id: 'docs',
  },
  {
    label: 'packages',
    id: 'packages',
    expanded: true,
    children: [
      {
        label: 'plugin-vue',
        id: 'plugin-vue',
      },
      {
        label: 'vite',
        id: 'vite',
        expanded: true,
        children: [
          {
            label: 'src',
            id: 'src',
          },
          {
            label: 'README.md',
            id: 'README.md',
          },
        ]
      },
    ]
  },
  {
    label: 'scripts',
    id: 'scripts',
    children: [
      {
        label: 'release.ts',
        id: 'release.ts',
      },
      {
        label: 'verifyCommit.ts',
        id: 'verifyCommit.ts',
      },
    ]
  },
  {
    label: 'pnpm-workspace.yaml',
    id: 'pnpm-workspace.yaml',
  },
];

转换出来的数据如下:

[
  {label: "docs", id: "docs"},
  {label: "packages", id: "packages", expanded: true, children: [...]},
  {label: "plugin-vue", id: "plugin-vue"},
  {label: "vite", id: "vite", expanded: true, children: [...]},
  {label: "src", id: "src"},
  {label: "README.md", id: "README.md"},
  {label: "scripts", id: "scripts", children: [...]},
  {label: "release.ts", id: "release.ts"},
  {label: "verifyCommit.ts", id: "verifyCommit.ts"},
  {label: "pnpm-workspace.yaml", id: "pnpm-workspace.yaml"}
]

和我们预期的格式非常接近,不过仔细对比发现:

  • 多了children属性
  • 少了parentId / level / isLeaf 字段

levelparentId怎么加呢?level可以通过每进入一次generateInnerTree函数自增的方式获取,parentId可以通过记录走过的节点路径path来获取。

export function generateInnerTree(
  tree: ITreeNode[],
  level = 0,
  path = [] as IInnerTreeNode[]
): IInnerTreeNode[] {
  level++
  return tree.reduce((prev: IInnerTreeNode[], cur) => {
    const o = Object.assign({}, cur) as IInnerTreeNode
    // 增加 level 属性
    o.level = level

    if (path.length > 0 && path[path.length - 1].level >= level) {
      while (path[path.length - 1]?.level >= level) {
        // 子 -> 父时,应该将栈顶元素弹出去
        path.pop()
      }
    }
    // 记录 父->子 路径 path
    path.push(o)

    const parentNode = path[path.length - 2]
    if (parentNode) {
      // 增加 parentId
      o.parentId = parentNode.id
    }

    if (o.children) {
      // 移除 children 属性
      return prev.concat(o, generateInnerTree(o.children, level, path))
    } else {
      // 增加 isLeaf 属性
      o.isLeaf = true
      return prev.concat(o)
    }
  }, [])
}

再来看看generateInnerTree的执行效果:

[
  { label: 'docs', id: 'docs', level: 1, isLeaf: true },
  {
    label: 'packages',
    id: 'packages',
    expanded: true,
    children: [ [Object], [Object] ],
    level: 1
  },
  {
    label: 'plugin-vue',
    id: 'plugin-vue',
    level: 2,
    parentId: 'packages',
    isLeaf: true
  },
  {
    label: 'vite',
    id: 'vite',
    expanded: true,
    children: [ [Object], [Object] ],
    level: 2,
    parentId: 'packages'
  },
  { label: 'src', id: 'src', level: 3, parentId: 'vite', isLeaf: true },
  {
    label: 'README.md',
    id: 'README.md',
    level: 3,
    parentId: 'vite',
    isLeaf: true
  },
  {
    label: 'scripts',
    id: 'scripts',
    children: [ [Object], [Object] ],
    level: 1
  },
  {
    label: 'release.ts',
    id: 'release.ts',
    level: 2,
    parentId: 'scripts',
    isLeaf: true
  },
  {
    label: 'verifyCommit.ts',
    id: 'verifyCommit.ts',
    level: 2,
    parentId: 'scripts',
    isLeaf: true
  },
  {
    label: 'pnpm-workspace.yaml',
    id: 'pnpm-workspace.yaml',
    level: 1,
    isLeaf: true
  }
]

效果和我们预期的是一致的,现在只剩下多余的children

if (o.children) {
  // 先处理子节点
  const children = generateInnerTree(o.children, level, path)
  // 移除 children 属性
  delete o.children
  return prev.concat(o, children)
}

方案:只要传入parentNode,就需要添加parentId


import { IInnerTreeNode, ITreeNode } from './tree-type'

export function generateInnerTree(
  tree: ITreeNode[],
  level = 0, // 节点层级
  parentNode = {} as IInnerTreeNode
): IInnerTreeNode[] {
  level++
  return tree.reduce((prev, cur) => {
    // 创建一个新节点
    const o = { ...cur } as IInnerTreeNode
    // 设置层级
    o.level = level
    // 如果层级比父节点层级高则是子级,设置父级parentId
    if (level > 1 && parentNode.level && level > parentNode.level) {
      o.parentId = parentNode.id
    }
    if (o.children) {
      // 如果存在children,则递归处理这些子节点
      const children = generateInnerTree(o.children, level, o)
      // 处理完删除多余children属性
      delete o.children
      // 将新构造的节点o和已拍平数据拼接起来
      return prev.concat(o, children)
    } else {
      // 叶子节点的情况
      o.isLeaf = true
      // 将新构造的节点o和已拍平数据拼接起来
      return prev.concat(o)
    }
  }, [] as IInnerTreeNode[])
}

下面修改类型声明,

// src/tree/src/tree-type.ts
export const treeProps = {
  data: {
    type: Object as PropType>,
    required: true
  }
} as const

使用generateTreeNode,

// src/tree/src/tree.tsx
export default defineComponent({
  name: 'Tree',
  props: treeProps,
  setup(props: TreeProps) {
    const { data } = toRefs(props)
    const innerData = ref(generateInnerTree(data.value))
    return () => {
      return (
        
{innerData.value.map(treeNode => treeNode.label)}
) } } })

更新文档,tree/index.md:


看一下效果:

不试着自己封装一个element-UI吗?vue3+ts+tsx+vite封装一个树ui组件(element-ui,ant等相同效果)_第3张图片

4节点缩进、折叠功能

节点缩进

现在虽然树节点都渲染出来了,但是看着不像一棵树,我们已经有了节点的层级信息,试着给它加个缩进效果吧。

只需要给TreeNode加一个paddingLeft就行了,第一层没有缩进,从第二层开始,每往里一层缩进24px。

{ innerData.map(treeNode => (
{ treeNode.label }
)) }

看着是不是有模有样了!效果如下:

不试着自己封装一个element-UI吗?vue3+ts+tsx+vite封装一个树ui组件(element-ui,ant等相同效果)_第4张图片

增加展开 / 收起按钮

现在是默认全部节点都展开了,假如我们把scripts那个节点的expanded: true去掉,希望是以下效果:

不试着自己封装一个element-UI吗?vue3+ts+tsx+vite封装一个树ui组件(element-ui,ant等相同效果)_第5张图片

同时需要明确地知道哪个节点是展开的,哪个节点是收起的,接着在节点前面加一个展开/收起的图标按钮给用户反馈。如果是展开的,则显示一个向下的三角图标:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UwWex1QM-1665648888963)(file://C:\Users\m9996\AppData\Roaming\marktext\images\2022-10-13-15-48-16-image.png?msec=1665648879980)]

如果是收起的,则显示一个向右的三角图标,表示该节点下面有子节点,并且是收起的:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-K10ZeFmp-1665648888963)(file://C:\Users\m9996\AppData\Roaming\marktext\images\2022-10-13-15-48-39-image.png?msec=1665648879980)]

实现起来非常简单,在label前面加一个svg图标,默认是向右的三角箭头,如果expanded为true,则顺时针旋转90度,变成向下的三角箭头。注意处理下叶子节点,叶子节点前面不应该有展开/收起图标,而应该是一个占位符,让节点能够左对齐,美观一点。

{ innerData.map(treeNode => (
{ treeNode.isLeaf ? : } { treeNode.label }
)) }

效果如下:

不试着自己封装一个element-UI吗?vue3+ts+tsx+vite封装一个树ui组件(element-ui,ant等相同效果)_第6张图片

增加展开 / 收起事件处理

前面都只是UI展示,现在我们想要点击节点前面的图标能够实现展开/收起,这涉及到逻辑,我们增加一个toggleNode的方法。

const innerData  = ref(generateTreeNode(data.value))
// 增加toggleNode方法
const toggleNode = (node: IInnerTreeNode) => {}
{ innerData.map(treeNode => (
{ treeNode.isLeaf ? : toggleNode(treeNode)} > } { treeNode.label }
)) }

但是实现功能却没有那么简单,点击展开、收起之后,点击节点的子节点要么全部显示,要么全部隐藏,它们不应该在出现在显示列表中。但是我们还要保持原始数据,因此这里实际上要从innerTree中计算一个新的列表用于展示:

// 获取那些展开的节点列表
const getExpendedTree = computed(() => {
  let excludeNodes: IInnerTreeNode[] = []
  const result = []

  for (const item of innerData.value) {
    // 如果遍历的节点在排除列表中,跳过本次循环
    if (excludeNodes.map(node => node.id).includes(item.id)) {
      continue
    }
    // 当前节点收起,它的子节点应该被排除掉
    if (item.expanded !== true) {
      excludeNodes = getChildren(item)
    }
    result.push(item)
  }

  return result
})

return () => {
  return (
    
{/* innerData.value.map(treeNode => ()) } */} { getExpendedTree.value.map(treeNode => (/**/)) } ...

这里需要获取指定节点子节点,getChildren实现如下:

// 获取指定节点的子节点
const getChildren = (node: IInnerTreeNode): IInnerTreeNode[] => {
  const result = []
  // 找到传入节点在列表中的索引
  const startIndex = innerData.value.findIndex(item => item.id === node.id)
  // 找到它后面所有的子节点(level比指定节点大)
  for (
    let i = startIndex + 1;
    i < innerData.value.length && node.level < innerData.value[i].level;
    i++
  ) {
    result.push(innerData.value[i])
  }
  return result
}

最后是实现toggleNode方法,只需要在原始列表中找到它并修改expanded状态:

const toggleNode = (node: IInnerTreeNode) => {
  const cur = innerData.value.find(item => item.id === node.id)
  if (cur) cur.expanded = !cur.expanded
}

现在可以正常使用了!效果如下:

不试着自己封装一个element-UI吗?vue3+ts+tsx+vite封装一个树ui组件(element-ui,ant等相同效果)_第7张图片

到此为止,我们就实现了一颗能展开/收起的极简版本的Tree组件。

5 useTree:提取UI无关的可复用逻辑

到目前为止Tree有了基本功能,不过它的体积也在不断膨胀,这不利于维护。提取UI无关的逻辑到composables中是Composition API的精髓,Tree组件的UI无关逻辑是什么呢?

分析Tree组件UI无关的部分是什么

还是从需求开始分析:

  • 我们要实现节点的展开 / 收起,只需要改变innerData中的expanded字段
  • 我们要实现勾选和点击选择,也是一样的,改变节点的checkedselected属性即可。
  • 节点禁用也是类似的,只需改变节点disableToggle / disableSelect / disableCheck 属性即可。
  • 如果要实现增删改节点呢?往innerData加节点、删节点、修改节点的label属性等。

实现Tree组件的功能就变成了:“操作innerData这个扁平的数据结构”,这就是Tree组件中与UI无关的逻辑部分啦,我们可以叫:useTree

实现基础版useTree

创建一个use-tree.ts的文件,写入以下内容:

// composables/use-tree.ts
// composables/use-tree.ts
import { ref, computed, Ref, unref } from 'vue'
import { IInnerTreeNode, ITreeNode } from '../tree-type'
import { generateInnerTree } from '../utils'

export default function useTree(tree: ITreeNode[] | Ref) {
  const data = unref(tree)
  const innerData = ref(generateInnerTree(data))

  const toggleNode = (node: IInnerTreeNode) => {
    const cur = innerData.value.find(item => item.id === node.id)
    if (cur) cur.expanded = !cur.expanded
  }
  // 获取那些展开的节点列表
  const expendedTree = computed(() => {
    let excludeNodes: IInnerTreeNode[] = []
    const result = []

    for (const item of innerData.value) {
      // 如果遍历的节点在排除列表中,跳过本次循环
      if (excludeNodes.map(node => node.id).includes(item.id)) {
        continue
      }
      // 当前节点收起,它的子节点应该被排除掉
      if (item.expanded !== true) {
        excludeNodes = getChildren(item)
      }
      result.push(item)
    }

    return result
  })

  // 获取指定节点的子节点
  const getChildren = (node: IInnerTreeNode): IInnerTreeNode[] => {
    const result = []
    // 找到传入节点在列表中的索引
    const startIndex = innerData.value.findIndex(item => item.id === node.id)
    // 找到它后面所有的子节点(level比指定节点大)
    for (
      let i = startIndex + 1;
      i < innerData.value.length && node.level < innerData.value[i].level;
      i++
    ) {
      result.push(innerData.value[i])
    }
    return result
  }

  return {
    expendedTree,
    toggleNode
  }
}

使用useTree

接下来就可以在Tree中使用useTree。

export default defineComponent({
  setup(props) {
    const { data } = toRefs(props);
    // 使用useTree
    const { toggleNode, expendedTree } = useTree(data.value)

    return () => (
      
{ expendedTree.map(treeNode =>
{ treeNode.label }
) }
) } })

接下来就是不断地完善useTree,给Tree组件增加功能啦。

6 加个hover效果吧

当鼠标移到节点上时,希望节点出现一个浅色的背景色hover:bg-slate-300,tree.tsx:

效果如下:

不试着自己封装一个element-UI吗?vue3+ts+tsx+vite封装一个树ui组件(element-ui,ant等相同效果)_第8张图片

7 加个连接线吧

一般为了让父子节点的关系更加一目了然,会给Tree增加连接线,比如VSCode的目录结构树:

不试着自己封装一个element-UI吗?vue3+ts+tsx+vite封装一个树ui组件(element-ui,ant等相同效果)_第9张图片

需要在展开/收起按钮前面增加连接线的元素,然后设置好它的样式就行。

连接线要显示有两个条件:

  • 必须不是叶子节点
  • 必须是展开状态

注意下面代码实现中我们关于连接线定位的计算公式:

  • top:和节点实际高度相同,即NODE_HEIGHT
  • left:level-1个NODE_INDENT再加上12像素偏移,即NODE_INDENT * (treeNode.level- 1) + 12px
  • height:高度是所有处于展开状态下的子节点数量乘NODE_HEIGHT,即NODE_HEIGHT * childrenExpanded.length
// 节点高度
const NODE_HEIGHT = 32

// 节点缩进大小
const NODE_INDENT = 24

export default defineComponent({
  setup(props) {
    const { toggleNode, expendedTree, getChildrenExpanded } = useTree(data.value)

    return () => (
      
{ expendedTree.valu.map(treeNode => (
{/* 连接线 */} {!treeNode.isLeaf && treeNode.expanded && lineable.value && ( )} {/* ... */}
)) }
) } })

下面是获取指定节点展开子节点工具方法:

//src/tree/src/composables/use-tree.ts
// 获取指定节点的子节点
  const getChildren = (node: IInnerTreeNode, recursive = true) => {
    const result = []
    // 找到node 在列表中的索引
    const startIndex = innerData.value.findIndex(item => item.id === node.id)
    // 找到它后面所有子节点(level 比当前节点大)
    for (
      let i = startIndex + 1;
      i < innerData.value.length && node.level < innerData.value[i].level;
      i++
    ) {
      if (recursive) {
        result.push(innerData.value[i])
      } else if (node.level === innerData.value[i].level - 1) {
        // 直接子节点
        result.push(innerData.value[i])
      }
    }
    return result
  }

  // 计算参考线高度
  const getChildrenExpanded = (
    node: IInnerTreeNode,
    result: IInnerTreeNode[] = []
  ) => {
    // 获取当前节点的直接子节点
    const childrenNodes = getChildren(node, false)
    result.push(...childrenNodes)
    childrenNodes.forEach(item => {
      if (item.expanded) {
        getChildrenExpanded(item, result)
      }
    })
    return result
  }

效果如下:

不试着自己封装一个element-UI吗?vue3+ts+tsx+vite封装一个树ui组件(element-ui,ant等相同效果)_第10张图片

8 勾选功能

本节我们给Tree组件增加一个可勾选的功能,这为以后批量编辑节点做好准备。

不试着自己封装一个element-UI吗?vue3+ts+tsx+vite封装一个树ui组件(element-ui,ant等相同效果)_第11张图片

新增checkable属性

先给Tree组件增加一个checkable的props,用来控制是否启用勾选功能。

export const treeProps = {
  data: {
    type: Object as PropType>,
    required: true
  },
  // 新增
  checkable: {
    type: Boolean,
    default: false
  }
} as const

勾选时通过TreeNode的checked属性来控制,先在Tree中增加勾选框元素,让它根据节点的checked属性动态变化,tree.tsx:

const { data, checkable } = toRefs(props)

// ...

{/* 复选框 */} {checkable.value && ( )} {/* 节点文本 */} {treeNode.label}

测试,docs/tree/index.md:把docs节点checked属性设置成true,勾选框也能正常被勾选上。

:::demo ☑️勾选功能,传入checkable
  ```vue
  
  

:::

效果如下:

不试着自己封装一个element-UI吗?vue3+ts+tsx+vite封装一个树ui组件(element-ui,ant等相同效果)_第12张图片

增加点击事件处理

下面处理用户点击行为

{
  checkable.value &&
   {
      toggleCheckNode(treeNode)
    }}
  />
}

实现toggleCheckNode

const toggleCheckNode = (treeNode: IInnerTreeNode) => {
  // 父节点可能一开始没有设置checked
  // 这里手动设置一下
  treeNode.checked = !treeNode.checked

  // 获取所有子节点,设置它们checked跟父节点一致
  getChildren(treeNode).forEach(child => {
    child.checked = treeNode.checked
  });
}

效果如下:当勾选packages节点时,它的所有子节点都被勾选上了。

不试着自己封装一个element-UI吗?vue3+ts+tsx+vite封装一个树ui组件(element-ui,ant等相同效果)_第13张图片

子到父的联动

子到父的联动会稍微复杂一点,我们先来梳理下逻辑:

  • 首先要知道当前勾选节点的父节点是那个,这通过parentId就可以获取
  • 其次需要知道当前节点的兄弟节点有多少个被勾选上了
    • 如果没一个勾选上,那么父节点应该取消勾选
    • 如果全部勾选上了,则父节点也应该勾选上
  • 最后还需要考虑递归,父节点的父节点也应该联动起来,以此类推

继续完善toggleCheckNode方法。

const toggleCheckNode = (treeNode: IInnerTreeNode) => {
    // ...

    // 子-父联动
    // 获取父节点
    const parentNode = innerData.value.find(item => item.id === treeNode.parentId);;
    // 如果没有父节点,则没必要处理子到父的联动
    if (!parentNode) return;
    // 获取兄弟节点:只是一个特殊的getChildren,仅获取父节点直接子节点,需要改造getChildren
    const siblingNodes = getChildren(parentNode, false)
    const checkedSiblingNodes = siblingNodes.filter(item => item.checked);

    if (checkedSiblingNodes.length === siblingNodes.length) {
      // 如果所有兄弟节点都被勾选,则设置父节点的checked属性为true
      parentNode.checked = true
    } else if (checkedSiblingNodes.length === 0) {
      // 否则设置父节点的checked属性为false
      parentNode.checked = false
    }
  }

获取兄弟节点其实就是getChildren方法的特殊版本,getChildren方法会获取一个节点的所有嵌套子节点,这里只需要获取直接子节点,所以只需要改造下getChildren

const getChildren = (node: IInnerTreeNode, recursive = true): IInnerTreeNode[] => {
  // ...

  for (
    let i = startIndex + 1;
    i < innerData.value.length && node.level < innerData.value[i].level;
    i++
  ) {
    // recursive时只添加level小1的后代
    if (recursive) {
      result.push(innerData.value[i])
    } else if (
      // 只要当前节点的层级比父节点小1,就是直接子节点
      node.level === innerData.value[i].level - 1
    ) {
      result.push(innerData.value[i]);
    }
  }
  return result
}

测试下功能正常!

不试着自己封装一个element-UI吗?vue3+ts+tsx+vite封装一个树ui组件(element-ui,ant等相同效果)_第14张图片

思考题:递归联动

最后还需要考虑递归,父节点的父节点也应该联动起来,以此类推

思考题:半选

大家可以思考下半选如何实现,即当子节点勾选数量大于1个小于总子节点数量时,它的父节点其实应该半选,目前的实现是没有勾选上。

9 自定义图标

为了让我们的Tree组件更灵活,应该允许使用者自定义树节点的样式,比如在节点前后增加图标、自定义展开/收起图标等。这个功能是纯UI的,不涉及逻辑,因此不需要修改useTree,而只需要修改Tree组件即可。

不试着自己封装一个element-UI吗?vue3+ts+tsx+vite封装一个树ui组件(element-ui,ant等相同效果)_第15张图片

自定义展开/收起图标

先增加自定义展开/收起图标的插槽,只需要加一个三目运算符的判断,如果有icon插槽,就使用插槽内容,没有icon插槽就用默认的实心三角箭头。

注意这里使用了Scoped Slots,用于往插槽传入treeNode参数。

export default defineComponent({
  name: 'Tree',
  props: treeProps,
  setup(props: TreeProps, { slots }) {
    return () => {
      return (
      {
        treeNode.isLeaf ?
           :
            {/* 新增icon插槽判断 */}
            slots.icon ?
              slots.icon({nodeData: treeNode, toggleNode}) :
              ... {/* 原先的图标 */}
      }
   }
 }  
})

在Tree组件中使用下试试看,icon插槽可以接收到容器传过来的treeNode数据,判断节点是否展开,展开就顺时针旋转90度,让箭头朝下,docs/tree/index.md:

:::demo 自定义展开图标,设置icon插槽