树形结构相信大家在日常生活中都见过,它的特点是一层一层嵌套,比如文件系统。
以下是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 组件需求
要实现的基础树组件效果如下:
主要包含以下功能:
- 渲染嵌套树形结构
- 节点连接线
- 节点展开 / 收起
- 节点勾选
- 点击选择
- 自定义图标
- 默认状态
- 节点禁用
- 增删改操作
- 虚拟滚动(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
<template>
<STree :data="data"></STree>
</template>
<script setup>
const data = [
{
label: 'docs',
id: 'node-1',
level: 1,
},
{
label: 'packages',
id: 'node-2',
expanded: true,
level: 1,
},
{
label: 'vite',
id: 'node-2-1',
parentId: 'node-2',
expanded: true,
level: 2,
},
{
label: 'README.md',
id: 'node-2-1-1',
parentId: 'node-2-1',
isLeaf: true,
level: 3,
},
{
label: 'scripts',
id: 'node-3',
level: 1,
},
{
label: 'pnpm-workspace.yaml',
id: 'node-4',
level: 1,
isLeaf: true,
},
]
</script>
```
:::
文档中注册菜单,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 <div class="s-tree">{
// 循环输出节点
innerData.value.map(treeNode => treeNode.label)
}</div>
}
}
})
此处会提示我们没有data
属性,我们给TreeProps
添加一个data类型声明,tree-type.ts
:
export const treeProps = {
data: {
type: Object as PropType<Array<IInnerTreeNode>>,
required: true
}
} as const
看看效果,很简陋,有待优化
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
字段
level
和parentId
怎么加呢?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<Array<ITreeNode>>,
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 (
<div class="s-tree">
{innerData.value.map(treeNode => treeNode.label)}
</div>
)
}
}
})
更新文档,tree/index.md:
<script lang="ts" setup>
import { ref } from 'vue'
const data = ref([ /* 嵌套数据 */ ]);
</script>
看一下效果:
4节点缩进、折叠功能
节点缩进
现在虽然树节点都渲染出来了,但是看着不像一棵树,我们已经有了节点的层级信息,试着给它加个缩进效果吧。
只需要给TreeNode加一个paddingLeft
就行了,第一层没有缩进,从第二层开始,每往里一层缩进24px。
<div class="s-tree">
{
innerData.map(treeNode => (
<div
class="s-tree-node"
style={{
paddingLeft: `${24 * (treeNode.level - 1)}px`
}}
>
{ treeNode.label }
</div>
))
}
</div>
看着是不是有模有样了!效果如下:
增加展开 / 收起按钮
现在是默认全部节点都展开了,假如我们把scripts那个节点的expanded: true
去掉,希望是以下效果:
同时需要明确地知道哪个节点是展开的,哪个节点是收起的,接着在节点前面加一个展开/收起的图标按钮给用户反馈。如果是展开的,则显示一个向下的三角图标:
如果是收起的,则显示一个向右的三角图标,表示该节点下面有子节点,并且是收起的:
实现起来非常简单,在label前面加一个svg图标,默认是向右的三角箭头,如果expanded
为true,则顺时针旋转90度,变成向下的三角箭头。注意处理下叶子节点,叶子节点前面不应该有展开/收起图标,而应该是一个占位符,让节点能够左对齐,美观一点。
<div class="s-tree">
{
innerData.map(treeNode => (
<div
class="s-tree-node"
style={{
paddingLeft: `${24 * (treeNode.level - 1)}px`
}}
>
{
treeNode.isLeaf
? <span style={{
display: 'inline-block',
width: '25px',
}} />
: <svg style={{
width: '25px',
height: '16px',
display: 'inline-block',
transform: treeNode.expanded ? 'rotate(90deg)': ''
}} viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"
>
<path fill="currentColor" d="M384 192v640l384-320.064z"></path>
</svg>
}
{ treeNode.label }
</div>
))
}
</div>
效果如下:
增加展开 / 收起事件处理
前面都只是UI展示,现在我们想要点击节点前面的图标能够实现展开/收起,这涉及到逻辑,我们增加一个toggleNode
的方法。
const innerData = ref(generateTreeNode(data.value))
// 增加toggleNode方法
const toggleNode = (node: IInnerTreeNode) => {}
<div class="s-tree">
{
innerData.map(treeNode => (
<div
class="s-tree-node"
style={{
paddingLeft: `${24 * (treeNode.level - 1)}px`
}}
>
{
treeNode.isLeaf
? <span style={{
display: 'inline-block',
width: '25px',
}} />
: <svg
// 增加展开/收起的点击事件
onClick={() => toggleNode(treeNode)}
>
<path fill="currentColor" d="M384 192v640l384-320.064z"></path>
</svg>
}
{ treeNode.label }
</div>
))
}
</div>
但是实现功能却没有那么简单,点击展开、收起之后,点击节点的子节点要么全部显示,要么全部隐藏,它们不应该在出现在显示列表中。但是我们还要保持原始数据,因此这里实际上要从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 (
<div class="s-tree">
{/* 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
}
现在可以正常使用了!效果如下:
到此为止,我们就实现了一颗能展开/收起的极简版本的Tree组件。
5 useTree:提取UI无关的可复用逻辑
到目前为止Tree有了基本功能,不过它的体积也在不断膨胀,这不利于维护。提取UI无关的逻辑到composables
中是Composition API的精髓,Tree组件的UI无关逻辑是什么呢?
分析Tree组件UI无关的部分是什么
还是从需求开始分析:
- 我们要实现节点的展开 / 收起,只需要改变innerData中的
expanded
字段 - 我们要实现勾选和点击选择,也是一样的,改变节点的
checked
和selected
属性即可。 - 节点禁用也是类似的,只需改变节点
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<ITreeNode[]>) {
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 () => (
<div class="s-tree">
{
expendedTree.map(treeNode => <div class="s-tree-node">{ treeNode.label }</div>)
}
</div>
)
}
})
接下来就是不断地完善useTree,给Tree组件增加功能啦。
6 加个hover效果吧
当鼠标移到节点上时,希望节点出现一个浅色的背景色hover:bg-slate-300
,tree.tsx:
<div class="s-tree-node hover:bg-slate-300">
效果如下:
7 加个连接线吧
一般为了让父子节点的关系更加一目了然,会给Tree增加连接线,比如VSCode的目录结构树:
需要在展开/收起按钮前面增加连接线的元素,然后设置好它的样式就行。
连接线要显示有两个条件:
- 必须不是叶子节点
- 必须是展开状态
注意下面代码实现中我们关于连接线定位的计算公式:
- 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 () => (
<div class="s-tree">
{
expendedTree.valu.map(treeNode => (
<div
{/* 添加样式 */}
class="s-tree-node relative leading-8"
style={{
paddingLeft: `${NODE_HEIGHT * (treeNode.level - 1)}px`
}}
>
{/* 连接线 */}
{!treeNode.isLeaf && treeNode.expanded && lineable.value && (
<span
class="s-tree-node__vline absolute w-px bg-slate-300"
style={{
height: `${
NODE_HEIGHT * getChildrenExpanded(treeNode).length
}px`,
left: `${NODE_INDENT * (treeNode.level - 1) + 12}px`,
top: `${NODE_HEIGHT}px`
}}
></span>
)}
{/* ... */}
</div>
))
}
</div>
)
}
})
下面是获取指定节点展开子节点工具方法:
//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
}
效果如下:
8 勾选功能
本节我们给Tree
组件增加一个可勾选的功能,这为以后批量编辑节点做好准备。
新增checkable属性
先给Tree组件增加一个checkable
的props,用来控制是否启用勾选功能。
export const treeProps = {
data: {
type: Object as PropType<Array<ITreeNode>>,
required: true
},
// 新增
checkable: {
type: Boolean,
default: false
}
} as const
勾选时通过TreeNode的checked
属性来控制,先在Tree中增加勾选框元素,让它根据节点的checked
属性动态变化,tree.tsx:
const { data, checkable } = toRefs(props)
// ...
<div class="s-tree-node">
{/* 复选框 */}
{checkable.value && (
<input
type="checkbox"
v-model={treeNode.checked}
class="relative top-[2px] mr-1"
/>
)}
{/* 节点文本 */}
{treeNode.label}
</div>
测试,docs/tree/index.md:把docs节点checked
属性设置成true,勾选框也能正常被勾选上。
:::demo ☑️勾选功能,传入checkable
```vue
<template>
<STree :data="data" checkable></STree>
</template>
<script setup>
import {ref} from 'vue'
const data = ref([
{
label: 'docs',
id: 'docs',
// 添加checked
checked: true
},
{
label: 'packages',
id: 'packages',
expanded: true,
// 增加checked
checked: true,
// ...
}
])
</script>
:::
效果如下:
增加点击事件处理
下面处理用户点击行为
{
checkable.value &&
<input type="checkbox"
v-model={treeNode.checked}
onClick={() => {
toggleCheckNode(treeNode)
}}
/>
}
实现toggleCheckNode
:
const toggleCheckNode = (treeNode: IInnerTreeNode) => {
// 父节点可能一开始没有设置checked
// 这里手动设置一下
treeNode.checked = !treeNode.checked
// 获取所有子节点,设置它们checked跟父节点一致
getChildren(treeNode).forEach(child => {
child.checked = treeNode.checked
});
}
效果如下:当勾选packages节点时,它的所有子节点都被勾选上了。
子到父的联动
子到父的联动会稍微复杂一点,我们先来梳理下逻辑:
- 首先要知道当前勾选节点的父节点是那个,这通过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
}
测试下功能正常!
思考题:递归联动
最后还需要考虑递归,父节点的父节点也应该联动起来,以此类推
思考题:半选
大家可以思考下半选如何实现,即当子节点勾选数量大于1个小于总子节点数量时,它的父节点其实应该半选,目前的实现是没有勾选上。
9 自定义图标
为了让我们的Tree组件更灵活,应该允许使用者自定义树节点的样式,比如在节点前后增加图标、自定义展开/收起图标等。这个功能是纯UI的,不涉及逻辑,因此不需要修改useTree,而只需要修改Tree组件即可。
自定义展开/收起图标
先增加自定义展开/收起图标的插槽,只需要加一个三目运算符的判断,如果有icon
插槽,就使用插槽内容,没有icon
插槽就用默认的实心三角箭头。
注意这里使用了Scoped Slots,用于往插槽传入treeNode参数。
export default defineComponent({
name: 'Tree',
props: treeProps,
setup(props: TreeProps, { slots }) {
return () => {
return (
{
treeNode.isLeaf ?
<span style={{ display: 'inline-block', width: '25px' }} /> :
{/* 新增icon插槽判断 */}
slots.icon ?
slots.icon({nodeData: treeNode, toggleNode}) :
<svg>...</svg> {/* 原先的图标 */}
}
}
}
})
在Tree组件中使用下试试看,icon
插槽可以接收到容器传过来的treeNode数据,判断节点是否展开,展开就顺时针旋转90度,让箭头朝下,docs/tree/index.md:
:::demo 自定义展开图标,设置icon插槽
<template>
<STree :data="data">
<template #icon="{nodeData, toggleNode}">
<span v-if="nodeData.isLeaf" class="devui-tree-node__indent"></span>
<span v-else @click="(event) => {
event.stopPropagation();
toggleNode(nodeData);
}"
>
<svg :style="{
transform: nodeData.expanded ? 'rotate(90deg)': '',
display: 'inline-block',
margin: '0 5px',
cursor: 'pointer'
}" viewBox="0 0 1024 1024" width="12" height="12"
>
<path d="M857.70558 495.009024 397.943314 27.513634c-7.132444-7.251148-18.794042-7.350408-26.048259-0.216941-7.253194 7.132444-7.350408 18.795065-0.216941 26.048259l446.952518 454.470749L365.856525 960.591855c-7.192819 7.192819-7.192819 18.85544 0 26.048259 3.596921 3.596921 8.311293 5.39487 13.024641 5.39487s9.42772-1.798972 13.024641-5.39487L857.596086 520.949836C864.747973 513.797949 864.796068 502.219239 857.70558 495.009024z"></path>
</svg>
</span>
</template>
</STree>
<template>
:::
效果如下:
自定义节点内容
有时我们想在节点前后增加一些内容,比如图标,就需要增加content
插槽。
{
slots.content
? slots.content(treeNode)
: treeNode.label
}
和icon
插槽的套路类似,不再赘述。
有了icon
和content
插槽,就可以做一个Github 代码树效果啦!
要实现的效果如下:
试着用我们的Tree组件来实现,主要有以下功能:
- 节点前面需要增加文件夹或文件的图标,如果是父节点则加文件夹图标,如果是叶子结点则加文件图标
- 叶子节点后面需要增加一个代表是否修改过的标记图标
<template #content="treeNode">
<svg v-if="treeNode.isLeaf" id="octicon_file_16" viewBox="0 0 16 16" width="16" height="16" fill="#57606a" style="display:inline-block"><path fill-rule="evenodd" d="M3.75 1.5a.25.25 0 00-.25.25v11.5c0 .138.112.25.25.25h8.5a.25.25 0 00.25-.25V6H9.75A1.75 1.75 0 018 4.25V1.5H3.75zm5.75.56v2.19c0 .138.112.25.25.25h2.19L9.5 2.06zM2 1.75C2 .784 2.784 0 3.75 0h5.086c.464 0 .909.184 1.237.513l3.414 3.414c.329.328.513.773.513 1.237v8.086A1.75 1.75 0 0112.25 15h-8.5A1.75 1.75 0 012 13.25V1.75z"></path></svg>
<svg v-else id="octicon_file-directory-fill_16" viewBox="0 0 16 16" width="16" height="16" fill="#54aeff" style="display:inline-block"><path d="M1.75 1A1.75 1.75 0 000 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0016 13.25v-8.5A1.75 1.75 0 0014.25 3H7.5a.25.25 0 01-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75z"></path></svg>
{{treeNode.label}}
<svg v-if="treeNode.isLeaf" title="modified" viewBox="0 0 16 16" width="16" height="16" fill="#9a6700" style="position: absolute; right: 0; top: 8px;">
<path fill-rule="evenodd" d="M2.75 2.5h10.5a.25.25 0 01.25.25v10.5a.25.25 0 01-.25.25H2.75a.25.25 0 01-.25-.25V2.75a.25.25 0 01.25-.25zM13.25 1H2.75A1.75 1.75 0 001 2.75v10.5c0 .966.784 1.75 1.75 1.75h10.5A1.75 1.75 0 0015 13.25V2.75A1.75 1.75 0 0013.25 1zM8 10a2 2 0 100-4 2 2 0 000 4z"></path>
</svg>
</template>
实现的效果如下:
是不是已经和Github的几乎是一样的,这说明我们开发的Tree组件是一个真正能在业务中用起来的实实在在的组件。后面要做的就是持续地完善它!
自定义节点内容
有时我们想在节点前后增加一些内容,比如图标,就需要增加content插槽。
{
slots.content
? slots.content(treeNode)
: treeNode.label
}
和icon插槽的套路类似,不再赘述。
有了icon和content插槽,就可以做一个Github PR代码检视的效果啦!
要实现的效果如下:
试着用我们的Tree组件来实现,主要有以下功能:
- 节点前面需要增加文件夹或文件的图标,如果是父节点则加文件夹图标,如果是叶子结点则加文件图标
- 叶子节点后面需要增加一个代表是否修改过的标记图标
<template #content="treeNode">
<svg v-if="treeNode.isLeaf" id="octicon_file_16" viewBox="0 0 16 16" width="16" height="16" fill="#57606a" style="display:inline-block"><path fill-rule="evenodd" d="M3.75 1.5a.25.25 0 00-.25.25v11.5c0 .138.112.25.25.25h8.5a.25.25 0 00.25-.25V6H9.75A1.75 1.75 0 018 4.25V1.5H3.75zm5.75.56v2.19c0 .138.112.25.25.25h2.19L9.5 2.06zM2 1.75C2 .784 2.784 0 3.75 0h5.086c.464 0 .909.184 1.237.513l3.414 3.414c.329.328.513.773.513 1.237v8.086A1.75 1.75 0 0112.25 15h-8.5A1.75 1.75 0 012 13.25V1.75z"></path></svg>
<svg v-else id="octicon_file-directory-fill_16" viewBox="0 0 16 16" width="16" height="16" fill="#54aeff" style="display:inline-block"><path d="M1.75 1A1.75 1.75 0 000 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0016 13.25v-8.5A1.75 1.75 0 0014.25 3H7.5a.25.25 0 01-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75z"></path></svg>
{{treeNode.label}}
<svg v-if="treeNode.isLeaf" title="modified" viewBox="0 0 16 16" width="16" height="16" fill="#9a6700" style="position: absolute; right: 0;">
<path fill-rule="evenodd" d="M2.75 2.5h10.5a.25.25 0 01.25.25v10.5a.25.25 0 01-.25.25H2.75a.25.25 0 01-.25-.25V2.75a.25.25 0 01.25-.25zM13.25 1H2.75A1.75 1.75 0 001 2.75v10.5c0 .966.784 1.75 1.75 1.75h10.5A1.75 1.75 0 0015 13.25V2.75A1.75 1.75 0 0013.25 1zM8 10a2 2 0 100-4 2 2 0 000 4z"></path>
</svg>
</template>
实现的效果如下:
是不是已经和Github的几乎是一样的,这说明我们开发的Tree组件是一个真正能在业务中用起来的实实在在的组件。后面要做的就是持续地完善它!
10 代码重构
目前UI全部都写在tree.tsx
中,导致代码可读性变差,需要进行重构。
TreeNode
我们首先可以将树节点部分抽离出来成为STreeNode
子组件。
重构的方法分成三步:
- 创建
tree-node.tsx
子组件文件,将tree.tsx
中相应的模板部分剪切到子组件中 - 补齐
tree-node
中的变量和方法 - 在
tree
中使用tree-node
子组件
创建tree-node.tsx
,components/tree-node/tree-node.tsx
import { defineComponent, inject, toRefs } from 'vue'
// 节点高度
const NODE_HEIGHT = 32
// 节点缩进大小
const NODE_INDENT = 24
export default defineComponent({
name: 'STreeNode',
setup(props, { slots }) {
const { lineable, checkable, treeNode } = toRefs(props)
const { toggleNode, getChildrenExpanded, toggleCheckNode } =
inject('TREE_UTILS')
return () => (
<div
class="relative leading-8 hover:bg-slate-300"
style={{
paddingLeft: `${NODE_INDENT * (treeNode.level - 1)}px`
}}
>
{/* 连接线 */}
{!treeNode.isLeaf && treeNode.expanded && lineable.value && (
<span
class="s-tree-node__vline absolute w-px bg-slate-300"
style={{
height: `${NODE_HEIGHT * getChildrenExpanded(treeNode).length}px`,
left: `${NODE_INDENT * (treeNode.level - 1) + 12}px`,
top: `${NODE_HEIGHT}px`
}}
></span>
)}
{/* 如果是叶子节点则放一个空白占位元素,否则放一个三角形反馈图标 */}
{treeNode.isLeaf ? (
<span
style={{
display: 'inline-block',
width: '25px'
}}
/>
) : slots.icon ? (
slots.icon({ nodeData: treeNode, toggleNode })
) : (
<svg
style={{
width: '25px',
height: '16px',
display: 'inline-block',
transform: treeNode.expanded ? 'rotate(90deg)' : ''
}}
viewBox="0 0 1024 1024"
xmlns="http://www.w3.org/2000/svg"
onClick={() => toggleNode(treeNode)}
>
<path fill="currentColor" d="M384 192v640l384-320.064z"></path>
</svg>
)}
{/* 复选框 */}
{checkable.value && (
<input
type="checkbox"
v-model={treeNode.checked}
class="relative top-[2px] mr-1"
onClick={() => {
toggleCheckNode(treeNode)
}}
/>
)}
{/* 节点文本 */}
{slots.content ? slots.content(treeNode) : treeNode.label}
</div>
)
}
})
需要解决一下问题:
- 定义TreeNodeProps
- 定义TreeUtils
创建TreeNodeProps
,components/tree-node/tree-node-type.ts
import { ExtractPropTypes, PropType } from 'vue'
import { IInnerTreeNode, treeProps } from '../tree-type'
export const treeNodeProps = {
...treeProps,
treeNode: {
type: Object as PropType<IInnerTreeNode>,
required: true
}
}
export type TreeNodeProps = ExtractPropTypes<typeof treeNodeProps>
import { treeNodeProps, TreeNodeProps } from './tree-node-type'
export default defineComponent({
props: treeNodeProps,
setup(props: TreeNodeProps, { slots }) {}
})
引入类型之后,会提示treeNode.xxx
错误,这是因为treeNode是Ref,对应修改一下。
定义TreeUtils
type TreeUtils = {
toggleNode: (treeNode: IInnerTreeNode) => void
getChildrenExpanded: (treeNode: IInnerTreeNode) => IInnerTreeNode[]
toggleCheckNode: (treeNode: IInnerTreeNode) => void
}
const { toggleNode, getChildrenExpanded, toggleCheckNode } = inject(
'TREE_UTILS'
) as TreeUtils
最后在tree.tsx中使用tree-node
import { defineComponent, provide, toRefs } from 'vue'
import useTree from './composables/use-tree'
import { IInnerTreeNode, TreeProps, treeProps } from './tree-type'
import STreeNode from './components/tree-node'
export default defineComponent({
name: 'STree',
props: treeProps,
setup(props: TreeProps, { slots }) {
// 获取data
const treeData = useTree(props.data)
provide('TREE_UTILS', treeData)
return () => {
return (
<div class="s-tree">
{
treeData.expendedTree.value.map((treeNode: IInnerTreeNode) => (
<STreeNode {...props} treeNode={treeNode}>
{{
content: slots.content,
icon: slots.icon
}}
</STreeNode>
))
}
</div>
)
}
}
})
测试可行。但是这里还有改进空间,tree.tsx
太薄,成了纯粹的转发。我们完全可以将插槽判断逻辑在此处完成,让tree-node
变成更纯粹的内容展示。我们作以下更改:
tree
中作插槽是否传递的判断tree-node
中移除判断逻辑- 把默认展开折叠图标封装为单独组件
tree中作插槽是否传递的判断
import STreeNodeToggle from './components/tree-node-toggle'
export default defineComponent({
setup(props: TreeProps, { slots }) {
return () => {
return (
<div class="s-tree">
{
treeData.expendedTree.value.map((treeNode: IInnerTreeNode) => (
<STreeNode {...props} treeNode={treeNode}>
{{
content: () =>
slots.content ? slots.content(treeNode) : treeNode.label,
icon: () =>
slots.icon ? (
slots.icon({
nodeData: treeNode,
toggleNode: treeData.toggleNode
})
) : (
<STreeNodeToggle
expanded={!!treeNode.expanded}
onClick={() => treeData.toggleNode(treeNode)}
></STreeNodeToggle>
)
}}
</STreeNode>
))
}
</div>
)
}
}
})
提取一个TreeNodeToggle组件
提取默认折叠图标为TreeNodeToggle
组件:
import { SetupContext } from 'vue'
// 函数式组件更简洁
export default (props: { expanded: boolean }, { emit }: SetupContext) => (
<svg
style={{
width: '25px',
height: '16px',
display: 'inline-block',
transform: props.expanded ? 'rotate(90deg)' : ''
}}
viewBox="0 0 1024 1024"
xmlns="http://www.w3.org/2000/svg"
// 点击事件在外面处理
onClick={() => emit('onClick')}
>
<path fill="currentColor" d="M384 192v640l384-320.064z"></path>
</svg>
)
这样tree.tsx
里面的代码就变得非常清爽,测试功能没问题之后,就可以继续增加别的特性。
11节点增删操作
接下来我们来完成节点的增删操作,最终效果如下:
打开use-tree.ts
文件,增加append
和remove
两个方法:
import { Ref } from 'vue'
import { IInnerTreeNode } from '../tree-type'
export default function useTree(tree: Ref<IInnerTreeNode[]>) {
const append = (parent: IInnerTreeNode, node: IInnerTreeNode) => {
// 增加节点的逻辑
console.log('useOperate append', parent, node)
}
const remove = (node: IInnerTreeNode) => {
// 删除节点的逻辑
console.log('useOperate remove', node)
}
return {
append,
remove
}
}
增加增删操作的UI
然后给tree组件增加一个props叫operable
:
export const treeProps = {
// 增加节点增删操作的功能
operable: {
type: Boolean,
default: false
},
};
在tree-node.tsx
中增加相应的操作按钮
export default defineComponent({
name: 'STreeNode',
props: treeNodeProps,
setup(props: TreeNodeProps, { emit, slots }) {
// 增加operable
const { lineable, checkable, operable } = toRefs(props)
// 增加append,remove
const { getChildrenExpanded, toggleCheckNode, append, remove } = inject(
'TREE_UTILS'
) as TreeUtils
// 增加isShow控制操作按钮显示
const isShow = ref(false)
// 操作按钮触发
const toggleOperate = () => {
if (isShow.value) {
isShow.value = false
} else {
isShow.value = true
}
}
return () => (
<div class="s-tree-node"
// 控制操作按钮显示
onMouseenter={toggleOperate}
onMouseleave={toggleOperate}
>
{/* 连接线 */}
<div class="s-tree-node__content">
{/* 展开/收起按钮 */}
{/* 勾选按钮 */}
{/* 节点内容 */}
{/* 增删改操作 */}
{operable.value && isShow.value && (
<span class="inline-flex ml-1">
<svg
onClick={() => {
append(treeNode, {
label: '新节点'
})
}}
viewBox="0 0 1024 1024"
width="14"
height="14"
class="cursor-pointer"
>
<path d="M590.769231 571.076923h324.923077c15.753846 0 29.538462-13.784615 29.538461-29.538461v-59.076924c0-15.753846-13.784615-29.538462-29.538461-29.538461H590.769231c-11.815385 0-19.692308-7.876923-19.692308-19.692308V108.307692c0-15.753846-13.784615-29.538462-29.538461-29.538461h-59.076924c-15.753846 0-29.538462 13.784615-29.538461 29.538461V433.230769c0 11.815385-7.876923 19.692308-19.692308 19.692308H108.307692c-15.753846 0-29.538462 13.784615-29.538461 29.538461v59.076924c0 15.753846 13.784615 29.538462 29.538461 29.538461H433.230769c11.815385 0 19.692308 7.876923 19.692308 19.692308v324.923077c0 15.753846 13.784615 29.538462 29.538461 29.538461h59.076924c15.753846 0 29.538462-13.784615 29.538461-29.538461V590.769231c0-11.815385 7.876923-19.692308 19.692308-19.692308z"></path>
</svg>
<svg
onClick={() => {
remove(treeNode)
}}
viewBox="0 0 1024 1024"
width="14"
height="14"
class="cursor-pointer ml-1"
>
<path d="M610.461538 500.184615l256-257.96923c11.815385-11.815385 11.815385-29.538462 0-41.353847l-39.384615-41.353846c-11.815385-11.815385-29.538462-11.815385-41.353846 0L527.753846 417.476923c-7.876923 7.876923-19.692308 7.876923-27.569231 0L242.215385 157.538462c-11.815385-11.815385-29.538462-11.815385-41.353847 0l-41.353846 41.353846c-11.815385 11.815385-11.815385 29.538462 0 41.353846l257.969231 257.969231c7.876923 7.876923 7.876923 19.692308 0 27.56923L157.538462 785.723077c-11.815385 11.815385-11.815385 29.538462 0 41.353846l41.353846 41.353846c11.815385 11.815385 29.538462 11.815385 41.353846 0L498.215385 610.461538c7.876923-7.876923 19.692308-7.876923 27.56923 0l257.969231 257.969231c11.815385 11.815385 29.538462 11.815385 41.353846 0L866.461538 827.076923c11.815385-11.815385 11.815385-29.538462 0-41.353846L610.461538 527.753846c-7.876923-7.876923-7.876923-19.692308 0-27.569231z"></path>
</svg>
</span>
)}
</div>
</div>
)
}
});
增加一个增删操作的demo,看下UI和数据传递是否正确
## 操作节点
:::demo
```vue
<template>
<STree :data="data" operable></STree>
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue'
export default defineComponent({
setup() {
const data = ref([
{
label: 'node 1',
id: 'node-1',
children: [
{
label: 'node 1-1',
id: 'node-1-1'
},
]
},
{
label: 'node 2',
id: 'node-2'
},
])
return {
data,
}
},
})
</script>
:::
效果如下:
效果如下:
点击“+”和“X”图标按钮,传递的数据也是正确的。下一步就是实现useOperate
里面的具体逻辑。
实现节点操作
接下来我们想要实现节点的增删操作的视线逻辑,use-tree.ts
:
import { randomId } from "./utils";
export default function useTree(data: Ref<IInnerTreeNode[]>) {
// ...
const getIndex = (node: IInnerTreeNode): number => {
if (!node) return -1
return innerData.value.findIndex(item => item.id === node.id)
}
const append = (parent: IInnerTreeNode, node: IInnerTreeNode) => {
// 获取parent最后一个子节点
const children = getChildren(parent, false)
const lastChild = children[children.length - 1]
// 确定node插入位置
// 默认在parent后面
let insertedIndex = getIndex(parent) + 1
// 如果存在lastChild则在其后面
if (lastChild) {
insertedIndex = getIndex(lastChild) + 1
}
// 保证parent是展开、非叶子状态
// 这样可以看到新增节点
parent.expanded = true
parent.isLeaf = false
// 新增节点初始化
const currentNode = ref({
...node,
level: parent.level + 1,
parentId: parent.id,
isLeaf: true
})
// 设置新增节点ID
if (currentNode.value.id === undefined) {
currentNode.value.id = randomId()
}
// 插入新节点
innerData.value.splice(insertedIndex, 0, currentNode.value)
}
const remove = (node: IInnerTreeNode) => {
// 获取node子节点ids
const childrenIds = getChildren(node).map(nodeItem => nodeItem.id)
// 过滤掉node和其子节点之外的节点
innerData.value = innerData.value.filter(
// item既不是node也不是node子节点
item => item.id !== node.id && !childrenIds.includes(item.id)
)
}
return {
append,
remove,
}
}
生成随机id,实现randomId
,src/shared/utils.ts
export function randomId(n = 8): string {
// 生成n位长度的字符串
const str = 'abcdefghijklmnopqrstuvwxyz0123456789' // 可以作为常量放到random外面
let result = ''
for (let i = 0; i < n; i++) {
result += str[parseInt((Math.random() * str.length).toString())]
}
return result
}
效果如下: