Vue 封装简易 Tree 组件:快一起来感受树形结构的魅力!😎

扯皮

这段时间搞个开题答辩整理资料闹麻了☹️。秋招也开始了,拿着自己秋招时的简历反复研究发现问题挺大的,而且项目也还有需要改进的地方

最重要的是自己项目还有一个遗憾,就是封装一个树形组件😤,这个组件的含金量还是比较高的,结果一是时间问题,二是那时候的我也太菜了撕不出来🤣

这段时间在 b 站上看到了一个组件库的视频教程,也是比较老的课程了,其实之前就了解过这个课程讲的还是不错的,但是付费还不便宜,作为学生党的我确实有点纠结🙁

但前段时间不知道是哪个 b... 站用户给它上传了,考虑到这个原因在这里就先不放视频连接了。里面的树形组件封装思路讲的还凑合,只跟着视频看一遍学不到什么东西,还得自己搞一遍总结下来才能吸收🧐,正好最近 React 用的比较多,顺带着复习一遍 Vue

废话不多说,直接开冲!😏

正文

不过开始前还是要先提个醒,Tree 组件做到组件库级别是十分复杂的,本文只简单介绍一些实现思路,代码并没有什么组织性

准备数据、格式化树结构数据源

首先树形数据比较好办,直接去 Element Plus 组件示例里粘贴过来就行:

ts 复制代码
const 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",
      },
    ],
  },
  {
    id: 3,
    label: "一级 3",
    children: [
      {
        id: 7,
        label: "二级 3-1",
      },
      {
        id: 8,
        label: "二级 3-2",
      },
    ],
  },
];
export default data;

现在就开始封装树形组件,给它起名 FsTree,我们不着急搭建它的基本结构样式,需要先确定该组件传入的属性有哪些🧐:

data 数据源肯定要有的,同时为了体现组件的通用性,我们增加 keyFiledlabelFiledchildrenField 可选属性用来确定数据源里的字段:

ts 复制代码
export type NodeKey = string | number;

export interface IFsTreeProps {
  // 数据源
  data: ITreeItem[];
  // key、label、children 字段映射
  keyField?: string;
  labelField?: string;
  childrenField?: string;
}

// 数据源 item
export interface ITreeItem {
  key?: NodeKey;
  label?: NodeKey;
  children?: ITreeItem[];
  [key: string]: any;
}

这几个 field 属性具体的含义是什么呢?🤔

考虑一下 data 数据源虽然是一个树形结构,但它的一些字段名对于我们组件封装者来说是不确定的,比如有的数据源用 id 来做唯一标识、有的用 key 来标识。有的是直接用 label 标识文本,而有的用 value 等等

当然你也可以针对于 props data 利用 Vue3.3 的泛型组件进行类型约束,但是这样的话就需要使用者进行一次转换操作,对于用户来说体验就比较差了...😑

像 Naive UI 中就有相关的属性:

那么就需要我们在组件封装中针对于树形数据源进行格式化 ,即: ITreeItem => ITreeNode

ts 复制代码
export interface ITreeItem {
  key?: NodeKey;
  label?: NodeKey;
  children?: ITreeItem[];
  [key: string]: any;
}

export interface ITreeNode extends Required<ITreeItem> {
  level: number;
  parentKey: NodeKey | null;
  children: ITreeNode[];
  rawNode: ITreeItem;
}

针对于 ITreeNode 是 Tree 组件内部使用的节点,它不仅固定字段名称,还会有一些额外属性方便后续实现功能,比如 level:该节点所处层级、parentKey:该节点的父级节点 key、rawNode:保存数据源 item

下面就要开始正式写逻辑代码了,怎么对树形数据源格式化呢? dfs 递归遍历秒了😁,一边遍历一边转化:

xml 复制代码
<script setup lang="ts">
import { ref, watch } from "vue";
import { IFsTreeProps, ITreeItem, ITreeNode } from "./types";
const props = withDefaults(defineProps<IFsTreeProps>(), {
  // 可选属性给默认值
  keyField: "key",
  labelField: "label",
  childrenField: "children",
});

const treeData = ref<ITreeNode[]>([]);

watch(
  () => props.data,
  (newValue) => {
    treeData.value = formatTreeData(newValue, null);
  },
  {
    immediate: true,
  }
);

function formatTreeData(data: ITreeItem[], parent: ITreeNode | null): ITreeNode[] {
  return data.map((item) => {
    // 数据源 children 可能不存在
    const children = item[props.childrenField] || [];
    const treeNode: ITreeNode = {
      key: item[props.keyField],
      label: item[props.labelField],
      children: [],
      // 初始化层数为 0,之后根据父节点 level + 1 即可
      level: parent ? parent.level + 1 : 0,
      parentKey: parent ? parent.key : null,
      rawNode: item,
    };
    if (children.length) treeNode.children = formatTreeData(children, treeNode);
    return treeNode;
  });
}
</script>

打印下日志看看是不是符合我们要想的结果,没啥毛病🤪:

拍平树形结构、渲染视图

其实根据上一步已经拿到了格式化后的 treeData,在视图上可以直接以递归组件的方式渲染节点了

但为什么还需要再进行一次拍平操作呢?这跟我们最后要实现的功能有关,我们把这个疑问留到最后实现该功能解释,现在先进行拍平操作🧐

经典的树形结构转换为一维数组题目,作为校招生基本上已经写烂的玩意儿,无脑深度遍历就完事了,这里我们使用计算属性:

ts 复制代码
const flattenTree = computed(() => {
  const res: ITreeNode[] = [];
  function dfs(tree: ITreeNode[]) {
    tree.forEach((node) => {
      res.push(node);
      dfs(node.children);
    });
  }
  dfs(treeData.value);
  return res;
});

注意其实也不能无脑遍历,要考虑遍历顺序的😁,因为渲染到视图上树形结构肯定是父节点在前面,所以要用先序遍历

接下来就可以创建 FsTreeNode 组件,这里之前计算的 level 就发挥作用了,需要根据它来设置偏移样式,以此来在视图上展示树形结构:

