Vue3 + Ts 开发一个 Tree 组件

897 阅读3分钟

目前效果

tree-08.gif

使用组件

<z-tree
  :data="data"
  key-field="keyName"
  label-field="labelName"
  children-field="childrenName"
  show-checkbox
  :default-checked-keys="['1', '2']"
></z-tree>

<script lang="ts" setup>
// 先生成数据
function createData(level = 1, parentLabel = ''): TreeOption[] {
  if (level > 4) return [];
  const arr = Array<number>(10).fill(0);
  return arr.map((item, index) => {
    // 当前的label 等于 父级的 label + index
    const label = parentLabel
      ? `${parentLabel}-${index + 1}`
      : String(index + 1);
    return {
      keyName: label,
      labelName: label,
      childrenName: createData(level + 1, label),
    };
  });
}
const data = ref(createData());
</script>

功能展示

1、展开图标添加旋转功能

// // 切换展开和收起状态
function expand(node: TreeNode) {
  expandedKeysSet.value.add(node.key);
  // 展开的时候调用异步加载的逻辑
  triggerLoading(node);
}

我们来看一下现在的效果

tree-01.gif

2、实现树的异步加载

因为是异步加载的,用一个变量来维护正在加载的节点的keys

// 4. 实现调用用户传递的异步加载函数
// 用来装正在加载节点的 key
const loadingKeysRef = ref(new Set<Key>());

// 如果传递了 onload 异步加载函数
function triggerLoading(node: TreeNode) {
  // 判断当前点击的 node 节点是否可以调用异步加载函数
  if (!node.children.length && !node.isLeaf) {
    const loadingKeys = loadingKeysRef.value;
    // 没有加载的时候,再调用加载函数
    if (!loadingKeys.has(node.key)) {
      loadingKeys.add(node.key);
      const onLoad = props.onLoad;
      if (onLoad) {
        onLoad(node.rawNode).then((children) => {
          // 把用户传递进来的孩子保存在 原始数据的位置
          node.rawNode.children = children;
          // 重新构造树新的节点
          node.children = createTree(children, node);
          loadingKeys.delete(node.key);
        });
      }
    }
  }
}

tree-02.gif

3、实现树节点的选择

实现tree节点的单选和多选功能

tree-03.gif

4、实现节点的禁用效果

5、实现自定义节点

关键代码

tree.vue

// 把tree组件的slot提供出去,方便后代组件使用
provide(treeInjectKey, {
  slots: useSlots(),
});

tree-node-content.tsx

import { defineComponent, inject, PropType } from 'vue';
import { TreeNode, treeInjectKey } from './tree';
export default defineComponent({
  name: 'z-tree-node-content',
  props: {
    node: {
      type: Object as PropType<TreeNode>,
      required: true,
    },
  },
  setup(props) {
    // 拿到tree组件的 slot 内容将数据回传给 外部的 v-slot
    const treeContext = inject(treeInjectKey);
    return () => {
      return treeContext?.slots.default
        ? treeContext.slots.default({ node: props.node })
        : props.node.label;
    };
  },
});

6、实现树的虚拟滚动

实现虚拟滚动首先就需要把tree型结构扁平化,通过监听滚动条滚动的位置,计算出当前要扁平化之后数组的区间,来动态更新树结构。

// 采用深度优先遍历
const flattenTree = computed(() => {
  const expandedKeys = expandedKeysSet.value;
  const result: TreeNode[] = [];
  const stack: TreeNode[] = [];
  for (let i = tree.value.length - 1; i >= 0; i--) {
    stack.push(tree.value[i]);
  }
  while (stack.length) {
    const topNode: TreeNode | undefined = stack.pop();
    if (!topNode) continue;
    result.push(topNode);
    // 判断当前的 key在不在 选中的里面
    if (expandedKeys.has(topNode.key)) {
      // 如果在里面就把儿子也加入到 栈 里面
      // 从后面往前面 压入栈中,取出来的时候就是反的。
      for (let i = topNode.children.length - 1; i >= 0; i--) {
        stack.push(topNode.children[i]);
      }
    }
  }
  return result;
});

tree-04.gif

上面实现了初步的虚拟滚动效果,根据滚动的scrollTop动态计算出当前要显示的数据,从而实现了虚拟滚动,下面,在此基础上进行优化,让上边和下边多显示几条,这样就不会出现,如果快速滚动下面有白屏的现象。

如下图所示:

image.png

7、实现checkbox组件

tree-07.gif

8、实现tree组件的级联选择

1. 实现自顶向下选中

// 自顶向下更新
function toggle(node: TreeNode, checked: boolean) {
  if (!node) return;
  // 如果是选中就添加,否则删除
  const checkedKeys = checkedKeysRef.value;
  if (checked) {
    checkedKeys.add(node.key);
    indeterminateRef.value.delete(node.key);
  } else {
    checkedKeys.delete(node.key);
  }
  // 检测是否有儿子,如果有则递归更新
  const children = node.children;
  if (children && children.length) {
    children.forEach((childNode) => {
      toggle(childNode, checked);
    });
  }
}

tree-05.gif

2. 实现自底向上选中

// 自下向上更新  目的是为了控制父节点的 选中 和 半选 (重要)
function updateCheckKeys(node: TreeNode) {
  // 判断有没有父节点
  if (node.parentKey) {
    const parentNode = fineNode(node.parentKey);
    if (parentNode) {
      let allChecked = true;
      let hasChecked = false;
      const nodes = parentNode.children;
      for (const node of nodes) {
        // 如果选中的容器里面有当前节点,则说明子节点有选中的
        if (checkedKeysRef.value.has(node.key)) {
          hasChecked = true;
        }
        // 如果 半选的容器中有当前节点,则说明当前子节点半选, 其父节点就不能是选中的
        else if (indeterminateRef.value.has(node.key)) {
          allChecked = false;
          hasChecked = true;
        } else {
          // 半选和选中的容器里面都没有,则父节点什么也没选中
          allChecked = false;
        }
      }
      // 如果子节点全部选中了,则就把父节点也添加到选中的容器中去,并从半选的容器中移除掉
      if (allChecked) {
        checkedKeysRef.value.add(parentNode.key);
        indeterminateRef.value.delete(parentNode.key);
      } else if (hasChecked) {
        // 如果子节点有选中的,则说明父节点需要半选,把父节点加入到半选容器中,并从选中容器中删除
        checkedKeysRef.value.delete(parentNode.key);
        indeterminateRef.value.add(parentNode.key);
      }
      updateCheckKeys(parentNode);
    }
  }
}

tree-06.gif

最终代码

本来是想着把使用pnpm搭建monorepo的过程,以及实现bem规范的的过程都记录下来形成文字版的呢,后面有时间的话在写把。先放到git仓库里面了。如果感兴趣可以查看仓库。

image.png

js??运算符

developer.mozilla.org/zh-CN/docs/…

js?.运算符