树形结构相信大家在日常生活中都见过,它的特点是一层一层嵌套,比如文件系统。
以下是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
主要包含以下功能:
由于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
看看效果,很简陋,有待优化
我们获取到的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>,
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:
看一下效果:
现在虽然树节点都渲染出来了,但是看着不像一棵树,我们已经有了节点的层级信息,试着给它加个缩进效果吧。
只需要给TreeNode加一个paddingLeft
就行了,第一层没有缩进,从第二层开始,每往里一层缩进24px。
{
innerData.map(treeNode => (
{ treeNode.label }
))
}
看着是不是有模有样了!效果如下:
现在是默认全部节点都展开了,假如我们把scripts那个节点的expanded: true
去掉,希望是以下效果:
同时需要明确地知道哪个节点是展开的,哪个节点是收起的,接着在节点前面加一个展开/收起的图标按钮给用户反馈。如果是展开的,则显示一个向下的三角图标:
如果是收起的,则显示一个向右的三角图标,表示该节点下面有子节点,并且是收起的:
实现起来非常简单,在label前面加一个svg图标,默认是向右的三角箭头,如果expanded
为true,则顺时针旋转90度,变成向下的三角箭头。注意处理下叶子节点,叶子节点前面不应该有展开/收起图标,而应该是一个占位符,让节点能够左对齐,美观一点。
{
innerData.map(treeNode => (
{
treeNode.isLeaf
?
:
}
{ treeNode.label }
))
}
效果如下:
前面都只是UI展示,现在我们想要点击节点前面的图标能够实现展开/收起,这涉及到逻辑,我们增加一个toggleNode
的方法。
const innerData = ref(generateTreeNode(data.value))
// 增加toggleNode方法
const toggleNode = (node: IInnerTreeNode) => {}
{
innerData.map(treeNode => (
{
treeNode.isLeaf
?
:
}
{ 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
}
现在可以正常使用了!效果如下:
到此为止,我们就实现了一颗能展开/收起的极简版本的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) {
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:
效果如下:
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 () => (
{
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
}
效果如下:
8 勾选功能
本节我们给Tree
组件增加一个可勾选的功能,这为以后批量编辑节点做好准备。
新增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
:::
效果如下:
增加点击事件处理
下面处理用户点击行为
{
checkable.value &&
{
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 ?
:
{/* 新增icon插槽判断 */}
slots.icon ?
slots.icon({nodeData: treeNode, toggleNode}) :
{/* 原先的图标 */}
}
}
}
})
在Tree组件中使用下试试看,icon
插槽可以接收到容器传过来的treeNode数据,判断节点是否展开,展开就顺时针旋转90度,让箭头朝下,docs/tree/index.md:
:::demo 自定义展开图标,设置icon插槽
{
event.stopPropagation();
toggleNode(nodeData);
}">
:::
效果如下:
自定义节点内容
有时我们想在节点前后增加一些内容,比如图标,就需要增加content
插槽。
{
slots.content
? slots.content(treeNode)
: treeNode.label
}
和icon
插槽的套路类似,不再赘述。
有了icon
和content
插槽,就可以做一个Github 代码树效果啦!
要实现的效果如下:
试着用我们的Tree组件来实现,主要有以下功能:
- 节点前面需要增加文件夹或文件的图标,如果是父节点则加文件夹图标,如果是叶子结点则加文件图标
- 叶子节点后面需要增加一个代表是否修改过的标记图标
{{treeNode.label}}
实现的效果如下:
是不是已经和Github的几乎是一样的,这说明我们开发的Tree组件是一个真正能在业务中用起来的实实在在的组件。后面要做的就是持续地完善它!
自定义节点内容
有时我们想在节点前后增加一些内容,比如图标,就需要增加content插槽。
{
slots.content
? slots.content(treeNode)
: treeNode.label
}
和icon插槽的套路类似,不再赘述。
有了icon和content插槽,就可以做一个Github PR代码检视的效果啦!
要实现的效果如下:
试着用我们的Tree组件来实现,主要有以下功能:
- 节点前面需要增加文件夹或文件的图标,如果是父节点则加文件夹图标,如果是叶子结点则加文件图标
- 叶子节点后面需要增加一个代表是否修改过的标记图标
{{treeNode.label}}
实现的效果如下:
是不是已经和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 () => (
{/* 连接线 */}
{!treeNode.isLeaf && treeNode.expanded && lineable.value && (
)}
{/* 如果是叶子节点则放一个空白占位元素,否则放一个三角形反馈图标 */}
{treeNode.isLeaf ? (
) : slots.icon ? (
slots.icon({ nodeData: treeNode, toggleNode })
) : (
)}
{/* 复选框 */}
{checkable.value && (
{
toggleCheckNode(treeNode)
}}
/>
)}
{/* 节点文本 */}
{slots.content ? slots.content(treeNode) : treeNode.label}
)
}
})
需要解决一下问题:
- 定义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,
required: true
}
}
export type TreeNodeProps = ExtractPropTypes
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 (
{
treeData.expendedTree.value.map((treeNode: IInnerTreeNode) => (
{{
content: slots.content,
icon: slots.icon
}}
))
}
)
}
}
})
测试可行。但是这里还有改进空间,tree.tsx
太薄,成了纯粹的转发。我们完全可以将插槽判断逻辑在此处完成,让tree-node
变成更纯粹的内容展示。我们作以下更改:
tree
中作插槽是否传递的判断
tree-node
中移除判断逻辑
- 把默认展开折叠图标封装为单独组件
tree中作插槽是否传递的判断
import STreeNodeToggle from './components/tree-node-toggle'
export default defineComponent({
setup(props: TreeProps, { slots }) {
return () => {
return (
{
treeData.expendedTree.value.map((treeNode: IInnerTreeNode) => (
{{
content: () =>
slots.content ? slots.content(treeNode) : treeNode.label,
icon: () =>
slots.icon ? (
slots.icon({
nodeData: treeNode,
toggleNode: treeData.toggleNode
})
) : (
treeData.toggleNode(treeNode)}
>
)
}}
))
}
)
}
}
})
提取一个TreeNodeToggle组件
提取默认折叠图标为TreeNodeToggle
组件:
import { SetupContext } from 'vue'
// 函数式组件更简洁
export default (props: { expanded: boolean }, { emit }: SetupContext) => (
)
这样tree.tsx
里面的代码就变得非常清爽,测试功能没问题之后,就可以继续增加别的特性。
11节点增删操作
接下来我们来完成节点的增删操作,最终效果如下:
打开use-tree.ts
文件,增加append
和remove
两个方法:
import { Ref } from 'vue'
import { IInnerTreeNode } from '../tree-type'
export default function useTree(tree: Ref) {
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 () => (
{/* 连接线 */}
{/* 展开/收起按钮 */}
{/* 勾选按钮 */}
{/* 节点内容 */}
{/* 增删改操作 */}
{operable.value && isShow.value && (
)}
)
}
});
增加一个增删操作的demo,看下UI和数据传递是否正确
## 操作节点
:::demo
```vue
:::
效果如下:
效果如下:
点击“+”和“X”图标按钮,传递的数据也是正确的。下一步就是实现useOperate
里面的具体逻辑。
实现节点操作
接下来我们想要实现节点的增删操作的视线逻辑,use-tree.ts
:
import { randomId } from "./utils";
export default function useTree(data: Ref) {
// ...
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
}