ts 复制代码
export interface IFsTreeNodeProps {
  node: ITreeNode;
}
xml 复制代码
<template>
  <div class="fs-node-content" :style="{ paddingLeft: `${props.node.level * 16}px` }">
    <div class="fs-node-label">{{ props.node.label }}</div>
  </div>
</template>

<script setup lang="ts">
import { IFsTreeNodeProps } from "./types";
const props = defineProps<IFsTreeNodeProps>();
</script>

<style scoped lang="scss">
.fs-node {
  &-content {
    display: flex;
    align-items: center;
    padding: 5px;
    gap: 5px;
    cursor: pointer;
  }
}
</style>

接着回到之前的 FsTree 组件中利用拍平的数据 flattenTree 直接 v-for 遍历即可:

xml 复制代码
<template>
  <div>
    <fs-tree-node v-for="i in flattenTree" :key="i.key" :node="i" />
  </div>
</template>

上效果图!这样即便只是一维数组,根据其 level 字段也能在视图上展示树形结构:

树的展开和收缩

展示树结构只是第一步操作,它现在还不算是一个 Tree 组件,人 Tree 组件最基本也长这样:

展开和收缩的功能对于 Tree 组件还是很有必要的,我们下面就来实现它

首先给 FsTree 的 props 上增加一个新属性:defaultExpandedKeys,它可以让用户指定默认展开的节点

ts 复制代码
export interface IFsTreeProps {
  // ...
  // new: 默认展开节点
  defaultExpandedKeys?: NodeKey[];
}

而在 FsTree 组件中我们用一个 Set 结构来对展开节点的 key 进行存储,之后我们就能够针对于单个 node 节点来判断是否展开:

xml 复制代码
<template>
  <div>
    <fs-tree-node v-for="i in flattenTree" :key="i.key" :node="i" :is-expanded="computedIsExpanded(i)" />
  </div>
</template>

<script setup lang="ts">
 const props = withDefaults(defineProps<IFsTreeProps>(), {
  //...
  defaultExpandedKeys: () => [],
});

const expandedSet = ref(new Set(props.defaultExpandedKeys));
const computedIsExpanded = (node: ITreeNode) => expandedSet.value.has(node.key);   
</script>

接下来改造 FsTreeNode 组件,给 FsTreeNode 的 props 上增加一个新属性: isExpanded 标识来判断是否展开节点:

ts 复制代码
export interface IFsTreeNodeProps {
  node: ITreeNode;
  isExpanded: boolean;
}

根据其 isExpanded 来设置图标样式状态,这里的图标随便去 iconFont 里面扒一个下来就行:

xml 复制代码
<template>
  <div class="fs-node-content" :style="{ paddingLeft: `${props.node.level * 16}px` }">
    <img :src="expandIcon" :class="['fs-node-icon', props.isExpanded && 'fs-node-icon__expanded']" />
    <div class="fs-node-label">{{ props.node.label }}</div>
  </div>
</template>

<script setup lang="ts">
import expandIcon from "@/assets/expand.svg";
import { IFsTreeNodeProps } from "./types";

const props = defineProps<IFsTreeNodeProps>();
</script>

<style scoped lang="scss">
.fs-node {
  &-content {
    display: flex;
    align-items: center;
    padding: 5px;
    gap: 5px;
    cursor: pointer;
  }
  &-icon {
    width: 0.8em;
    height: 0.8em;
  }
  &-icon__expanded {
    transform: rotate(90deg);
  }
}
</style>

接下来还需要增加点击事件进行展开和收缩操作,我们给节点组件的 icon 增加点击事件,发送自定义事件来通知父组件对该节点的 expanded 状态进行更改:

ts 复制代码
export interface IFsTreeNodeEmitter {
  (e: "toggleExpanded", node: ITreeNode): void;
}
xml 复制代码
<template>
  <div class="fs-node-content" :style="{ paddingLeft: `${props.node.level * 16}px` }">
    <img
      :src="expandIcon"
      :class="['fs-node-icon', props.isExpanded && 'fs-node-icon__expand']"
      @click="handleToggleExpand(props.node)"
    />
    <div class="fs-node-label">{{ props.node.label }}</div>
  </div>
</template>

<script setup lang="ts">
import expandIcon from "@/assets/expand.svg";
import { IFsTreeNodeProps, IFsTreeNodeEmitter, ITreeNode } from "./types";

const props = defineProps<IFsTreeNodeProps>();
const emit = defineEmits<IFsTreeNodeEmitter>();

const handleToggleExpand = (node: ITreeNode) => {
  emit("toggleExpanded", node);
};
</script>

我们回到 FsTree 中监听该事件,根据当前是否展开修改 Set 结构即可:

ts 复制代码
function handleToggleExpanded(node: ITreeNode) {
  expandedSet.value.has(node.key) ? expandedSet.value.delete(node.key) : expandedSet.value.add(node.key);
}

到此为止我们就实现了节点的展开收缩状态的改变,在视图上也可以看到点击时其图标的变化,说明之前的实现没大问题:

接下来就到了该功能最核心的部分了,怎么实现这里数据的展开和收缩呢?🤔

其实相当于我们每次改变 expanded 状态后都需要重新计算 flattenTree 来展示新的树形结构

首先我们要明白展开和收缩是针对于当前节点的所有孩子节点,我们现在多了一个 expandedSet,它可以判断当前节点是否展开和收缩,那我们可以根据该结构在计算 flattenTree 的过程中是否添加孩子节点

即如果遍历到当前节点属于收缩状态,那不再进行递归其孩子节点,有了这个思路就可以改造之前 flattenTree 计算实现,其实也就多了一行判断🤣:

ts 复制代码
const flattenTree = computed(() => {
  const res: ITreeNode[] = [];
  function dfs(tree: ITreeNode[]) {
    tree.forEach((node) => {
      res.push(node);
      // 当前节点属于展开状态再深度遍历
      if (expandedSet.value.has(node.key)) dfs(node.children);
    });
  }
  dfs(treeData.value);
  return res;
});

