You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

14 KiB

24如何设计一个树形组件

你好,我是大圣。

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

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