组件库开发——Tree树形控件 | 青训营笔记

805 阅读6分钟

这是我参与「第五届青训营」伴学笔记创作活动的第 14 天

Tree 树形控件

Tree组件算是在众多组件中比较复杂的组件了,也是为数不多会用到算法的组件。在实际开发时,我们可以把组件分成一个个模块来开发,保证耦合性。

首先对于一个树形控件,我们可以先看最基本的构造。

image.png

首先对于这么一棵树,我们可以把他拆分为根结点,结点内容,生成子节点三大部分。

可能有人要问了,生成子节点是啥?

其实我们树这个结构天然需要借助递归来生成,因此在这里我们需要用组件递归的方式来生成组件。就类似我们写递归函数的时候,我们必然会定义一个递归函数dfs,并且我们还会在main中调用dfs。

function dfs() {  // 生成子节点
}

main() {
    dfs()  // 根结点
}

好了大概内容介绍到这里,接下来我们还是按照标准流程来做组件开发

接口

首先定义接口,同样是用props获取。

import { ExtractPropTypes } from "vue";

export const TreeProps = {
  data: {    // 暴露出去,给用户传入数据
    type: Array,
    default: () => [],
  },
  label: {  // 解构data后的标签名
    type: String,
    default: "label",
  },
  children: {  // 子节点数量
    type: String,
    default: "children",
  },
  showCheckbox: {  // 是否有复选框
    type: Boolean,
    default: false,
  },
  nodeKey: {    // 结点的键值,可以简单认为是唯一id
    type: String,
    default: "",
  },
  defaultExpandedKeys: {     // api 默认展开项
    type: Array,
    default: () => [],
  },
  defaultCheckedKeys: {     // api 默认选中项
    type: Array,
    default: () => [],
  },
  defaultExpandAll: {        // api 默认展开所有
    type: Boolean,
    default: false,
  },
  renderContent: {      // 自定义渲染函数,暴露出去的接口,让用户自己渲染数据
    type: Function,
  },
};

export type TreeProps = ExtractPropTypes<typeof TreeProps>;

开发组件

根结点

首先我们需要定义根节点,类似于调用一个递归函数,我们在调用递归组件也是同样的操作,直接把递归组件挂上去.

<template>
  <div class="h-tree">
    <tree-node>
    </tree-node>
  </div>
</template>

同理我们也需要把我们的props.data作为参数传给递归组件,但是先别急,我们获取到的data是在props的,但是我们在调用的时候难免会修改到数据,因此我们需要先对data进行一个深拷贝,深拷贝的原因是因为props是单向数据流,我们是不能修改的。

onMounted(() => {
  initFn(deepCopy(props.data));
});

我们接着对数据进行加工,这个操作是获取到其它的信息(如是否展开结点等)和解耦数据

const copyData = ref([]);
const checkedKeys = ref(props.defaultCheckedKeys);

const initFn = (data: any) => {
  // data经过深拷贝后再使用,不会影响外界传入的数据.
  copyData.value = data.map((item: any) => {
    item.children = item[props.children] || [];
    item.label = item[props.label];
    item.id = item[props.nodeKey];
    item.isOpen = false;
    item.isChecked = false;
    if (item.children && item.children.length) {
      initFn(item.children);
    }
    return {
      id: item.id,
      label: item.label,
      children: item.children,
      isOpen: item.isOpen,
      isChecked: item.isChecked,
      disabled: item.disabled,
    };
  });
};

ok,根结点的基本操作已经完成下面我们来解决生成结点

那么我们就可以把我们的新数据传入其中,然后来递归生成结点啦。

<template>
  <div class="h-tree">
    <tree-node
      v-for="(item, index) in copyData"
      :key="index"
      :items="item"
      :label="label"
      :children="children"
      :show-checkbox="showCheckbox"
      :index="0"
      :node-key="nodeKey"
      :default-expanded-keys="defaultExpandedKeys"
      :default-checked-keys="checkedKeys"
      :default-expand-all="defaultExpandAll"
      :render-content="renderContent"
      :parent-data="copyData">
    </tree-node>
  </div>
</template>

生成结点

接下来这个就是这个结点组件的内容,在后面我们可以往里面加复选框等东西,在这里我们先把基础的弄好,首先我们需要绑定一个点击事件,来判断点击展开组件,因为我们有render的接口,所以nodeContent就是我们封装的一个结点内容,这个暂时不说。

<template>
  <ul class="h-tree-node" :style="nodeStyle">
    <div :class="['h-tree-node__content']" @click.stop="handleToggle(items)">
      <span :class="expandIconClass">
        <i class="h-icon-you1"></i>
      </span>
      <nodeContent :data="items" :render-content="renderContent" :parent-data="parentData" />
    </div>
  </ul>