来看效果,还是挺像样的哈:

但是不知道大伙是否发现一个问题,就是如果一个节点已经没有孩子了,我们其实应该隐藏这个图标,否则给用户的感觉好像这个节点还能再展开一样

说的专业一些就是针对于叶子节点我们无需再展示展开图标,需要对叶子节点进行标识

我们给数据源增加一个 isLeaf 可选属性进行标识:

ts 复制代码
// 数据源 item
export interface ITreeItem {
  key?: NodeKey;
  label?: NodeKey;
  children?: ITreeItem[];
  // new: 是否为叶子节点
  isLeaf?: boolean;
  [key: string]: any;
}

接下来在 FsTree 组件刚开始格式化数据源操作中添加一个对 isLeaf 字段的计算:

ts 复制代码
function formatTreeData(data: ITreeItem[], parent: ITreeNode | null): ITreeNode[] {
  return data.map((item) => {
    const children = item[props.childrenField] || [];
    const treeNode: ITreeNode = {
      //...
      // new:对 isLeaf 进行计算,如果数据源有 isLeaf 直接使用,否则根据其 children 长度判断
      isLeaf: item.isLeaf !== undefined ? item.isLeaf : children.length === 0,
      rawNode: item,
    };
    if (children.length) treeNode.children = formatTreeData(children, treeNode);
    return treeNode;
  });
}

最后来到 FsTreeNode ,现在每个 node 节点都有一个 isLeaf 属性,根据这个属性来设置图标样式即可,需要注意的是由于叶子节点隐藏图标的缘故,所以它的 paddingLeft 应该设置大一些视图上才符合树形结构:

xml 复制代码
<template>
  <div class="fs-node-content" :style="{ paddingLeft: `${props.node.level * (props.node.isLeaf ? 24 : 16)}px` }">
    <img
      :src="expandIcon"
      :class="['fs-node-icon', props.isExpanded && 'fs-node-icon__expanded', props.node.isLeaf && 'fs-node-icon__leaf']"
      @click="handleToggleExpand(props.node)"
    />
    <div class="fs-node-label">{{ props.node.label }}</div>
  </div>
</template>

<style scoped lang="scss">
.fs-node {
  &-content {
    display: flex;
    align-items: center;
    padding: 5px;
    gap: 5px;
    cursor: pointer;
  }
  &-icon {
    width: 0.8em;
    height: 0.8em;
  }
  &-icon__expanded {
    transform: rotate(90deg);
  }
  &-icon__leaf {
    display: none;
  }
}
</style>

树的选择

需要先解释一下这里的选择功能还不是 checkbox 选择,而是当我们点击 treeItem 以及 hover 时会有一个背景色,并且会有对应的点击事件

调研 Naive UI 和 Antd 组件库的 Tree 发现它们后会有对应的属性配置,并且支持多选:

它们有的我们也要有!先给 FsTree 中增加对应的 props 和自定义事件并给出默认值:

ts 复制代码
export interface IFsTreeProps {
  // ...
  // 是否可以选择节点
  selectable?: boolean;
  // 是否支持多选
  multipleSelect?: boolean;
}

export interface IFsTreeEmitter {
  // 点击选择节点事件
  (e: "onSelectNodes", nodes: ITreeNode[]): void;
}
xml 复制代码
<script>
const props = withDefaults(defineProps<IFsTreeProps>(), {
  // ...
  selectable: true,
  multipleSelect: false,
});

const emit = defineEmits<IFsTreeEmitter>();
</script>

而在 FsTreeNode 中也增加对应 props 和自定义事件:

ts 复制代码
export interface IFsTreeNodeProps {
  // ...
  // 是否选中
  isSelect: boolean;
}

export interface IFsTreeNodeEmitter {
  // ...
  // 选中自定义事件
  (e: "selectNode", node: ITreeNode): void;
}

我们给 FsTreeNode 增加点击事件来发射选中自定义事件,同时根据 isSelect 设置背景色样式:

xml 复制代码
<template>
  <div
    :class="['fs-node-content', props.isSelect && 'fs-node-content__select']"
    :style="{ paddingLeft: `${props.node.level * (props.node.isLeaf ? 24 : 16)}px` }"
    @click="handleSelectNode(props.node)"
  >
    <!-- ... -->
  </div>
</template>

<script setup lang="ts">
import expandIcon from "@/assets/expand.svg";
import { IFsTreeNodeProps, IFsTreeNodeEmitter, ITreeNode } from "./types";
const props = defineProps<IFsTreeNodeProps>();
const emit = defineEmits<IFsTreeNodeEmitter>();
// new
const handleSelectNode = (node: ITreeNode) => {
  emit("selectNode", node);
};
</script>

<style scoped lang="scss">
.fs-node {
  &-content {
    display: flex;
    align-items: center;
    padding: 5px;
    gap: 5px;
    cursor: pointer;
    // new
    &:hover {
      background-color: #f5f7fa;
    }
    &__select {
      background-color: #f5f7fa;
    }
  }
}
</style>

而在 FsTree 中我们增加一个 Map 结构来存储选中的节点,监听 FsTreeNode的自定义事件进行设置,同时给用户抛出选择事件即可:

xml 复制代码
<template>
  <div>
    <fs-tree-node
      v-for="i in flattenTree"
      :key="i.key"
      :node="i"
      :is-expanded="computedIsExpanded(i)"
      :is-select="computedIsSelect(i)"
      @toggle-expanded="handleToggleExpanded"
      @select-node="handleSelectNode"
    />
  </div>
</template>

<script setup lang="ts">
// ...
const selectNodesMap = ref<Map<NodeKey, ITreeNode>>(new Map());
const computedIsSelect = (node: ITreeNode) => selectNodesMap.value.has(node.key);


