|
|
# 24|树:如何设计一个树形组件?
|
|
|
|
|
|
你好,我是大圣。
|
|
|
|
|
|
上一讲,我们一起学习了弹窗组件的设计与实现,这类组件的主要特点是需要渲染在最外层body标签之内,并且还需要支持JavaScript动态创建和调用组件。相信学完上一讲,你不但会对弹窗类组件的实现加深理解,也会对TDD模式更有心得。
|
|
|
|
|
|
除了弹窗组件,树形组件我们在前端开发中经常用到,所以今天我就跟你聊一下树形组件的设计思路跟实现细节。
|
|
|
|
|
|
## 组件功能分析
|
|
|
|
|
|
我们进入[Element3的Tree组件文档页面](https://e3.shengxinjing.cn/#/component/tree),现在我们对Vue的组件如何设计和实现已经很熟悉了,我重点挑跟之前组件设计不同的地方为你讲解。
|
|
|
|
|
|
在设计新组件的时候,我们需要重点考虑的就是树形组件和之前我们之前的Container、Button、Notification有什么区别。树形组件的主要特点是可以无限层级、这种需求在日常工作和生活中其实很常见,比如后台管理系统的菜单管理、文件夹管理、生物分类、思维导图等等。
|
|
|
|
|
|
![图片](https://static001.geekbang.org/resource/image/0y/f6/0yy86b867a51890c7ea1ebbaf11f90f6.png?wh=1814x744)
|
|
|
|
|
|
根据上图所示,我们可以先拆解出树形组件的功能需求。
|
|
|
|
|
|
首先,树形组件的节点可以无限展开,父节点可以展开和收起节点,并且每一个节点有一个复选框,可以切换当前节点和所有子节点的选择状态。另外,同一级所有节点选中的时候,父节点也能自动选中。
|
|
|
|
|
|
下面的代码是Element3的Tree组件使用方式,所有的节点配置都是一个data对象实现的。每个节点里的label用来显示文本;expaned显示是否展开;checked用来决定复选框选中列表,data数据内部的children属性用来配置子节点数组,子节点的数据结构和父节点相同,可以递归实现。
|
|
|
|
|
|
```javascript
|
|
|
<el-tree
|
|
|
:data="data"
|
|
|
show-checkbox
|
|
|
v-model:expanded="expandedList"
|
|
|
v-model:checked="checkedList"
|
|
|
:defaultNodeKey="defaultNodeKey"
|
|
|
>
|
|
|
</el-tree>
|
|
|
<script>
|
|
|
export default {
|
|
|
data() {
|
|
|
return {
|
|
|
expandedList: [4, 5],
|
|
|
checkedList: [5],
|
|
|
data: [
|
|
|
{
|
|
|
id: 1,
|
|
|
label: '一级 1',
|
|
|
children: [
|
|
|
{
|
|
|
id: 4,
|
|
|
label: '二级 1-1',
|
|
|
children: [
|
|
|
{
|
|
|
id: 9,
|
|
|
label: '三级 1-1-1'
|
|
|
},
|
|
|
{
|
|
|
id: 10,
|
|
|
label: '三级 1-1-2'
|
|
|
}
|
|
|
]
|
|
|
}
|
|
|
]
|
|
|
},
|
|
|
{
|
|
|
id: 2,
|
|
|
label: '一级 2',
|
|
|
children: [
|
|
|
{
|
|
|
id: 5,
|
|
|
label: '二级 2-1'
|
|
|
},
|
|
|
{
|
|
|
id: 6,
|
|
|
label: '二级 2-2'
|
|
|
}
|
|
|
]
|
|
|
}
|
|
|
],
|
|
|
defaultNodeKey: {
|
|
|
childNodes: 'children',
|
|
|
label: 'label'
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
## 递归组件
|
|
|
|
|
|
这里父节点和子节点的样式操作完全一致,并且可以无限嵌套,这种需求需要组件递归来实现,也就是组件内部渲染自己渲染自己。
|
|
|
|
|
|
想要搞定递归组件,我们需要先明确什么是递归,递归的概念也是我们前端进阶过程中必须要掌握的知识点。
|
|
|
|
|
|
前端的场景中,树这个数据结构出现的频率非常高,浏览器渲染的页面是Dom树,我们内部管理的是虚拟Dom树,**树形结构是一种天然适合递归的数据结构**。
|
|
|
|
|
|
我们先来做一个算法题感受一下,我们来到[leetcode第226题反转二叉树](https://leetcode-cn.com/problems/invert-binary-tree),题目的描述很简单,就是把属性结构反转,下面是题目的描述:
|
|
|
|
|
|
> 每一个节点的val属性代表显示的数字,left指向左节点,right指向右节点,如何实现invertTree去反转这一个二叉树,也就是所有节点的left和right互换位置呢?
|
|
|
|
|
|
```javascript
|
|
|
|
|
|
输入
|
|
|
4
|
|
|
/ \
|
|
|
2 7
|
|
|
/ \ / \
|
|
|
1 3 6 9
|
|
|
输出
|
|
|
4
|
|
|
/ \
|
|
|
7 2
|
|
|
/ \ / \
|
|
|
9 6 3 1
|
|
|
节点的构造函数
|
|
|
/**
|
|
|
* Definition for a binary tree node.
|
|
|
* function TreeNode(val, left, right) {
|
|
|
* this.val = (val===undefined ? 0 : val)
|
|
|
* this.left = (left===undefined ? null : left)
|
|
|
* this.right = (right===undefined ? null : right)
|
|
|
* }
|
|
|
*/
|
|
|
|
|
|
```
|
|
|
|
|
|
输入的左右位置正好相反,而且每个节点的结构都相同,这就是非常适合递归的场景。递归的时候,我们首先需要思考递归的核心逻辑如何实现,这里就是两个节点如何交换,然后就是递归的终止条件,否则递归函数就会进入死循环。
|
|
|
|
|
|
下面的代码中,设置invertTree函数的终止条件是root是null的时候,也就是如果节点不存在的时候不需要反转。这里我们只用了一行解构赋值的代码就实现了,值得注意的是右边的代码中我们递归调用了inverTree去递归执行,最终实现了整棵树的反转。
|
|
|
|
|
|
```javascript
|
|
|
var invertTree = function(root) {
|
|
|
// 递归 终止条件
|
|
|
if(root==null) {
|
|
|
return root
|
|
|
}
|
|
|
// 递归的逻辑
|
|
|
[root.left, root.right] = [invertTree(root.right), invertTree(root.left)]
|
|
|
return root
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
树形组件的数据结构内部的children可以无限嵌套,处理这种数据结构,就需要使用递归的算法思想。有了上面这个算法题的基础后,我们后面再学习树形组件如何实现就能更加顺畅了。
|
|
|
|
|
|
## 组件实现
|
|
|
|
|
|
首先我们进入到Element3的tree文件夹内部,然后找到tree.vue文件。tree.vue 是组件的入口容器,用于接收和处理数据,并将数据传递给 TreeNode.vue;TreeNode.vue 负责渲染树形组件的选择框、标题和递归渲染子元素。
|
|
|
|
|
|
在下面的代码中,我们提供了el-tree的容器,还导入了el-tree-node进行渲染。tree.vue通过provide向所有子元素提供tree的数据,通过useExpand判断树形结构的展开状态,并且用到了watchEffect去向组件外部通知update:expanded事件。
|
|
|
|
|
|
```javascript
|
|
|
<template>
|
|
|
<div class="el-tree">
|
|
|
<el-tree-node v-for="child in tree.root.childNodes" :node="child" :key="child.id"></el-tree-node>
|
|
|
</div>
|
|
|
</template>
|
|
|
|
|
|
<script>
|
|
|
import ElTreeNode from './TreeNode.vue'
|
|
|
const instance = getCurrentInstance()
|
|
|
const tree = new Tree(props.data, props.defaultNodeKey, {
|
|
|
asyncLoadFn: props.asyncLoadFn,
|
|
|
isAsync: props.async
|
|
|
})
|
|
|
const state = reactive({
|
|
|
tree
|
|
|
})
|
|
|
provide('elTree', instance)
|
|
|
useTab()
|
|
|
useExpand(props, state)
|
|
|
|
|
|
function useExpand(props, state) {
|
|
|
const instance = getCurrentInstance()
|
|
|
const { emit } = instance
|
|
|
|
|
|
if (props.defaultExpandAll) {
|
|
|
state.tree.expandAll()
|
|
|
}
|
|
|
|
|
|
watchEffect(() => {
|
|
|
emit('update:expanded', state.tree.expanded)
|
|
|
})
|
|
|
|
|
|
watchEffect(() => {
|
|
|
state.tree.setExpandedByIdList(props.expanded, true)
|
|
|
})
|
|
|
|
|
|
onMounted(() => {
|
|
|
state.tree.root.expand(true)
|
|
|
})
|
|
|
}
|
|
|
|
|
|
|
|
|
</script>
|
|
|
|
|
|
```
|
|
|
|
|
|
然后我们进入到Tree.Node.vue文件中,tree-node组件是树组件的核心,一个TreeNode组件包含四个部分:展开按钮、文本的多选框、每个节点的标题和递归的children子节点。
|
|
|
|
|
|
我们先来看 TreeNode.vue 的模板基本结构,可以把下面的div标签分成四个部分:el-tree-node\_\_content负责每个树节点的渲染,第一个span就是渲染展开符;el-checkbox组件负责显示复选框,并且绑定了node.isChecked属性;el-node\_\_contentn负责渲染树节点的标题;el-tree\_\_children负责递归渲染el-tree-node节点,组件内部渲染自己,这就是组件递归的写法。
|
|
|
|
|
|
```xml
|
|
|
<div
|
|
|
v-show="node.isVisable"
|
|
|
class="el-tree-node"
|
|
|
:class="{
|
|
|
'is-expanded': node.isExpanded,
|
|
|
'is-current': elTree.proxy.dragState.current === node,
|
|
|
'is-checked': node.isChecked,
|
|
|
}"
|
|
|
role="TreeNode"
|
|
|
ref="TreeNode"
|
|
|
:id="'TreeNode' + node.id"
|
|
|
@click.stop="onClickNode"
|
|
|
>
|
|
|
<div class="el-tree-node__content">
|
|
|
<span
|
|
|
:class="[
|
|
|
{ expanded: node.isExpanded, 'is-leaf': node.isLeaf },
|
|
|
'el-tree-node__expand-icon',
|
|
|
elTree.props.iconClass
|
|
|
]"
|
|
|
@click.stop="
|
|
|
node.isLeaf ||
|
|
|
(elTree.props.accordion ? node.collapse() : node.expand())
|
|
|
">
|
|
|
</span>
|
|
|
<el-checkbox
|
|
|
v-if="elTree.props.showCheckbox"
|
|
|
:modelValue="node.isChecked"
|
|
|
@update:modelValue="onChangeCheckbox"
|
|
|
@click="elTree.emit('check', node, node.isChecked, $event)"
|
|
|
>
|
|
|
</el-checkbox>
|
|
|
<el-node-content
|
|
|
class="el-tree-node__label"
|
|
|
:node="node"
|
|
|
></el-node-content>
|
|
|
</div>
|
|
|
<div
|
|
|
class="el-tree-node__children"
|
|
|
v-show="node.isExpanded"
|
|
|
v-if="!elTree.props.renderAfterExpand || node.isRendered"
|
|
|
role="group"
|
|
|
:aria-expanded="node.isExpanded"
|
|
|
>
|
|
|
<el-tree-node
|
|
|
v-for="child in node.childNodes"
|
|
|
:key="child.id"
|
|
|
:node="child"
|
|
|
>
|
|
|
</el-tree-node>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
```
|
|
|
|
|
|
然后我们看下tree-node中我们需要处理的数据有哪些。下面的代码中,我们先通过inject注入tree组件最完成的配置。然后在点击节点的时候,通过判断elTree的全局配置,去决定点击之后的切换功能,并且在展开和checkbox切换的同时,通过emit对父组件触发事件。
|
|
|
|
|
|
```javascript
|
|
|
const elTree = inject('elTree')
|
|
|
const onClickNode = (e) => {
|
|
|
!elTree.props.expandOnClickNode ||
|
|
|
props.node.isLeaf ||
|
|
|
(elTree.props.accordion ? props.node.collapse() : props.node.expand())
|
|
|
|
|
|
!elTree.props.checkOnClickNode ||
|
|
|
props.node.setChecked(undefined, elTree.props.checkStrictly)
|
|
|
|
|
|
elTree.emit('node-click', props.node, e)
|
|
|
elTree.emit('current-change', props.node, e)
|
|
|
props.node.isExpanded
|
|
|
? elTree.emit('node-expand', props.node, e)
|
|
|
: elTree.emit('node-collapse', props.node, e)
|
|
|
}
|
|
|
|
|
|
const onChangeCheckbox = (e) => {
|
|
|
props.node.setChecked(undefined, elTree.props.checkStrictly)
|
|
|
elTree.emit('check-change', props.node, e)
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
到这里,树结构的渲染其实就结束了。
|
|
|
|
|
|
但是有些场景我们需要对树节点的渲染内容进行自定。比如后面这段代码,我们在节点的右侧加上append和delete操作按钮,这种需求在菜单树的管理中很常见。
|
|
|
|
|
|
这个时候我们节点需要支持内容的自定义,然后我们注册了el-node-content组件。这个组件使用起来非常简单,由于我们还需要支持节点的自定义渲染,所以要把这部分抽离成组件。当slots.default为函数的时候,返回函数的执行内容;或者传递的renderContent是函数的话,也要返回函数执行的结果。
|
|
|
|
|
|
```javascript
|
|
|
import { TreeNode } from './entity/TreeNode'
|
|
|
import { inject, h } from 'vue'
|
|
|
|
|
|
render(ctx) {
|
|
|
const elTree = inject('elTree')
|
|
|
if (typeof elTree.slots.default === 'function') {
|
|
|
return elTree.slots.default({ node: ctx.node, data: ctx.node.data.raw })
|
|
|
} else if (typeof elTree.props.renderContent === 'function') {
|
|
|
return elTree.props.renderContent({
|
|
|
node: ctx.node,
|
|
|
data: ctx.node.data.raw
|
|
|
})
|
|
|
}
|
|
|
|
|
|
return h('span', ctx.node.label)
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
这样,用户就可以利用render-content属性传递一个函数的方式,去实现内容的自定义渲染。
|
|
|
|
|
|
我们还是结合代码例子做理解,下面的代码中用了render-content的方式返回树形结构的渲染结果,render-content传递的函数内部会根据node和data数据,返回对应的标题,并且新增了两个el-button组件。
|
|
|
|
|
|
```xml
|
|
|
<div class="custom-tree-container">
|
|
|
<div class="block">
|
|
|
<p>使用 render-content</p>
|
|
|
<el-tree
|
|
|
:data="data1"
|
|
|
show-checkbox
|
|
|
default-expand-all
|
|
|
:expand-on-click-node="false"
|
|
|
:render-content="renderContent"
|
|
|
>
|
|
|
</el-tree>
|
|
|
</div>
|
|
|
</div>
|
|
|
<script>
|
|
|
function renderContent({ node, data }) {
|
|
|
return (
|
|
|
<span class="custom-tree-node">
|
|
|
<span>{data.label}</span>
|
|
|
<span>
|
|
|
<el-button
|
|
|
size="mini"
|
|
|
type="text"
|
|
|
onClick={() => this.append(node, data)}
|
|
|
>
|
|
|
Append
|
|
|
</el-button>
|
|
|
<el-button
|
|
|
size="mini"
|
|
|
type="text"
|
|
|
onClick={() => this.remove(node, data)}
|
|
|
>
|
|
|
Delete
|
|
|
</el-button>
|
|
|
</span>
|
|
|
</span>
|
|
|
)
|
|
|
}
|
|
|
</script>
|
|
|
|
|
|
```
|
|
|
|
|
|
上面的代码会渲染出下面的示意图的效果。
|
|
|
![图片](https://static001.geekbang.org/resource/image/ce/59/cef8cb4d740cfcc0984e546761e33b59.png?wh=1800x940)
|
|
|
|
|
|
最后,我们还可以对树实现更多操作方式的支持。
|
|
|
|
|
|
比如我们可以支持树形结构的拖拽修改、可以把任何任意节点拖拽到其他树形内部、修改整个树形结构的内容。想要实现这些功能,我们就需要监听节点的drag-over、drag-leave等拖拽事件,在drop事件执行的时候,把拖拽的节点数据,复制给拖拽的节点中完成修改即可。这部分代码,同学们可以自行去Element3拓展学习。
|
|
|
|
|
|
## 总结
|
|
|
|
|
|
今天的主要内容就讲完啦,我们来总结一下今天学到的内容吧。
|
|
|
|
|
|
首先我们分析了树形组件的设计需求、我们需要递归组件的形式去实现树形节点的无限嵌套,然后我们通过算法题的形式掌握了递归的概念,这个概念在Vue组件中也是一样的,每个组件返回name后,可以通过这个name在组件内部来调用自己,这样就可以很轻松地实现Tree组件。
|
|
|
|
|
|
tree组件具体要分成三个组件进行实现。最外层的tree组件负责整个树组件的容器,内部会通过provide方法为子元素提供全局的配置和操作方法。每个tree的配置中的title、expanded、checked树形作为树组件显示的主体内容。children是一个深层嵌套的数组,我们需要用递归组件的方式渲染出完成的树,tree内部的tree-node组件就负责递归渲染出完成的树形结构。
|
|
|
|
|
|
最后,我们想支持树节点的自定义渲染,这就需要在teree-node内部定制tree-node-content组件,用来渲染用户传递的render-content或者默认的插槽函数。
|
|
|
|
|
|
树形数据在我们日常开发项目中也很常见,菜单、城市选择、权限等数据都很适合树形结构,学会树形结构的处理,能很好地帮助我们在日常开发中应对更复杂的需求。
|
|
|
|
|
|
## 思考题
|
|
|
|
|
|
最后留一个思考题吧。我们的树形组件现在是全部节点的渲染,如果我们有1000个节点要渲染,如何对这个树形节点做性能优化呢?
|
|
|
|
|
|
欢迎你在评论区分享你的答案,也欢迎你把这一讲的内容分享给你的同事和朋友们,我们下一讲再见。
|
|
|
|