{T}

设计树形组件

组件功能分析

树形组件的主要特点是可以无限层级、这种需求在日常工作和生活中其实很常见,比如后台管理系统的菜单管理、文件夹管理、生物分类、思维导图等等。

树形组件的节点可以无限展开,父节点可以展开和收起节点,并且每一个节点有一个复选框,可以切换当前节点和所有子节点的选择状态。同一级所有节点选中的时候,父节点也能自动选中

html
<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>

组件实现

这里父节点和子节点的样式操作完全一致,并且可以无限嵌套,这种需求需要组件递归来实现,也就是组件内部渲染自己渲染自己

首先进入到 Element3 的 tree 文件夹内部,然后找到 tree.vue 文件。tree.vue 是组件的入口容器,用于接收和处理数据,并将数据传递给 TreeNode.vue;TreeNode.vue 负责渲染树形组件的选择框、标题和递归渲染子元素

提供了 el-tree 的容器并导入了 el-tree-node 进行渲染。tree.vue 通过 provide 向所有子元素提供 tree 的数据,通过 useExpand 判断树形结构的展开状态,并且用到了 watchEffect 去向组件外部通知 update:expanded 事件

Vue SFC
<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 节点,组件内部渲染自己,这就是组件递归的写法

Vue SFC
<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 传递的函数内部会根据 node 和 data 数据,返回对应的标题,并且新增了两个 el-button 组件

Vue SFC
<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>

上面的代码会渲染出下面的示意图的效果

image-20240617115044551

最后还可以对树实现更多操作方式的支持。比如可以支持树形结构的拖拽修改、可以把任何任意节点拖拽到其他树形内部、修改整个树形结构的内容。想要实现这些功能,就需要监听节点的 drag-over、drag-leave 等拖拽事件,在 drop 事件执行的时候,把拖拽的节点数据,复制给拖拽的节点中完成修改即可