function handleSelectNode(node: ITreeNode) {
  if (!props.selectable) return;
  if (selectNodesMap.value.has(node.key)) {
    selectNodesMap.value.delete(node.key);
  } else {
    if (!props.multipleSelect) selectNodesMap.value.clear();
    selectNodesMap.value.set(node.key, node);
  }
  const nodes = [...selectNodesMap.value].map(([_, node]) => toRaw(node));
  emit("onSelectNodes", nodes);
}
</script>

<style scoped lang="scss"></style>

选中逻辑很简单,无非就是根据传入的 props 先判断是否能够进行选择,之后选择存入 Map、取消删除 Map 中对应的节点即可

需要注意的一点是注意给用户抛出数据时使用 toRaw 转换数据,这个 API 由 Vue3 提供:

如果不经常封装自定义组件可能不太会用到这个 API,就拿这里写的功能举例子:

假如不使用该 API,那在用户使用我们的 Tree 组件监听对应的 onSelectNodes 时获取到的节点数据都是 Proxy 对象

xml 复制代码
<template>
  <div class="container">
    <fs-tree :data="data" key-field="id" @on-select-nodes="handleSelect" multiple-select></fs-tree>
  </div>
</template>

<script setup lang="ts">
import data from "./components/FsTree/data";
import FsTree from "./components/TestTree/FsTree.vue";
import { ITreeNode } from "./components/TestTree/types";
// key!!!
const handleSelect = (nodes: ITreeNode[]) => {
  console.log(nodes);
};
</script>

<style scoped lang="scss">
.container {
  margin-left: 50px;
  padding: 20px;
}
</style>

这样会造成一个什么问题呢?🤔

我们都知道 Vue3 响应式原理中对原始数据进行代理得到 Proxy 对象,那也就是说针对于一个 Proxy 对象它是有依赖收集的,那现在我在获取的过程中进行了这样的操作:

ts 复制代码
const handleSelect = (nodes: ITreeNode[]) => {
  const currentNode = nodes[0];
  if (currentNode) {
    setTimeout(() => {
      currentNode.label = "hello world";
      currentNode.isLeaf = true;
    }, 1000);
  }
};

你会发现这样的操作直接影响到了树结构数据,相当于直接更改了对应节点的原有属性

实际上这种操作个人认为不太合法,因为用户监听对应的事件只是想要拿到选择的对应数据,可能不小心修改了数据,但本意并不是想要修改原来的树结构,所以使用 toRaw 来获取原始对象数据就没有这样的问题:

但是具体要不要使用还是要看业务需求来定,说不定还真就需要选择之后让用户修改对应的树结构了😂,官方也提到该 API 谨慎使用,所以仁者见仁智者见智🧐

树的异步加载

这个功能其实对于 Tree 组件也是比较常见了,类比分页效果毕竟不能保证每次后端就把所有的数据返回

这里的异步加载操作可以参考 Element Plus API 的实现,从组件的开发者来讲是无法感知哪个节点需要进行异步加载的,但是我们之前有埋一个伏笔: isLeaf 属性

注意之前我们把 isLeaf 属性添加到了数据源 ITreeItem 且为可选属性,而并非直接添加到 ITreeNode 作为必填属性就是考虑到这一点

这样就能够让用户告知 Tree 组件这个节点是否需要加载更多的孩子节点,当用户手动配置该节点 isLeaf 为 true 时,此时按照之前的逻辑会展示 expanded 图标也会有对应的点击展开事件,而我们就需要在之前的展开事件多加一层异步加载更多数据的操作

先给 FsTree 增加对应的 load 属性,要求传入一个函数,当用户触发展开操作时会拿到当前节点,同时以回调函数的方式来获取异步数据,组件内部获取到数据添加到对应的孩子节点上即可:

ts 复制代码
export interface IFsTreeProps {
  // ...
  // 异步加载数据方法
  load?: LoadFuncitonType;
}

export type LoadFuncitonType = (node: ITreeNode, loadedCallback: (data: ITreeItem[]) => void) => void;
ts 复制代码
function handleLoadTreeData(node: ITreeNode) {
 // 判断当前节点是否属于叶子节点,且有传入对应的 load 方法
  if (!node.children.length && !node.isLeaf && props.load) {
    props.load(node, (res) => {
      if (res.length) {
        // 获取到数据后保存到原数据 children 上
        node.rawNode.children = res;
        // 获取到的数据还需要进行格式化后才保存到当前节点的 children 上
        node.children = formatTreeData(res, node);
        expandedSet.value.add(node.key);
      } else {
        node.isLeaf = true;
        expandedSet.value.delete(node.key);
      }
    });
    return true;
  }
  return false;
}

function handleToggleExpanded(node: ITreeNode) {
  // new: 针对于展开事件添加对异步数据的处理
  if (!handleLoadTreeData(node))
    expandedSet.value.has(node.key) ? expandedSet.value.delete(node.key) : expandedSet.value.add(node.key);
}

我们来看用户视角使用 Tree 组件,首先给数据源的某个节点增加一个 isLeaf 属性,表示它需要加载异步数据,然后传入对应的 load 方法:

ts 复制代码
const data = [
  {
    id: 1,
    label: "一级 1",
    children: [
      {
        id: 4,
        label: "二级 1-1",
        children: [
          {
            id: 9,
            label: "三级 1-1-1",
            // new
            isLeaf: false,
          },
          {
            id: 10,
            label: "三级 1-1-2",
          },
        ],
      },
    ],
  },
  // ...
];

export default data;
xml 复制代码
<template>
  <div class="container">
    <fs-tree :data="data" key-field="id" :load="loadMoreData"></fs-tree>
  </div>
</template>

<script setup lang="ts">
import data from "./components/FsTree/data";
import { LoadFuncitonType } from "./components/FsTree/types";
import FsTree from "./components/TestTree/FsTree.vue";

const loadMoreData: LoadFuncitonType = (node, resolve) => {
  // 根据其 key 找到对应的异步节点,异步获取孩子数据
  if (node.key === 9) {
    setTimeout(() => {
      resolve([
        {
          id: 20,
          label: "四级 1-1-1-1",
        },
        {
          id: 21,
          label: "四级 1-1-1-2",
        },
      ]);
    }, 1500);
  }
};
</script>

