你好,我是大圣。

上一讲,我们一起学习了弹窗组件的设计与实现,这类组件的主要特点是需要渲染在最外层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个节点要渲染,如何对这个树形节点做性能优化呢?

欢迎你在评论区分享你的答案,也欢迎你把这一讲的内容分享给你的同事和朋友们,我们下一讲再见。