# 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 ``` ## 递归组件 这里父节点和子节点的样式操作完全一致,并且可以无限嵌套,这种需求需要组件递归来实现,也就是组件内部渲染自己渲染自己。 想要搞定递归组件,我们需要先明确什么是递归,递归的概念也是我们前端进阶过程中必须要掌握的知识点。 前端的场景中,树这个数据结构出现的频率非常高,浏览器渲染的页面是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 ``` 然后我们进入到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
``` 然后我们看下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

使用 render-content

``` 上面的代码会渲染出下面的示意图的效果。 ![图片](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个节点要渲染,如何对这个树形节点做性能优化呢? 欢迎你在评论区分享你的答案,也欢迎你把这一讲的内容分享给你的同事和朋友们,我们下一讲再见。