来一起看看效果:

感觉给用户的体验还是不太好😐,最好在异步加载的过程中增加一个 loading 效果,我们还是去 iconFont 里扒一个 loading 图标,改造之前的 FsTreeNode,增加 isLoading 属性,并引入对应图标添加动画:

ts 复制代码
export interface IFsTreeNodeProps {
  // ...
  // 是否处于加载状态
  isLoading: boolean;
}
xml 复制代码
<template>
  <div
    :class="['fs-node-content', props.isSelect && 'fs-node-content__select']"
    :style="{ paddingLeft: `${props.node.level * (props.node.isLeaf ? 24 : 16)}px` }"
    @click="handleSelectNode(props.node)"
  >
    <!-- new -->
    <img v-if="props.isLoading" :src="loadingIcon" class="fs-node-icon fs-node-icon__loading" />
    <img
      v-else
      :src="expandIcon"
      :class="['fs-node-icon', props.isExpanded && 'fs-node-icon__expanded', props.node.isLeaf && 'fs-node-icon__leaf']"
      @click="handleToggleExpand(props.node)"
    />
    <div class="fs-node-label">{{ props.node.label }}</div>
  </div>
</template>

<script setup lang="ts">
import loadingIcon from "@/assets/loading.svg";
import expandIcon from "@/assets/expand.svg";
// ...
</script>

<style scoped lang="scss">
.fs-node {
  // ...
  &-icon__loading {
    cursor: default;
    animation: loading 1s infinite ease-in-out;
  }
}

// new
@keyframes loading {
  from {
    transform: rotate(0);
  }
  to {
    transform: rotateZ(360deg);
  }
}
</style>

而在 FsTree 中也需要计算出对应节点的 loading 状态,我们依旧使用 Set 存储,在异步过程中设置 loading 即可:

xml 复制代码
<template>
  <div>
    <fs-tree-node
      v-for="i in flattenTree"
      :key="i.key"
      :node="i"
      :is-expanded="computedIsExpanded(i)"
      :is-select="computedIsSelect(i)"
      :is-loading="computedIsLoading(i)"
      @toggle-expanded="handleToggleExpanded"
      @select-node="handleSelectNode"
    />
  </div>
</template>

<script setup lang="ts">
// ...
// new
const loadingSet = ref(new Set<NodeKey>([]));
const computedIsLoading = (node: ITreeNode) => loadingSet.value.has(node.key);

function handleLoadTreeData(node: ITreeNode) {
  if (!node.children.length && !node.isLeaf && props.load) {
    // 增加 loading 控制
    loadingSet.value.add(node.key);
    props.load(node.rawNode, (res) => {
       // ...
      loadingSet.value.delete(node.key);
    });
    return true;
  }
  return false;
}

</script>

<style scoped lang="scss"></style>

现在再来看看效果,异步加载的功能基本上就完成了:

当然我们只是简单实现了一个异步加载,实际上可以看看 Element Plus 的做法,它们的异步加载能够做到第一个节点就是异步加载节点,而我们只是在原有的数据之上针对于某个节点做异步加载

这个功能就不再延申了,简单扒了一下 Element Plus 中 Tree 的源码,实际上就是在初始化操作时也要考虑异步加载的情况:

针对于 Tree 所有操作它们用整个 store 来维护,感兴趣的可以扒下源码看看,我们的简易实现就不碰瓷它们的 Tree 实现了🤐

自定义树节点

通用性组件最重要的是什么?就是通用性(废话),所以会发现一个组件它一定会有插槽让用户来自定义内容

毕竟作为组件开发者你再怎么实现也不可能满足所有用户需求,所以只能给用户提供可定制的方式,交给用户处理了

那我们来看看 el-tree 是怎么实现自定义的:

可以看到给 Tree 组件一个传入默认插槽,那问题来了:我们真正展示 tree 节点的是 TreeNode 组件,而用户传入的插槽是传给了 Tree 组件,也就是说这里牵扯到一个跨组件传递插槽的情景

插槽本质上也就是一个函数返回虚拟节点罢了,所以这里的问题其实就是跨组件传递数据,那对应到 Vue 中就很容易想到 provide / inject

通过扒 Element Plus 源码可以发现针对于节点展示的内容又抽离出了 TreeContent 组件,我们也跟着学学 😁

首先利用 useSlots API 可以在 FsTree 中获取插槽内容,我们再通过 provide 把它提供给子孙组件:

xml 复制代码
<template>
<!-- ... -->
</template>

<script setup lang="ts">
// ...
import { useSlots, provide } from "vue";
// ...
provide("RootTreeSlots", { slots: useSlots() });
</script>

<style scoped lang="scss"></style>

创建 FsTreeContent 组件,通过 inject 拿到对应的插槽,如果没有传入插槽就正常渲染节点的 label

刚才提到插槽的本质就是函数,拿到对应的函数执行,然后使用 h 函数进行渲染即可,注意要把 ITreeNode 数据传出,其实就是作用域插槽了:

xml 复制代码
<script lang="ts">
import { PropType, defineComponent, h, inject } from "vue";
import { ITreeNode } from "./types";

export default defineComponent({
  props: {
    node: {
      type: Object as PropType<ITreeNode>,
      required: true,
    },
  },
  setup(props) {
    const node = props.node;
    const { slots } = inject("RootTreeSlots") as any;
    return () => h("div", null, slots.default ? slots.default({ node }) : h("span", null, props.node.label));
  },
});
</script>

接着在 FsTreeNode 中使用 FsTreeContent 即可:

xml 复制代码
<template>
  <div
    :class="['fs-node-content', props.isSelect && 'fs-node-content__select']"
    :style="{ paddingLeft: `${props.node.level * (props.node.isLeaf ? 24 : 16)}px` }"
    @click="handleSelectNode(props.node)"
  >
    <!-- ... -->
    <div class="fs-node-label">
      <fs-tree-content :node="props.node" />
    </div>
  </div>
