当我们做多级菜单或者权限列表管理的时候,大多会采用树形结构来实现,有的朋友为了省事,不想多费脑力,多费时间,直接几层for循环就做了个差不多的树形组件,更省事的朋友直接拿ElementUI中的Tree树形组件来完成需求,这时,稍微有点要求的产品就来要求了:不行,这组件样式都是竖着的,要是权限列表多了,这还了得?改。
这里笔者仿照ElementUI的风格,自定义了个简单的树形组件,先看样式:
什么是递归组件?简单的说就是组件自己调用自己。有些朋友可能就懵了,哎呦卧槽,组件还能调用自己?这么牛X?对,就是这么牛X。
咱们先不谈如何实现,先看工作中会碰到的一种需求。
看如下示例数据:
list: [
{
id: "1",
label: "一级菜单1",
checked: false,
children: [
{
id: "11",
label: "二级菜单1",
checked: false,
children: [
{
id: "111",
label: "三级菜单1",
checked: false,
},
],
},
{
id: "12",
label: "二级菜单2",
checked: false,
children: [
{
id: "121",
label: "三级菜单1",
checked: false,
},
{
id: "122",
label: "三级菜单2",
checked: false,
},
],
},
],
},
],
按照以上数据来实现视图,很多朋友一看这有啥难度,然后直接上手写代码,最终写了如下代码:
<div class="example">
<div v-for="item in list" :key="item.id" class="example-item">
<el-checkbox v-model="item.checked">{{item.label}}</el-checkbox>
<div v-for="it in item.children" :key="it.id" class="example-item">
<el-checkbox v-model="it.checked">{{it.label}}</el-checkbox>
<div v-for="i in it.children" :key="i.id" class="example-item">
<el-checkbox v-model="it.checked">{{i.label}}</el-checkbox>
</div>
</div>
</div>
</div>
这么简答的需求我要是做不出来,那简直是侮辱我80的智商。这时,有朋友瞪着眼睛多看了两眼,咦?我瞅着怎么这代码结构好像有规律可循?这要是多几层数据,我岂不是要继续重复嵌套下去?咱是有水平的程序员,怎么能总是干这种重复的事情呢?
于是,递归的思想冒出来了,我可以把上述代码的基本结构抽离出来,成一个组件,然后让这个组件嵌套调用自己,这样无论有多少层数据,我都不用担心了。
请看如下:
创建ExmapleItem.vue
<template>
<div class="example-item">
<el-checkbox v-model="item.checked">{{item.label}}</el-checkbox>
<div v-if="item.children">
<example-item v-for="it in item.children" :key="it.id" :item="it"></example-item>
</div>
</div>
</template>
<script>
export default {
name: "example-item",
props:{
item: {
type: Object,
}
}
}
</script>
上述示例代码是递归组件的一个子项,咱们要实现上述list数据内容,还需要另外创建一个组件,在这个组件引入递归组件,这么使用:
<example-item v-for="item in list" :key="item.id" :item="item"></example-item>
以上简单示例就实现了一个简单的递归组件方案,这里需要重点说明的一点就是,在组件内部要加上name名称,该名称写的是什么,递归调用的组件名称就是什么,注意递归组件内部数据的传递参数要保持一致。
上述示例看明白了,递归组件也明白了,那么,就可以进行自定义树形组件的开发工作。
不想看下面内容的朋友,可以直接点此下载完整内容。
新建一个CTree文件夹,在内部创建两个文件index.vue和CTreeItem.vue。
index.vue
<template>
<div class="c-tree-component">
<c-tree-item v-for="item in tree" :key="item.value" :treeObj="item" :size="size"></c-tree-item>
</div>
</template>
<script>
import treeStore from "./tree-store";
import CTreeItem from "./CTreeItem.vue";
export default {
name: "CTree",
data() {
return {
tree: [],
};
},
props: {
data: {
type: Array,
default: [],
},
size: {
type: String,
default: "normal", // small 小
},
mode: {
type: String,
default: "normal", // auth 权限模式,此模式下,子组件被选中,父组件一定被选中
},
expanded: {
type: Boolean,
default: true,
},
},
mounted() {
this.tree = this.initData(this.data);
// 组件事件监听,监听子组件状态变化,获取和更改父组件数据
treeStore.$on("tree-change", (res) => {
// res当前点击项的数据
// 如果当前模式为auth,执行auth模式组件选择方式
if (this.mode === "auth") {
let result = this.reInitParentCheck(this.tree, res.value);
this.tree = result.data;
} else {
// 拿到更新后的部分数据,normal模式下,子组件的check随父组件变化
res = this.updateSonCheck(res, res.checked);
// 更新数据,同步显示组件样式
this.tree = this.updateData(this.tree, res);
// 更新数据,更新所有checkBox状态
this.tree = this.reInitCheck(this.tree);
}
// console.log(this.tree);
// 向外部引用父组件传递选中的value值
let result = this.getAllValue(this.tree);
this.$emit("change", result);
// console.log(result);
});
},
methods: {
/**
* 采用递归方式重新修改data数据
* 方便组件进行选中,展开等操作
*/
initData(e) {
let temp = [];
e.forEach((ele) => {
if (ele.children && ele.children.length > 0) {
ele.children = this.initData(ele.children);
}
temp.push({
...ele,
checked: false, // 是否选中
expanded: this.expanded, // 是否展开
indeterminate: false, // checkBox的中间态
});
});
return temp;
},
/**
* 更新全部数据内容
*/
updateData(e, target) {
let isFind = false;
e.forEach((ele) => {
if (ele.value === target.value) {
ele = target;
isFind = true;
}
});
if (!isFind && e.children && e.children.length > 0) {
e.children = this.updateData(e.children, target);
}
return e;
},
/**
* 更新数据的check状态
* 判断是否有children,如果有,全部更新为父级数据的check状态
*/
updateSonCheck(e, checked) {
if (e.children && e.children.length > 0) {
e.children.forEach((i) => {
i.checked = checked;
if (i.children && i.children.length > 0) {
i = this.updateSonCheck(i, checked);
}
});
}
e.checked = checked;
return e;
},
/**
* 更新全部数据的视图check状态
* none all half
*/
reInitCheck(e) {
e.forEach((ele) => {
if (ele.children && ele.children.length > 0) {
ele.children = this.reInitCheck(ele.children);
let res = "";
res = this.getNodeStatus(ele.children);
if (res === "none") {
ele.checked = false;
ele.indeterminate = false;
} else if (res === "all") {
ele.checked = true;
ele.indeterminate = false;
} else if (res === "half") {
ele.checked = false;
ele.indeterminate = true;
}
}
});
return e;
},
/**
* 更新全部父级数据的视图check状态
* mode为auth模式时,调用此方法
* 根据当前级别所有项check状态更新父组件check状态
* 父组件check不影响子组件,子组件check必定更新父组件check
*/
reInitParentCheck(e, current) {
let isChecked = false;
for (let i = 0; i < e.length; i++) {
let ele = e[i];
// 如果遍历选项与当前操作项的value相等,将所有子项置为同样状态
if (ele.value === current) {
// 更新所有子项数据
ele = this.updateSonCheck(ele, ele.checked);
// 如果当前操作项check为true,将标记isChecked置为true,表示当前级别有选中项
if (ele.checked) {
isChecked = true;
continue;
}
}
if (ele.children && ele.children.length > 0) {
let res = this.reInitParentCheck(ele.children, current);
ele.children = res.data;
ele.checked = res.isChecked;
}
if (ele.checked) {
isChecked = true;
}
}
return {
data: e,
isChecked: isChecked,
};
},
/**
* 获取每一节点的状态,用于更新checkBox中间态
* 如果该节点所有同级checkBox都被选中,返回all
* 0个选中,返回none
* 其余情况返回half
*/
getNodeStatus(e) {
let checkedNum = 0;
e.forEach((ele) => {
if (ele.checked) {
++checkedNum;
}
});
if (checkedNum === 0) {
return "none";
} else if (checkedNum === e.length) {
return "all";
} else {
return "half";
}
},
/**
* 获取所有被选中项的value
*/
getAllValue(e) {
let temp = [];
e.forEach((ele) => {
if (ele.checked) {
temp.push(ele.value);
}
if (ele.children && ele.children.length > 0) {
let res = this.getAllValue(ele.children);
temp = temp.concat(res);
}
});
return temp;
},
},
components: {
CTreeItem,
},
};
</script>
CTreeItem.vue
<template>
<div class="c-tree" @click.stop="_handleClick">
<div class="c-tree-level" :style="{height:size=='small'?'26px':'40px'}">
<i class="el-icon-caret-right c-tree-level-icon"></i>
<el-checkbox @click.native.stop v-model="obj.checked" :indeterminate="obj.indeterminate" @change="_handleChange"></el-checkbox>
<span>{{obj.label}}</span>
</div>
<el-collapse-transition>
<div v-if="obj.children" v-show="obj.expanded" class="c-tree-level-child" :class="{'level-last':!obj.children[0].children}">
<c-tree-item v-for="it in obj.children" :key="it.value" :treeObj="it" :size="size"></c-tree-item>
</div>
</el-collapse-transition>
</div>
</template>
<script>
import treeStore from "./tree-store";
export default {
name: "CTreeItem",
data() {
return {
obj: {
children: [],
checked: false,
expanded: false,
},
};
},
props: {
treeObj: {
type: Object,
},
size: {
type: String,
default: "normal", // small 小
},
},
watch: {
"treeObj.checked": function (e) {
this.obj = this.treeObj;
},
},
created() {
this.obj = this.treeObj;
},
methods: {
/**
* 点击操作,控制展开和收缩,并更新父组件数据
*/
_handleClick() {
// 修改当前点击项的收缩展开状态
this.obj.expanded = !this.obj.expanded;
this._handleChange();
},
/**
* checkBox数据修改监听
* 更新局部组件视图,将更新的组件状态传给父组件
*/
_handleChange() {
this.$nextTick(() => {
treeStore.$emit("tree-change", this.obj);
});
},
},
};
</script>
<style lang="scss" scoped>
.c-tree {
&-level {
display: flex;
align-items: center;
cursor: pointer;
height: 40px;
&:hover,
&.focus {
background-color: #f5f7fa;
}
&.expanded {
.c-tree-level-icon {
transform: rotate(90deg);
}
}
&-icon {
color: #c0c4cc;
font-size: 12px;
transition: 0.3s;
transform: rotate(0deg);
padding: 6px;
}
.el-checkbox {
margin-right: 8px;
}
&-child {
margin-left: 24px;
&.level-last {
display: flex;
align-items: center;
.c-tree-level {
padding-right: 12px;
margin-right: 24px;
&-icon {
color: transparent;
}
}
}
}
}
}
</style>
在递归组件中,寻常使用的this.$emit()就无法生效,我们这里可以采用eventBus方案来实现,有朋友可能疑惑:eventBus是个啥玩意?你就当它是一种解决方案就可以,方法很简单,另外创建一个Vue实例,让emit和on绑定同一个实例就可以在递归组件内部发送事件了。
如上述代码中的tree-store.js.
import Vue from 'vue';
export default new Vue();
外部按照正常组件引用方式,引入index.vue组件就可以。
<c-tree :data="tree" mode="auth" :expanded="true"></c-tree>
本组件提供了两种mode,默认“normal”,checkBox有中间态,子组件不全部选中情况下,父组件check状态为false,“auth”为权限列表选择模式,一旦子组件有选中项,父组件一定为选中状态。
expanded控制是否展开。
size:默认normal,间隔较大,如上图所示,“small”表示各级之间间隔较小,表现形式如同ElementUI中的tree组件。
直接使用本案例的朋友这里要注意一点,外部传入数据可以按照如下方式传入,label表示名称,value表示key或者id等:
示例参数:
tree: [
{
label: "一级1",
value: "level_1",
children: [
{
label: "二级1",
value: "level_1_1",
children: [
{
label: "三级1",
value: "level_1_1_1",
},
{
label: "三级2",
value: "level_1_1_2",
},
],
},
{
label: "二级2",
value: "level_1_2",
children: [
{
label: "三级2-1",
value: "level_1_2_1",
},
{
label: "三级2-2",
value: "level_1_2_2",
},
{
label: "三级2-3",
value: "level_1_2_3",
},
],
},
{
label: "二级3",
value: "level_1_3",
children: [
{
label: "三级3-1",
value: "level_1_3_1",
},
{
label: "三级3-2",
value: "level_1_3_2",
},
{
label: "三级3-3",
value: "level_1_3_3",
},
{
label: "三级3-4",
value: "level_1_3_4",
},
],
},
],
},
{
label: "一级2",
value: "level_2",
children: [
{
label: "二级2-1",
value: "level_2_1",
children: [
{
label: "三级2-1-1",
value: "level_2_1_1",
},
],
},
],
},
],
细心的朋友会发现,上述代码中用到了el-collapse-transition标签,这是ElementUI提供的内置过度动画中的一个标签,用来进行展开折叠操作。
有朋友自己写组件用到了此标签,引用后发现不生效,并没有展开折叠的动效。
解决方法:
1.用一个单独的简单示例操作此标签,查看是否生效,如果生效,说明问题出在你引用此标签的组件内。
2.检查组件的最外层div是否有v-if操作,如果有,干掉,再次检查是否生效。
el-collapse-transition标签的外层,最好不要有v-if操作,尤其是当你采用了局部视图更新方案时,此标签会因为视图更新失效。
点此下完源码资源
欢迎关注本博主:小圣贤君,有问题可以留言哦~