# 三.数据输出(树)
前言 --> 树组件特点
- 下拉菜单组件应该由两部分组成:
- 选中项的文本
- 待选菜单(默认隐藏)
- 它的主要功能包括:
- 节点可以无限延伸(递归)
- 可以展开/收起子节点
- 节点可以选中,选中父节点,它的所有子节点也全部被选中,同样,反选父节点,其所有子节点也取消选择
- 同一级所有子节点选中时,它的父级也自动选中,一直递归判断到根节点
# 1.目录结构
├── tree
│ ├── tree-node-content.vue
│ ├── tree-node.vue
│ ├── tree.vue
│ └── index.js
2
3
4
5
Tree 是典型的数据驱动型组件,所以节点的配置就是一个 data,里面描述了所有节点的信息
data: [
{
title: "parent 1", // 节点的标题
expand: true, // 是否展开子节点。开启后,其直属子节点将展开
children: [
// 子节点属性数组
{
title: "parent 1-1",
expand: true,
checked: true, //是否选中该节点。开启后,该节点的 Checkbox 将选中
children: [
{
title: "leaf 1-1-1",
},
{
title: "leaf 1-1-2",
},
],
},
{
title: "parent 1-2",
children: [
{
title: "leaf 1-2-1",
},
{
title: "leaf 1-2-1",
},
],
},
],
},
]
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
如果一个节点的没有 children 字段,那它就是最后一个节点,这也是递归组件终结的判断依据
同时再提供一个是否显示多个选框的 props:showCheckbox,以及两个 events
- on-toggle-expand:展开和收起子列表时触发
- on-check-change:点击复选框时触发
# 2.组件封装
# 2.1 tree.vue
在src/components
中新建目录tree
,并在 tree 下创建两个组件tree.vue
和node.vue
。tree.vue 是组件的入口,用于接收和处理数据,并将数据传递给 node.vue;node.vue 就是一个递归组件,它构成了每一个节点,即一个可展开/关闭的按钮(+或-)、一个多选框、节点标题以及递归的下一级节点。
tree.vue 主要负责两件事:
- 1.定义了组件的入口,即组件的 API
- 2.对接收的数据 props:data 初步处理,为了在 tree.vue 中不破坏使用者传递的源数据 data,所以克隆了一份数据
先来看 tree.vue 的代码
<template>
<div v-if="cloneData.length">
<tree-node
v-for="(item, index) in cloneData"
:key="index"
:data="item"
:show-checkbox="showCheckbox"
/>
</div>
</template>
<script>
import TreeNode from "./node.vue";
function typeOf(obj) {
const toString = Object.prototype.toString;
const map = {
"[object Boolean]": "boolean",
"[object Number]": "number",
"[object String]": "string",
"[object Function]": "function",
"[object Array]": "array",
"[object Date]": "date",
"[object RegExp]": "regExp",
"[object Undefined]": "undefined",
"[object Null]": "null",
"[object Object]": "object",
};
return map[toString.call(obj)];
}
function deepCopy(data) {
const t = typeOf(data);
let o;
if (t === "array") {
o = [];
} else if (t === "object") {
o = {};
} else {
return data;
}
if (t === "array") {
for (let i = 0; i < data.length; i++) {
o.push(deepCopy(data[i]));
}
} else if (t === "object") {
for (let i in data) {
o[i] = deepCopy(data[i]);
}
}
return o;
}
export default {
name: "VueTree",
components: { TreeNode },
props: {
data: {
type: Array,
default() {
return [];
},
},
showCheckbox: {
type: Boolean,
default: false,
},
},
data() {
return {
cloneData: [],
};
},
created() {
this.getData();
},
watch: {
data() {
this.getData();
},
},
methods: {
getData() {
this.cloneData = deepCopy(this.data);
},
emitEvent(data) {
this.$emit("change", data);
},
},
};
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
在组件 created 时(以及 watch 监听 data 改变时,)调用了rebuildData
方法克隆源数据,并赋值给了cloneData
在 template 中,先是渲染了一个 node.vue 组件(tree-node
),这一级是 Tree 的根节点,因为 cloneData 是一个数组,所以这个根节点不一定只有一项,有肯能是并列的多项。不过这里使用的 node.vue 还没有用到 Vue.js 的递归调用,它只处理第一级根节点。
<tree-node>组件(node.vue)接受两个props
:
- 1.showCheckbox:与 tree.vue 的 showCheckbox 相同,只是进行传递;
- 2.data:node.vue 接收的 data 是一个 Object 而非 Array,因为它只负责渲染当前的一个节点,并递归渲染下一个子节点(即 children),所以这里对 cloneData 进行循环,将每一项节点数据赋给了 tree-node。
# 2.2 node.vue
node.vue 是树组件 Tree 的核心,而一个 tree-node 节点包含 4 个部分:
- 1.展开与关闭的按钮
- 2.多选框
- 3.节点标题
- 4.递归子节点
先来看 node.vue 的基本结构
<template>
<ul class="tree-ul">
<li class="tree-li">
<span class="tree-expand" @click="handleExpand">
<span v-if="data.children && data.children.length && !data.expand">{{
"+"
}}</span>
<span v-if="data.children && data.children.length && data.expand">{{
"-"
}}</span>
</span>
<el-checkbox
v-if="showCheckbox"
:value="data.checked"
@input="handleCheck"
/>
<span>{{ data.title }}</span>
<template v-if="data.expand && data.children.length">
<tree-node
v-for="(item, index) in data.children"
:key="index"
:data="item"
:show-checkbox="showCheckbox"
/>
</template>
</li>
</ul>
</template>
<script>
function findComponentUpward(context, componentName) {
let parent = context.$parent;
let name = parent.$options.name;
while (parent && (!name || [componentName].indexOf(name) < 0)) {
parent = parent.$parent;
if (parent) name = parent.$options.name;
}
return parent;
}
export default {
name: "TreeNode",
props: {
data: {
type: Object,
default: () => ({}),
},
showCheckbox: {
type: Boolean,
default: false,
},
},
data() {
return {
tree: findComponentUpward(this, "VueTree"),
};
},
methods: {
handleExpand() {
this.$set(this.data, "expand", !this.data.expand);
if (this.tree) {
this.tree.emitEvent("on-toggle-expand", this.data);
}
},
},
};
</script>
<style>
.tree-ul .tree-li {
list-style: none;
padding-left: 10px;
}
.tree-expand {
cursor: pointer;
}
</style>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
props:data
包含了当前节点的所有信息,比如是否展开子节点(expand),是否选中(checked),子节点数据(children)等。
第一部分 expand,如果当前节点不含有子节点,也就是没有 children 字段或 children 的长度为 0,那就说明当前节点已经是最后一级节点,所以不含有展开/收起的按钮。
上一节我们说到,一个 Vue.js 递归组件有两个必要条件:name 特性和终结条件。name 已经指定为 TreeNode
,而这个终结递归的条件,就是 v-for="(item, index) in data.children"
,当 data.children
不存在或为空数组时,自然就不会继续渲染子节点,递归也就停止了。
注意,这里的 v-if="data.expand"
并不是递归组件的终结条件,虽然它看起来像是一个可以为 false
的判断语句,但它的用处是判断当前节点的子节点是否展开(渲染),如果当前节点不展开,那它所有的子节点也就不会展开(渲染)。
上面的代码保留了两个方法 handleExpand
与 handleCheck
,先来看前者。
点击 + 号时,会展开直属子节点,点击 - 号关闭,这一步只需在 handleExpand
中修改 data 的 expand 数据即可,同时,我们通过 Tree 的根组件(tree.vue)触发一个自定义事件 @on-toggle-expand
(上文已介绍):
<script>
// node.vue,部分代码省略
import { findComponentUpward } from "../../utils/assist.js"
export default {
data() {
return {
tree: findComponentUpward(this, "Tree"),
}
},
methods: {
handleExpand() {
this.$set(this.data, "expand", !this.data.expand)
if (this.tree) {
this.tree.emitEvent("on-toggle-expand", this.data)
}
},
},
}
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<script>
// tree.vue,部分代码省略
export default {
methods: {
emitEvent(eventName, data) {
this.$emit(eventName, data, this.cloneData)
},
},
}
</script>
2
3
4
5
6
7
8
9
10
在 node.vue 中,通过findComponentUpward
向上找到 Tree 的实例,并调用它的emitEvent
方法来触发自定义事件@on-toggle-expand
。之所以使用findComponentUpward
寻找组件,而不是用$parent
,是因为当前的 node.vue,它的父级不一定就是 tree.vue,因为它是递归组件,父级有可能还是自己。
这里有一点需要注意,修改 data.expand,我们是通过Vue
的$set
方法来修改,并没有像下面这样修改:
this.data.expand = !this.data.expand
当选中(或取消选中)一个节点时:
- 它下面的所有子节点都会被选中;
- 如果同一级所有子节点选中时,它的父级也自动选中,一直递归判断到根节点。
第 1 个逻辑相对简单,当选中一个节点时,只要递归地遍历它下面所属的所有子节点数据,修改所有的 checked
字段即可:
// node.vue,部分代码省略
export default {
methods: {
handleCheck(checked) {
this.updateTreeDown(this.data, checked)
if (this.tree) {
this.tree.emitEvent("on-check-change", this.data)
}
},
updateTreeDown(data, checked) {
this.$set(data, "checked", checked)
if (data.children && data.children.length) {
data.children.forEach((item) => {
this.updateTreeDown(item, checked)
})
}
},
},
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
一个节点,除了手动选中(或反选),还有就是第 2 种逻辑的被动选中(或反选),也就是说,如果这个节点的所有直属子节点(就是它的第一级子节点)都选中(或反选)时,这个节点就自动被选中(或反选),递归地,可以一级一级响应上去。有了这个思路,我们就可以通过 watch
来监听当前节点的子节点是否都选中,进而修改当前的 checked
字段:
// node.vue,部分代码省略
export default {
watch: {
"data.children": {
handler(data) {
if (data) {
const checkedAll = !data.some((item) => !item.checked)
this.$set(this.data, "checked", checkedAll)
}
},
deep: true,
},
},
}
2
3
4
5
6
7
8
9
10
11
12
13
14
在 watch 中,监听了 data.children 的改变,并且是深度监听的。这段代码的意思是,当 data.children
中的数据的某个字段发生变化时(这里当然是指 checked 字段),也就是说它的某个子节点被选中(或反选)了,这时执行绑定的句柄 handler 中的逻辑。const checkedAll = !data.some(item => !item.checked);
也是一个巧妙的缩写,checkedAll 最终返回结果就是当前子节点是否都被选中了。
这里非常巧妙地利用了递归的特性,因为 node.vue 是一个递归组件,那每一个组件里都会有 watch 监听 data.children
,要知道,当前的节点有两个”身份“,它既是下属节点的父节点,同时也是上级节点的子节点,它作为下属节点的父节点被修改的同时,也会触发上级节点中的 watch 监听函数。这就是递归。
# 3.使用案例
总结
通过对前端组件的分析,需要重点关注组件中易变性对组件封装的影响,它会对组件的可复用性、可扩展性产生很大影响