</template>

<script>
import FsTreeContent from "./FsTreeContent.vue";
</script>

一切准备就绪,现在再来使用 FsTree 传入对应的默认插槽看看效果:

xml 复制代码
<template>
  <div class="container">
    <fs-tree :data="data" key-field="id" :load="loadMoreData">
      <template #default="{ node }">{{ node.key }} - {{ node.label }}</template>
    </fs-tree>
  </div>
</template>

没啥大毛病,这个功能只要对插槽的本质有一定研究就知道怎么做了🧐

checkbox 选择

又是一个经典功能,之前我们实现了 Tree 的点击选择,而 checkbox 实现更复杂一些,需要考虑到节点之间的级联选择,我们一起来看看怎么实现

这里为了方便就使用原生的 input 做 checkbox 了,所以在开始实现之前我们先研究一下原生特性:

首先将其 type 设置为 checkbox 我们就能够得到一个复选框,我们这里不关注它的 value 值,主要关注它的视图状态,控制 checkbox 勾选主要通过 checked 属性

html 复制代码
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <button id="btn1">check</button>
    <input type="checkbox" id="checkbox" />

    <script>
      const btn1 = document.querySelector("#btn1");
      const checkBox = document.querySelector("#checkbox");

      btn1.addEventListener("click", () => {
        checkBox.checked = !checkBox.checked;
      });
    </script>
  </body>
</html>

但结合我们 Tree 组件的业务组件,要考虑当勾选其孩子时父节点要处于半选状态,而设置复选框为半选的属性是 indeterminate,与 checked 属性一样属于 boolean 值:

html 复制代码
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <button id="btn1">check</button>
    <button id="btn2">half check</button>
    <input type="checkbox" id="checkbox" />

    <script>
      const btn1 = document.querySelector("#btn1");
      const btn2 = document.querySelector("#btn2");
      const checkBox = document.querySelector("#checkbox");

      btn1.addEventListener("click", () => {
        checkBox.checked = !checkBox.checked;
      });

      btn2.addEventListener("click", () => {
        checkBox.indeterminate = !checkBox.indeterminate;
      });
    </script>
  </body>
</html>

当然之所以把这两个属性单拉出来讲这么多肯定不是那么简单,同样的代码注意看下面 gif 的操作:

细心的你一定会发现设置半选的优先级比全选的高,当半选为 true 时,即便设置为 checked 视图上仍然显示为半选状态

明白了这个规律后我们就可以在每次进行全选和半选时同时把对方置为 false,这样就保证了当前视图状态的唯一性:

html 复制代码
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <button id="btn1">check</button>
    <button id="btn2">half check</button>
    <input type="checkbox" id="checkbox" />

    <script>
      const btn1 = document.querySelector("#btn1");
      const btn2 = document.querySelector("#btn2");
      const checkBox = document.querySelector("#checkbox");

      btn1.addEventListener("click", () => {
        checkBox.indeterminate = false;
        checkBox.checked = !checkBox.checked;
      });

      btn2.addEventListener("click", () => {
        checkBox.checked = false;
        checkBox.indeterminate = !checkBox.indeterminate;
      });
    </script>
  </body>
</html>

下面就进入到了我们的主题,我们先给 FsTree 组件 props 增加展示勾选和默认勾选属性以及勾选自定义事件,而数据源 ITreeItem 增加是否勾选、半选属性:

ts 复制代码
export interface IFsTreeProps {
  // ...
  // 默认勾选节点
  defaultCheckedKeys?: NodeKey[];
  // 是否展示勾选框
  showCheckbox?: boolean;
}

export interface IFsTreeEmitter {
  // ...
  // 勾选节点事件
  (e: "onCheckChange", node: ITreeItem, checked: boolean): void;
}

export interface ITreeItem {
  // ...
  // 是否勾选
  isChecked?: boolean;
  // 是否半选
  isHalfChecked?: boolean;
  [key: string]: any;
}

对应的 FsTreeNode 组件 props 也增加展示勾选属性,增加 onCheck 自定义事件表示用户手动勾选:

ts 复制代码
export interface IFsTreeNodeProps {
  // ...
  // 是否展示勾选
  showCheckbox: boolean;
}

export interface IFsTreeNodeEmitter {
  // ...
  // 勾选自定义事件
  (e: "onCheck", node: ITreeNode): void;
}

那么就可以根据其 showCheckbox 给 FsTreeNode 增加对应的复选框了:

xml 复制代码
<template>
  <div
    :class="['fs-node-content', props.isSelect && 'fs-node-content__select']"
    :style="{ paddingLeft: `${props.node.level * (props.node.isLeaf ? 24 : 16)}px` }"
    @click="handleSelectNode(props.node)"
  >
    <img v-if="props.isLoading" :src="loadingIcon" class="fs-node-icon fs-node-icon__loading" />
    <img
      v-else
      :src="expandIcon"
      :class="['fs-node-icon', props.isExpanded && 'fs-node-icon__expanded', props.node.isLeaf && 'fs-node-icon__leaf']"
      @click="handleToggleExpand(props.node)"
    />
    <div class="fs-node-label">
      <!-- new -->
      <input
        v-if="props.showCheckbox"
        type="checkbox"
        :checked="props.node.isChecked"
        :indeterminate="props.node.isHalfChecked"
        @change="handleCheckChange(props.node)"
      />
      <fs-tree-content :node="props.node" />
    </div>
  </div>
</template>

<script setup lang="ts">
// ...
const handleCheckChange = (node: ITreeNode) => {
  emit("onCheck", node);
};
</script>

<style scoped lang="scss">
// ...
</style>

回到 FsTree 组件中,按照之前选择的思路,我们创建一个 checkedSet 来存储勾选的节点,格式化结构时也增加对应的两个属性:

ts 复制代码
const checkedSet = ref(new Set(props.defaultCheckedKeys));

