这是我参与「第五届青训营」伴学笔记创作活动的第 14 天
Tree 树形控件
Tree组件算是在众多组件中比较复杂的组件了,也是为数不多会用到算法的组件。在实际开发时,我们可以把组件分成一个个模块来开发,保证耦合性。
首先对于一个树形控件,我们可以先看最基本的构造。
首先对于这么一棵树,我们可以把他拆分为根结点,结点内容,生成子节点三大部分。
可能有人要问了,生成子节点是啥?
其实我们树这个结构天然需要借助递归来生成,因此在这里我们需要用组件递归的方式来生成组件。就类似我们写递归函数的时候,我们必然会定义一个递归函数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组件的大概开发思路。 最后附上完整代码地址:
如果觉得有帮助的话,请给我们项目点个star吧。