各位掘金的小伙伴,大家好!前面咱们完成了基于vite基本环境搭建,集成了ts、eslint、prettier以及tailwindcss工具,是时候用tsx语法来开发一个Vue3组件了。本节开始,咱们一起来开发一个tree
组件,通过逐步迭代,使其从光秃秃、毫无生机变为生机盎然、装饰漂亮。
树结构的定义
首先对于树结构,我们自然想到下面的展现形式:
[
{
label: '前端',
id: 'frontend'
},
{
label: 'java',
id: 'java',
expanded: true,
children: [
{ label: '11', id: '11' },
{ label: '22', id: '22' },
{ label: '33', id: '33' },
{
label: '44',
id: '44',
expanded: true,
children: [
{
label: 'aaa',
id: 'aaa',
expanded: true,
children: [
{ label: 'a11', id: 'a11' },
{ label: 'a22', id: 'a22' }
]
},
{
label: 'bbb',
id: 'bbb',
expanded: false,
children: [
{ label: 'b11', id: 'b11' },
{ label: 'b22', id: 'b22' }
]
}
]
},
{ label: '55', id: '55' },
{ label: '66', id: '66' },
{ label: '77', id: '77' },
{
label: 'Java基础',
id: 'javaBasic'
},
{
label: 'Java Web',
id: 'javaWeb',
children: [
{
label: 'ssm',
id: 'ssm'
}
]
}
]
},
{
label: '数据库',
id: 'db',
expanded: false,
children: [
{
label: '关系型数据库',
id: 'relationShipDB'
},
{
label: '非关系型数据库',
id: 'nonRelationShipDB'
}
]
}
]
这里,小卷随便造了些数据,这是一个带有children
子节点数组的嵌套结构,此外还具有的属性:label
(标签)、id
(节点标识id)和expanded
(是否展开)。很自然,我们可以为这种嵌套结构的节点抽取出类型定义,创建一个维护树节点类型定义的types.ts
文件:
src/components/tree/types.ts
// 定义基本的树节点类型
export interface ITreeNode {
label: string // 节点标签名
id?: string // 节点id,可为空
children?: ITreeNode[] // 子节点列表,可为空
expanded?: boolean //是否展开,空则表示默认折叠
}
这里咱们创建并导出一个ITreeNode
类型,其中label
字段是非空的,children
是自身类型的一个数组。
有了前面的tree
数据结构和节点类型的定义,是不是咱们就可以编写组件模板来渲染这棵树了呢?答案是肯定的!我们可以定义一个TreeNode
的组件,对其子节点列表的渲染采用遍历的方式继续递归渲染子组件TreeNode
,这种思路是开发具有嵌套的层级结构组件大伙儿所能想到的常规思路。
咱们这里要介绍的是对嵌套数据结构进行扁平化处理后,采用列表的形式来渲染,只不过树的层级结构是由节点元素按照层级进行一定的paddingLeft
来实现的。随着后续的学习,小伙伴会发现,尽管tree
组件的渲染不是递归的,但是对铺平节点列表之前的拍平处理以及后续子节点的计算处理却依然采用的递归的思想呢。
注意
拍平结构避免了树组件内部的递归渲染,但带来的麻烦是,需要开发者对于子节点范围划定做更多的编程处理。后续开发中,小卷会提供思路,如何来简化这种处理方式。
树结构拍平处理
现在咱们写一个工具函数对之前的嵌套结构进行拍平处理。小伙伴们跟着小卷的思路,咱们先来实现一个最简单的数组结果处理的需求:把一个数组中的元素复制到一个新数组中。创建一个utils.ts
的工具函数编写文件:
src/components/tree/utils.ts
function copyArr(arr: number[]) {
return arr.reduce((result, cur) => {
return result.concat(cur)
}, [] as number[])
}
const newArr = copyArr([1, 2, 3])
console.log(newArr)
这里我们巧用了Array
的reduce
方法,在遍历每个元素时进行转存的处理,把结果存入箭头回调函数的第一个参数result
中,而在reduce
的第2个参数中对这个存放转存元素的数组进行初始化。
现在我们测试这个copyArr
函数,可以全局安装一个ts-node
工具来执行.ts
文件:
npm i -g ts-node@10.9.2
同时,在工程tsconfig.json
中加入ts-node
配置,启用对es module
的编译支持:
{
...
"ts-node": {
"esm": true
},
...
}
测试下,ok!
核心处理函数
发散思维
对于嵌套的树结构,我们只需要写一个函数,接收一个代表当前层级的数组,也就是通过数组的
reduce
方法,将当前层级的每个节点放到一个新的数组中,而对于父节点的情况,我们递归调用该函数,对其子节点列表做相同的处理,将得到的拍平的数组,插入到当前父节点之后即可。
ok!现在我们可以轻易写出拍平核心处理函数:
src/components/tree/utils.ts
import { ITreeNode } from './types'
export function generateFlatTree(tree: ITreeNode[]): ITreeNode[] {
return tree.reduce((prev, cur) => {
if (cur.children) {
// 递归,得到子节点拍平的数组
const children = generateFlatTree(cur.children)
return prev.concat(cur, children)
} else {
return prev.concat(cur)
}
}, [] as ITreeNode[])
}
测试一下,ok!
扁平化数据结构
现在我们系统拍平后的结构能够展示父节点id、所处的层级,并且把嵌套的children
属性移除掉,用一个是否叶子节点的标记属性代替,也就是说,咱们要定义一个新的IFlattreeNode
结构来替换掉原始的ITreeNode
,我们将在原来节点类型基础上进行扩展:
src/components/tree/types.ts
...
// 扩展层级关系,用于拍平结构的树节点
export interface IFlatTreeNode extends ITreeNode {
parentId?: string // 父节点id,若是一级节点则为空
level: number // 节点层级,数值从1开始
isLeaf: boolean //是否为叶子节点
originalNode: ITreeNode // 指向对应的原始节点
}
这里,我们对ITreeNode
扩展了4个字段,其中parentId
可为空,originalNode
的引用有助于对其子节点做递归计算处理。
对generateFlatTree
函数进一步完善后导出,方法参数扩展level
和pid
参数,使得递归时能绑定节点上下级关系,对拍平的节点这里我们从原节点拷贝出一个对象作为IFlatTreeNode
类型,后续会通过逻辑处理为其扩展属性赋值,并最终把children
属性移除掉,完善后的核心代码:
import { IFlatTreeNode, ITreeNode } from './types'
/**
*
* @param tree 当前层级的节点列表
* @param level 表示当前节点所处的层级
* @param pid 父节点id
*/
export function generateFlatTree(tree: ITreeNode[], level = 0, pid = ''): IFlatTreeNode[] {
level++ // 层级加1
// 巧用数组的reduce方法对嵌套的树形结构进行拍平处理,prev为当前层级要处理的拍平结构结果,cur为当前遍历的节点
return tree.reduce((prev, cur) => {
// 拷贝当前节点
const o = { ...cur } as IFlatTreeNode
// 绑定关系
o.originalNode = cur
o.level = level
// 为内层节点设置父id
if (level > 1 && pid) {
o.parentId = pid
}
// 判断当前节点是否存在children,如果存在则递归处理
if (o.children) {
// 以当前节点作为父节点,对子节点列表做递归处理,得到内部拍平的内容
const children = generateFlatTree(o.children, level, o.id!)
// 移除嵌套结构
delete o.children
// 在已经拍平的结构基础上再拼接当前节点和内部拍平节点
o.isLeaf = false
return prev.concat(o, children)
} else {
// 叶子节点,处理会更简单
o.isLeaf = true
return prev.concat(o)
}
}, [] as IFlatTreeNode[])
}
通过ts-node
进行测试,ok!
Tree组件开发
有了前面树结构转换的铺垫,树组件的开发会变得非常简单!在types.ts
中定义组件的data
属性:
import { ExtractPropTypes, PropType } from 'vue'
// 树数据的属性定义
export const props = {
data: {
// 类型为一个元素为ITreeNode类型的数组
type: Object as PropType<Array<IFlatTreeNode>>,
required: true
}
} as const // 设置为常量,外部无法修改
// 提取tree组件的属性定义类型
export type Props = ExtractPropTypes<typeof props>
...
注意,这里节点的定义类型为IFlatTreeNode
,这样我们可以在外部先完成拍平操作后再给tree
组件传入data
属性即可。
接下来,我们从之前Vite TSX Vue3组件开发快速入门小节学到的知识,可以很轻松的开发出tree
组件了:
src/components/tree/index.tsx
import { defineComponent } from 'vue'
import { props, Props } from './types'
export default defineComponent({
name: 'JuanTree',
props,
setup(props: Props) {
// 解构出传入的tree数据
const { data } = props
return () => {
return (
<div class='juan-tree'>
{/* 相当于v-for */}
{data.map((treeNode) => (
<div
key={treeNode.id}
class='juan-tree-node'
style={{
/* 树的层级缩进 */
paddingLeft: `${24 * (treeNode.level - 1)}px`
}}
>
{treeNode.label}
</div>
))}
</div>
)
}
}
})
值得注意的是,这里tsx的元素遍历的写法,需要为元素绑定key
,以实现节点变化后的局部dom
渲染。这里我们对节点元素<div>
采用了动态绑定style
属性的方式依据level
来决定其左边留白的距离。
在App
组件中应用JuanTree
组件:
import { defineComponent } from 'vue'
import JuanTree from './components/tree'
import { generateFlatTree } from './components/tree/utils'
import { ITreeNode } from './components/tree/types'
export default defineComponent({
setup() {
// 这里数据省略
const treeData = [...] as ITreeNode[]
return () => {
// 树的扁平化处理
const flatTree = generateFlatTree(treeData)
return (
<div class='m-4'>
<JuanTree class='bg-gray-200' data={flatTree}></JuanTree>
</div>
)
}
}
})
看下页面效果,粗略的展示出一棵树:
实现树节点的展开与折叠
思路点拨
实际要渲染的树结构,咱们应该排除掉所有
expanded
不为true
的节点的后代节点。也就是应该有一个方法来计算一个IFlatTreeNode
下所有后代节点的长度,在从上到下对传入的data
进行遍历时,跳过这些长度的节点即可得到最终要渲染的树的列表结构。
这里要计算的后代节点长度,聪明的小伙伴想到在generateFlatTree
工具函数中,其实基于递归调用得到的结果,咱们是直接可以利用的。我们不妨打印到控制台看看:
if (o.children) {
const children = generateFlatTree(o.children, level, o.id!)
console.log(o.id + '子节点长度:' + children.length)
...
}
这样还要啥自行车,咱直接获取就行了嘛。自然我们可以在IFlatTreeNode
类型中扩展一个length
属性:
export interface IFlatTreeNode extends ITreeNode {
...
length: number // 所有子孙节点的长度
}
在generateFlatTree
函数中判断是父节点的逻辑中设置下该属性:
if (o.children) {
const children = generateFlatTree(...)
// 记录当前节点子代的长度
o.length = children.length
...
}
接着,咱们需要借助于计算属性对响应式的tree
数据进行计算,得到真正要展示的数据,自然我们想到在tree
组件的setup
方法中进行这样的处理:
// 让其变为响应式数据以加入计算属性的计算
const flatData = ref(data)
// 获取那些展开的节点列表
const expandedTree = computed(() => {
const result = []
// 循环列表,跳过那些非expanded
for (let i = 0; i < flatData.value.length; i++) {
const item = flatData.value[i]
// 当当前节点处于折叠状态,它的子节点应该被排除
if (!item.isLeaf && item.expanded !== true) {
// 跳过内部所有的节点
i += item.length
}
result.push(item)
}
// 得到折叠后的新节点列表
return result
})
此时,模板使用的是计算属性返回的数据,遍历的是expandedTree.value
:
<div ...>
{expandedTree.value.map((treeNode) => (
...
))}
</div>
看下效果,ok!正是我们需要的折叠后的效果!
对是否展开的节点做下标记,在渲染的节点模板中我们暂且以
button
元素作为节点折叠、展开的修饰部件:
<div ...>
{treeNode.isLeaf ? (
/* 叶子节点临时展示,留出间距,确保同级的父节点和叶子节点对齐 */
<span class='mr-1 inline-block w-[20px]'></span>
) : (
<button class='mr-1 inline-block h-[18px] w-[20px]'>
{/* 父节点的展开/折叠操作临时用+、-代替 */}
{treeNode.expanded ? <span>-</span> : <span>+</span>}
</button>
)}
{treeNode.label}
</div>
再瞅一眼,good!
最后再来锦上添花,实现展开与折叠效果,给button
绑定点击事件:<button onClick={() => toggleNode(treeNode)} ...>
,在TSX的setup
方法中声明下事件处理函数:
const toggleNode = (node: ITreeNode) => {
// 对展开状态取反
node.expanded = !node.expanded
}
这里对实际为IFlatTreeNode
类型的节点更新expanded
属性,因为expandedTree
计算属性中参与计算的flatData
是响应式的,而计算属性返回数据列表中的节点对象来自于传入的data
,节点对象属性发生变化自然会触发计算属性重新计算啦看下效果,杠杠滴
存在的问题
聪明的小伙伴会提出这样的问题:“小卷,现在咱们的树节点是固定的,如果可以动态增删节点,那么
IFlatTreeNode
节点的length
属性岂不是不会变化了嘛?!”“非常好的问题!”咱们后面要进一步使用计算属性来修复这个问题,给善于思考的小伙伴一个大大的👍
好了!学到这里,咱们一颗基本的树组件就“画”好了,后续咱们会继续逐步迭代来丰富展示效果和交互体验!大家加油!