function formatTreeData(data: ITreeItem[], parent: ITreeNode | null): ITreeNode[] {
  return data.map((item) => {
    const children = item[props.childrenField] || [];
    const treeNode: ITreeNode = {
      // ...
      // new
      isChecked:
        item.isChecked !== undefined ? item.isChecked : checkedSet.value.has(item[props.keyField]) ? true : false,
      isHalfChecked: item.isHalfChecked !== undefined ? item.isHalfChecked : false,
    };
    if (children.length) treeNode.children = formatTreeData(children, treeNode);
    return treeNode;
  });
}

之后就到了级联选择的业务逻辑了,我们监听 FsTreeNode 的勾选事件,针对于勾选主要做四件事:

  1. 更改当前节点的 checked 状态
  2. 向下递归遍历孩子节点进行勾选
  3. 向上递归遍历父节点,对于每个父节点判断其所有孩子节点是否为全选,若为非全选则把父节点设置为半选,否则为全选
  4. 发送自定义事件 onCheckChange 供用户监听

这四个步骤都清楚之后就可以上手写逻辑了:

ts 复制代码
function handleCheckNode(node: ITreeNode) {
  // 1. 根据当前勾选状态修改当前节点
  node.isChecked = !node.isChecked;
  checkedSet.value[node.isChecked ? "add" : "delete"](node.key);
  // 2. 处理孩子节点
  handleCheckChildren(node, node.isChecked);
  // 3. 处理父节点
  handleCheckParent(node, node.isChecked);
  // 4. 发送自定义事件
  emit("onCheckChange", toRaw(node.rawNode), node.isChecked);
}

针对于孩子节点无脑进行递归遍历即可,其状态由父节点决定:

ts 复制代码
function handleCheckChildren(node: ITreeNode, isCheck: boolean) {
  const children = node.children;
  if (children) {
    children.forEach((node) => {
      node.isChecked = isCheck;
      checkedSet.value[node.isChecked ? "add" : "delete"](node.key);
      handleCheckChildren(node, isCheck);
    });
  }
}

还记得我们之前在 ITreeNode 上添加了 parentKey 属性吗?我们将用它来找到每个节点的父级节点🙂

但现在我们还需要一个扁平结构存储所有节点,虽然我们由 flattenTree,但通过 parentKey 找到节点需要调用 find 方法,find 方法的时间复杂度为 O(n),最重要的是 flattenTree 对于折叠的节点是不计算在内的,因此需要一个新数据结构存储所有节点

这里我们可以新增一个 Map 结构,它的作用就是用来做 key - 节点 映射的,有了它就能直接调用 get 够找到对应的父节点,之后就进行逻辑处理

需要注意的是我们一开始提到的 checkbox 半选优先级问题,其他的就是正常业务逻辑:

ts 复制代码
const treeMap = computed<Map<NodeKey, ITreeNode>>(() => {
  const map = new Map<NodeKey, ITreeNode>();
  dfs(treeData.value);
  function dfs(data: ITreeNode[]) {
    data.forEach((item) => {
      map.set(item.key, item);
      dfs(item.children);
    });
  }
  return map;
});

function handleCheckParent(node: ITreeNode, isCheck: boolean) {
  let parentKey = node.parentKey;
  while (parentKey) {
    const parent = treeMap.value.get(parentKey)!;
    const children = parent.children;
    let isAll = true;
    let isHalf = false;

    if (children) {
      children.forEach((node) => {
        if (!node.isChecked) isAll = false;
        if (node.isChecked) isHalf = true;
      });
    }
    if (isAll) {
      parent.isHalfChecked = false;
      parent.isChecked = isCheck;
      checkedSet.value[isCheck ? "add" : "delete"](parent.key);
    } else {
      parent.isChecked = false;
      checkedSet.value.delete(parent.key);
      parent.isHalfChecked = isHalf;
    }
    parentKey = parent.parentKey;
  }
}

到此就实现了勾选级联效果,我们在父组件使用看看效果:

xml 复制代码
<template>
  <div class="container">
    <fs-tree :data="data" key-field="id" show-checkbox @on-check-change="handleCheck"> </fs-tree>
  </div>
</template>

<script setup lang="ts">
import data from "./components/FsTree/data";
import { ITreeItem } from "./components/TestTree/types";
import FsTree from "./components/TestTree/FsTree.vue";

const handleCheck = (node: ITreeItem, checked: boolean) => {
  console.log(node, checked);
};
</script>

一般组件组件实例也会提供一些方法辅助获取到所选的内容:

我们也意思意思,在 FsTree 中实现 getCheckedKeys 和 getCheckedNodes,再使用 defineExpose 把这俩方法暴露出去:

ts 复制代码
defineExpose({ getCheckedKeys, getCheckedNodes });

function getCheckedKeys() {
  return [...checkedSet.value];
}

function getCheckedNodes() {
  const res: ITreeNode[] = [];
  checkedSet.value.forEach((key) => {
    res.push(toRaw(treeMap.value.get(key)!));
  });
  return res;
}

之后在外部就可以通过 ref 获取来调用啦🧐:

xml 复制代码
<template>
  <div class="container">
    <fs-tree ref="treeRef" :data="data" key-field="id" show-checkbox @on-check-change="handleCheck"> </fs-tree>
  </div>
</template>

<script setup lang="ts">
import data from "./components/FsTree/data";
import FsTree from "./components/TestTree/FsTree.vue";
import { ref } from "vue";

const treeRef = ref<InstanceType<typeof FsTree>>();

const handleCheck = () => {
  console.log(treeRef.value?.getCheckedKeys(), treeRef.value?.getCheckedNodes());
};
</script>

树的虚拟滚动

现在市面上的组件库越来越卷了,记得作者刚开始学前端的组件库还没见到有虚拟列表的说法,现在各大组件库对于 Tree 组件都已经有了自己的虚拟滚动效果:

这里面只有 Element Plus 最奇葩,它把虚拟滚动树又单独抽离出一个新的组件:

个人猜测应该还是历史遗留问题,也没有针对于 Tree 组件进行重构,可以发现其他组件库的 Tree 组件早已经使用以扁平数据渲染节点,而只有 Element Plus 的 Tree 组件还是以树形数据结构递归渲染节点

