你好,我是大圣。
上一讲,我们一起学习了弹窗组件的设计与实现,这类组件的主要特点是需要渲染在最外层body标签之内,并且还需要支持JavaScript动态创建和调用组件。相信学完上一讲,你不但会对弹窗类组件的实现加深理解,也会对TDD模式更有心得。
除了弹窗组件,树形组件我们在前端开发中经常用到,所以今天我就跟你聊一下树形组件的设计思路跟实现细节。
我们进入Element3的Tree组件文档页面,现在我们对Vue的组件如何设计和实现已经很熟悉了,我重点挑跟之前组件设计不同的地方为你讲解。
在设计新组件的时候,我们需要重点考虑的就是树形组件和之前我们之前的Container、Button、Notification有什么区别。树形组件的主要特点是可以无限层级、这种需求在日常工作和生活中其实很常见,比如后台管理系统的菜单管理、文件夹管理、生物分类、思维导图等等。
根据上图所示,我们可以先拆解出树形组件的功能需求。
首先,树形组件的节点可以无限展开,父节点可以展开和收起节点,并且每一个节点有一个复选框,可以切换当前节点和所有子节点的选择状态。另外,同一级所有节点选中的时候,父节点也能自动选中。
下面的代码是Element3的Tree组件使用方式,所有的节点配置都是一个data对象实现的。每个节点里的label用来显示文本;expaned显示是否展开;checked用来决定复选框选中列表,data数据内部的children属性用来配置子节点数组,子节点的数据结构和父节点相同,可以递归实现。
<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题反转二叉树,题目的描述很简单,就是把属性结构反转,下面是题目的描述:
每一个节点的val属性代表显示的数字,left指向左节点,right指向右节点,如何实现invertTree去反转这一个二叉树,也就是所有节点的left和right互换位置呢?
输入
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去递归执行,最终实现了整棵树的反转。
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事件。
<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节点,组件内部渲染自己,这就是组件递归的写法。
<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对父组件触发事件。
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是函数的话,也要返回函数执行的结果。
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组件。
<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>
上面的代码会渲染出下面的示意图的效果。
最后,我们还可以对树实现更多操作方式的支持。
比如我们可以支持树形结构的拖拽修改、可以把任何任意节点拖拽到其他树形内部、修改整个树形结构的内容。想要实现这些功能,我们就需要监听节点的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个节点要渲染,如何对这个树形节点做性能优化呢?
欢迎你在评论区分享你的答案,也欢迎你把这一讲的内容分享给你的同事和朋友们,我们下一讲再见。