</template>

同时,作为递归函数,我们需要传入接收刚刚父组件的参数,同样是用props接收

const props = defineProps({
  items: {
    type: Object,
    default: () => {},
  },
  label: String,
  children: String,
  showCheckbox: Boolean,
  index: Number,
  nodeKey: String,
  // 默认展开项
  defaultExpandedKeys: Array,
  // 默认选中项
  defaultCheckedKeys: Array,
  // 默认展开所有
  defaultExpandAll: Boolean,
  renderContent: Function,
  parentData: Array,
});

接下来就是重点了,我们要实现一下组件递归,调用递归函数所以我们需要在template重新调用自己。

<div class="h-tree-ul-box" v-if="isShow" v-show="items.isOpen">
    <tree-node
      v-for="(i, j) in items.children"
      :key="j"
      :items="i"
      :label="label"
      :children="children"
      :show-checkbox="showCheckbox"
      :index="index && index + 1"
      :node-key="nodeKey"
      :default-expanded-keys="defaultExpandedKeys"
      :default-checked-keys="defaultCheckedKeys"
      :default-expand-all="defaultExpandAll"
      :render-content="renderContent"
      :parent-data="items.children">
    </tree-node>
</div>

同时我们需要去判断,不能让组件一直递归下去,因此我们加上判断,如果改结点存在子节点才能继续递归, 具体实现就是用v-if来判断即可。

const isShow = computed(() => {
  return props.items.children && props.items.children.length;
});

那么我们整的一个template就是这样

<template>
  <ul class="h-tree-node" :style="nodeStyle">
    <div :class="['h-tree-node__content']" @click.stop="handleToggle(items)">
      <span :class="expandIconClass">
        <i class="h-icon-you1"></i>
      </span>
      <nodeContent :data="items" :render-content="renderContent" :parent-data="parentData" />
    </div>
      <div class="h-tree-ul-box" v-if="isShow" v-show="items.isOpen">
        <tree-node
          v-for="(i, j) in items.children"
          :key="j"
          :items="i"
          :label="label"
          :children="children"
          :show-checkbox="showCheckbox"
          :index="index && index + 1"
          :node-key="nodeKey"
          :default-expanded-keys="defaultExpandedKeys"
          :default-checked-keys="defaultCheckedKeys"
          :default-expand-all="defaultExpandAll"
          :render-content="renderContent"
          :parent-data="items.children">
        </tree-node>
      </div>
  </ul>
</template>

首先我们来实现一下点击事件,判断展开,是不是很容易?

const handleToggle = (item: any) => {
  item.isOpen = !item.isOpen;
  // 展开/收起子节点时触发
};

此时我们已经完成了tree的基本功能,最后我们只需要补充一下nodeContent

结点内容

结点内容其实非常简单,我们只需要接收结点的数据,用虚拟结点的方式返回即可。

<script lang="ts">
import { h, toRefs, reactive } from "vue";
export default {
  props: {
    data: {
      type: Object,
      required: true,
    },
    renderContent: Function,
    parentData: Array,
  },
  setup(props) {
    const { data, renderContent, parentData } = toRefs(props);
    const nodeData = reactive({
      data: data.value,
      parentData: parentData.value,
    });
    return () => [renderContent.value ? renderContent.value(h, nodeData) : h("span", data.value.label)];
  },
};
</script>

完善Tree组件

看到上面的template其实我们还有很多内容没有完成,接下来我们补充其它功能

处理默认展开,选中参数功能

很简单,只需要在每个结点中,加上一个初始化判断即可,具体的就是每个结点在创建的过程,去遍历传进来的defaultExpandedKeys,如果有结点被设置为默认展开就把这个结点的open标记为true

onMounted(() => {
  _initDefault();
});

// 初始化默认的展开项和选中项
const _initDefault = () => {
  let { items, nodeKey, defaultExpandedKeys, defaultCheckedKeys, defaultExpandAll } = props;
  const nodeKeyValue = nodeKey && items[nodeKey];
  const isExpand = defaultExpandedKeys!.includes(nodeKeyValue) || defaultExpandAll;
  const isChecked = defaultCheckedKeys!.includes(nodeKeyValue);
  items.isOpen = isExpand;
  items.isChecked = isChecked;
};

增加复选框