因为虚拟滚动肯定是需要扁平数据的,所以考虑到这一点将 Tree 和 Virtual Tree 进行了切割

所以这要提到我们最早的伏笔了,为什么一开始就要拍平树形结构呢?就是考虑到我们最后要做虚拟滚动

当然关于虚拟列表的原理已经写了很多篇文章了:

面试官:能否用原生JS手写一个虚拟列表...啊?你还真能写啊? - 掘金 (juejin.cn)

定高的虚拟列表会了,那不定高的...... 哈,我也会!看我详解一波!🤪🤪🤪 - 掘金 (juejin.cn)

这里其实就是一个定高的虚拟列表套壳,我们就来快速实现一个最简单的虚拟列表组件完事,甚至连触底加载都不做了,因为一般树形数据很少说以分页的形式加载的:

xml 复制代码
<template>
  <div class="fs-virtuallist-container" ref="containerRef" @scroll="handleScroll">
    <div class="fs-virtuallist-list" :style="scrollStyle">
      <div class="fs-virtuallist-item" v-for="i in renderList" :key="i.key">
        <slot :item="i" />
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { CSSProperties, computed, onMounted, reactive, ref } from "vue";

const props = defineProps<{
  itemHeight: number;
  dataSource: any[];
}>();

const containerRef = ref<HTMLDivElement | null>(null);

const state = reactive({
  viewHeight: 0,
  maxCount: 0,
  startIndex: 0,
});

const endIndex = computed(() => state.maxCount + state.startIndex);

const renderList = computed(() => props.dataSource.slice(state.startIndex, endIndex.value));

const scrollStyle = computed(
  () =>
    ({
      height: `${props.dataSource.length * props.itemHeight - state.startIndex * props.itemHeight}px`,
      transform: `translate3d(0, ${state.startIndex * props.itemHeight}px, 0)`,
    } as CSSProperties)
);

const handleScroll = () => {
  const scrollTop = containerRef.value!.scrollTop;
  state.startIndex = Math.floor(scrollTop / props.itemHeight);
};

onMounted(() => {
  state.viewHeight = containerRef.value!.clientHeight;
  state.maxCount = Math.ceil(state.viewHeight / props.itemHeight) + 1;
});
</script>

<style scoped lang="scss">
.fs-virtuallist {
  &-container {
    width: 100%;
    height: 100%;
    overflow-y: scroll;
  }
  &-item {
    width: 100%;
    box-sizing: border-box;
    height: v-bind("`${props.itemHeight}px`");
  }
}
</style>

emmm,确实挺简陋的,我们引入到 FsTree 中,先给容器写死个高度看看效果

xml 复制代码
<template>
  <div style="width: 100%; height: 400px">
    <fs-virtuallst :item-height="32" :data-source="flattenTree">
      <template #default="{ item }">
        <fs-tree-node
          :node="item"
          :show-checkbox="props.showCheckbox"
          :is-expanded="computedIsExpanded(item)"
          :is-select="computedIsSelect(item)"
          :is-loading="computedIsLoading(item)"
          @toggle-expanded="handleToggleExpanded"
          @select-node="handleSelectNode"
          @on-check="handleCheckNode"
        />
      </template>
    </fs-virtuallst>
  </div>
</template>

父组件使用 FsTree,粘贴个生成大量树形数据的方法:

xml 复制代码
<template>
  <div class="container">
    <fs-tree :data="createData(2, 5, 10)" key-field="id" show-checkbox> </fs-tree>
  </div>
</template>

<script setup lang="ts">
import FsTree from "./components/TestTree/FsTree.vue";
import { ref } from "vue";


interface Tree {
  id: string;
  label: string;
  children?: Tree[];
}

const getKey = (prefix: string, id: number) => {
  return `${prefix}-${id}`;
};

const createData = (maxDeep: number, maxChildren: number, minNodesNumber: number, deep = 1, key = "node"): Tree[] => {
  let id = 0;
  return Array.from({ length: minNodesNumber })
    .fill(deep)
    .map(() => {
      const childrenNumber = deep === maxDeep ? 0 : Math.round(Math.random() * maxChildren);
      const nodeKey = getKey(key, ++id);
      return {
        id: nodeKey,
        label: nodeKey,
        children: childrenNumber ? createData(maxDeep, maxChildren, childrenNumber, deep + 1, nodeKey) : undefined,
      };
    });
};

</script>

<style scoped lang="scss">
.container {
  margin-left: 50px;
  padding: 20px;
}
</style>

来看虚拟滚动效果,虽然简陋但看着也还像回事🤪:

下来整理一下形成配置结束~

End

最后源码奉上👇:

DrssXpro/tree-component-demo: vue3 + TS:实现简易 Tree 组件 (github.com)

最近因为写 React 比较多,Vue 有些基本用法都让哥们给忘完了😅,这回正好趁着机会复习一波,一个 Tree 组件最基本的通信和插槽使用都涵盖了,写一遍下来还是挺不错的

相关推荐
gnip41 分钟前
链式调用和延迟执行
前端·javascript
SoaringHeart1 小时前
Flutter组件封装:页面点击事件拦截
前端·flutter
杨天天.1 小时前
小程序原生实现音频播放器,下一首上一首切换,拖动进度条等功能
前端·javascript·小程序·音视频
Dragon Wu1 小时前
React state在setInterval里未获取最新值的问题
前端·javascript·react.js·前端框架
Jinuss1 小时前
Vue3源码reactivity响应式篇之watch实现
前端·vue3
YU大宗师1 小时前
React面试题
前端·javascript·react.js
木兮xg1 小时前
react基础篇
前端·react.js·前端框架
ssshooter2 小时前
你知道怎么用 pnpm 临时给某个库打补丁吗?
前端·面试·npm
IT利刃出鞘2 小时前
HTML--最简的二级菜单页面
前端·html
yume_sibai2 小时前
HTML HTML基础(4)
前端·html