<template>
  <ul class="h-tree-node" :style="nodeStyle">
    <div :class="['h-tree-node__content']" @click.stop="handleToggle(items)">
      <span :class="expandIconClass">
        <i class="h-icon-you1"></i>
      </span>
      <h-checkbox
        v-if="showCheckbox"
        style="margin-right: 0"
        v-model="items!.isChecked"
        :indeterminate="items.indeterminate"
        :disabled="items.disabled"
        @change="handleCheckChange">
      </h-checkbox>
      <nodeContent :data="items" :render-content="renderContent" :parent-data="parentData" />
    </div>
      <div class="h-tree-ul-box" v-if="isShow" v-show="items.isOpen">
        <tree-node
          v-for="(i, j) in items.children"
          :key="j"
          :items="i"
          :label="label"
          :children="children"
          :show-checkbox="showCheckbox"
          :index="index && index + 1"
          :node-key="nodeKey"
          :default-expanded-keys="defaultExpandedKeys"
          :default-checked-keys="defaultCheckedKeys"
          :default-expand-all="defaultExpandAll"
          :render-content="renderContent"
          :parent-data="items.children">
        </tree-node>
      </div>
  </ul>
</template>

复选框只需要在模板中增加即可,这里的复选框是已经封装好的组件,如果有兴趣可以看这里

首先我们在checkbox上绑定一个点击事件,对于这个结点的点击操作,我们需要处理这么几个情况。

1.该结点的状态需要发生变化,并且其字结点也需要全部变化,我们需要递归改变该子树上结点的状态。

// 选中一个节点时,递归地遍历下面所属的所有子节点
const updateChildChecked = (item: any, val: any) => {
  item.isChecked = val;
  if (item.children && item.children.length) {
    item.children.forEach((el: any) => {
      updateChildChecked(el, val);
    });
  }
};

2.同时父级的状态需要根据子节点是否全选,等发生变化

因此,这里就需要做一个回调,通过组件间父子通信去改变父节点的状态,在这里我直接从根节点开始,用provide,把这个函数注入给全部的子节点,子节点用inject去接收即可,并且要注意一点,我们需要维持结点展开与否,所以也需要做一个回调注入给子组件。

// 父节点中
const checkboxChange = () => {
  updateChecked(copyData.value);
};

const checkedChange = (data: any) => {
  const checkedNodes = getCheckedNodes();
  emits("checked-change", checkedNodes, data);
};

provide("checkboxChange", checkboxChange);  // 复选框变化
provide("checked-change", checkedChange);   // 结点的孩子展开状态

// 子有一个选中,父为半选
// 子全选中,父为全选
// 子一个都没选中,父不选
const updateChecked = (data: any) => {
  data.forEach((item: any) => {
    let checked;
    let indeterminate;
    let checkedNodes;
    if (item.children && item.children.length) {
      updateChecked(item.children);
      const children = item.children;
      // 过滤出选中的
      checkedNodes = children.filter((child: any) => child.isChecked);
      if (checkedNodes.length === 0) {
        checked = false;
        indeterminate = false;
      } else if (checkedNodes.length === children.length) {
        checked = true;
        indeterminate = false;
      } else {
        checked = false;
        indeterminate = true;
      }
      item.isChecked = checked;
      item.indeterminate = indeterminate;
    }
  });
};


// 子结点中
通过该
const checkboxChange = inject<() => void>("checkboxChange", () => {});
const checkedChange = inject<(param: any) => void>("checked-change", () => {});
const updateParentChecked = () => {
  nextTick(() => {
    checkboxChange && checkboxChange();
  });
};

最后调用我们只需要在点击复选框时,把这些全部事件绑定上去即可。

const handleCheckChange = (val: any) => {
  updateChildChecked(props.items, val); // 设置子级
  updateParentChecked(); // 设置父级

  checkedChange(props.items);
};

暴露接口获取内容

这个内容就是简单的把我们已经选中的数据去遍历一遍即可。

// 对外暴露,通过传入keys数组设置选中
const setCheckedKeys = (keys: any) => {
  if (!props.showCheckbox) return;
  checkedKeys.value = keys;
};

// 对外暴露,获取选中项的keys数组
const getCheckedKeys = () => {
  return getCheckedNodes().map((ele: any) => ele.id);
};

// 对外暴露,获取选中项的数据数组
const getCheckedNodes = () => {
  const checkedNodes: Object[] = [];
  const traverse = function (copyData: any) {
    copyData.forEach((item: any) => {
      if (item.isChecked) {
        checkedNodes.push({
          id: item.id,
          label: item.label,
          children: item.children ? item.children : [],
        });
      }
      if (item.children && item.children.length) {
        traverse(item.children);
      }
    });
  };

  traverse(copyData.value);

  return checkedNodes;
};

defineExpose({
  setCheckedKeys,
  getCheckedKeys,
  getCheckedNodes,
});

总结:

到此,整个组件就完成了。这就是整个Tree组件的大概开发思路。 最后附上完整代码地址:

github项目代码

Tree组件演示效果

如果觉得有帮助的话,请给我们项目点个star吧。