【Vue】动态组件与递归组件(demo实现)

最近看 iview 实战上的讲解很有感悟,于是学习整理了一下,也算加深自己的理解。

动态组件

有的时候,我们希望根据一些条件,动态地切换某个组件,或动态地选择渲染某个组件。它是一个没有上下文的函数,常用于程序化地在多个组件中选择一个。使用 Render 或 Functional Render 可以解决动态切换组件的需求,不过那是基于一个 JS 对象(Render 函数),而 Vue.js 提供了另外一个内置的组件 <component> 和 is 特性,可以更好地实现动态组件。

先来看一个 <component> 和 is 的基本示例,首先定义三个普通组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<!-- a.vue -->
<template>
<div>组件 A</div>
</template>
<script>
export default {};
</script>
<!-- b.vue -->
<template>
<div>组件 B</div>
</template>
<script>
export default {};
</script>
<!-- c.vue -->
<template>
<div>组件 C</div>
</template>
<script>
export default {};
</script>

然后在父组件中导入这 3 个组件,并动态切换:

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
<template>
<div>
<button @click="handleChange('A')">显示 A 组件</button>
<button @click="handleChange('B')">显示 B 组件</button>
<button @click="handleChange('C')">显示 C 组件</button>

<component :is="component"></component>
</div>
</template>
<script>
import componentA from '../components/a.vue';
import componentB from '../components/b.vue';
import componentC from '../components/c.vue';

export default {
data() {
return {
component: componentA,
};
},
methods: {
handleChange(component) {
if (component === 'A') {
this.component = componentA;
} else if (component === 'B') {
this.component = componentB;
} else if (component === 'C') {
this.component = componentC;
}
},
},
};
</script>

这里的 is 动态绑定的是一个组件对象(Object),它直接指向 a / b / c 三个组件中的一个。除了直接绑定一个 Object,还可以是一个 String,比如标签名、组件名。下面的这个组件,将原生的按钮 button 进行了封装,如果传入了 prop: to,那它会渲染为一个 <a> 标签,用于打开这个链接地址,如果没有传入 to,就当作普通 button 使用。来看下面的示例:

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
34
35
36
37
38
39
<!-- button.vue -->
<template>
<component :is="tagName" v-bind="tagProps"> <slot></slot> </component>
</template>
<script>
export default {
props: {
// 链接地址
to: {
type: String,
default: '',
},
// 链接打开方式,如 _blank
target: {
type: String,
default: '_self',
},
},
computed: {
// 动态渲染不同的标签
tagName() {
return this.to === '' ? 'button' : 'a';
},
// 如果是链接,把这些属性都绑定在 component 上
tagProps() {
let props = {};

if (this.to) {
props = {
target: this.target,
href: this.to,
};
}

return props;
},
},
};
</script>

使用组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<template>
<div>
<i-button>普通按钮</i-button>
<br />
<i-button to="https://juejin.im">链接按钮</i-button>
<br />
<i-button to="https://juejin.im" target="_blank">新窗口打开链接按钮</i-button>
</div>
</template>
<script>
import iButton from '../components/a.vue';

export default {
components: { iButton },
};
</script>

最终会渲染出一个原生的 <button> 按钮和两个原生的链接 <a>,且第二个点击会在新窗口中打开链接,如图:

使用组件

i-button 组件中的 <component> is 绑定的就是一个标签名称 button / a,并且通过 v-bind 将一些额外的属性全部绑定到了 <component> 上。

再回到第一个 a / b / c 组件切换的示例,如果这类的组件,频繁切换,事实上组件是会重新渲染的,比如我们在组件 A 里加两个生命周期:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!-- a.vue -->
<template>
<div>组件 A</div>
</template>
<script>
export default {
mounted() {
console.log('组件创建了');
},
beforeDestroy() {
console.log('组件销毁了');
},
};
</script>

只要切换到 A 组件,mounted 就会触发一次,切换到其它组件,beforeDestroy 也会触发一次,说明组件再重新渲染,这样有可能导致性能问题。为了避免组件的重复渲染,可以在 <component> 外层套一个 Vue.js 内置的 <keep-alive> 组件,这样,组件就会被缓存起来:

1
<keep-alive> <component :is="component"></component> </keep-alive>

这时,只有 mounted 触发了,如果不离开当前页面,切换到其它组件,beforeDestroy 不会被触发,说明组件已经被缓存了。

keep-alive 还有一些额外的 props 可以配置:

  • include:字符串或正则表达式。只有名称匹配的组件会被缓存
  • exclude:字符串或正则表达式。任何名称匹配的组件都不会被缓存
  • max:数字。最多可以缓存多少组件实例

小结

还有一类是异步组件,Vue.js 文档已经介绍的很清楚了。事实上异步组件我们用的很多,比如 router 的配置列表,一般都是用的异步组件形式:

1
2
3
4
{
path: '/form',
component: () => import('./views/form.vue')
}

这样每个页面才会在路由到时才加载对应的 JS 文件,否则入口文件会非常庞大。

递归组件、动态组件和异步组件是 Vue.js 中相对冷门的 3 种组件模式,不过在封装复杂的独立组件时,前两者会经常使用。

递归组件

递归组件就是指组件在模版中调用自己,开启递归组件的必要条件,就是在组件中设置一个 name 选项,比如下面示例:

1
2
3
4
5
6
7
8
<template>
<div><my-component></my-component></div>
</template>
<script>
export default {
name: 'my-component',
};
</script>

在 Webpack 中导入一个 Vue.js 组件,一般是通过 import myComponent from ‘xxx’ 这样的语法,然后在当前组件(页面)的 components: { myComponent } 里注册组件。这种组件是不强制设置 name 字段的,组件的名字都是使用者在 import 进来后自定义的,但递归组件的使用者是组件自身,它得知道这个组件叫什么,因为没有用 components 注册,所以 name 字段就是必须的了。除了递归组件用 name,我们在之前的小节也介绍过,用一些特殊的方法,通过遍历匹配组件的 name 选项来寻找组件实例。

不过呢,上面的示例是有问题的,如果直接运行,会抛出 max stack size exceeded 的错误,因为组件会无限递归下去,死循环。解决这个问题,就要给递归组件一个限制条件,一般会在递归组件上用 v-if 在某个地方设置为 false 来终结。比如我们给上面的示例加一个属性 count,当大于 5 时就不再递归:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<template>
<div><my-component :count="count + 1" v-if="count <= 5"></my-component></div>
</template>
<script>
export default {
name: 'my-component',
props: {
count: {
type: Number,
default: 1,
},
},
};
</script>

小结

实现一个递归组件的必要条件是:

  • 要给组件设置 name;
  • 要有一个明确的结束条件

递归组件常用来开发具有未知层级关系的独立组件,在业务开发中很少使用。比如常见的有级联选择器和树形控件。这类组件一般都是数据驱动型的,父级有一个字段 children,然后递归。

实战:树形控件——Tree

Tree 组件是递归类组件的典型代表,它常用于文件夹、组织架构、生物分类、国家地区等等,世间万物的大多数结构都是树形结构。使用树控件可以完整展现其中的层级关系,并具有展开收起选择等交互功能。

本节要实现的 Tree 组件具有以下功能:

树形控件

  • 节点可以无限延伸(递归);
  • 可以展开 / 收起子节点;
  • 节点可以选中,选中父节点,它的所有子节点也全部被选中,同样,反选父节点,其所有子节点也取消选择;
  • 同一级所有子节点选中时,它的父级也自动选中,一直递归判断到根节点。

API

Tree 是典型的数据驱动型组件,所以节点的配置就是一个 data,里面描述了所有节点的信息,比如图片中的示例数据为:

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
data: [
{
title: 'parent 1',
expand: true,
children: [
{
title: 'parent 1-1',
expand: true,
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',
},
],
},
],
},
];

每个节点的配置(props:data)描述如下:

  • title:节点标题(本例为纯文本输出,可参考 Table 的 Render 或 slot-scope 将其扩展);
  • expand:是否展开直子节点。开启后,其直属子节点将展开;
  • checked:是否选中该节点。开启后,该节点的 Checkbox 将选中;
  • children:子节点属性数组。

如果一个节点没有 children 字段,那它就是最后一个节点,这也是递归组件终结的判断依据。

同时再提供一个是否显示多选框的 props:showCheckbox,以及两个 events:

  • on-toggle-expand:展开和收起子列表时触发;
  • on-check-change:点击复选框时触发。

因为是数据驱动,组件的 API 都比较简单,这一点跟 Table 组件是一样的,它们复杂的逻辑都在组件本身。

入口 tree.vue

在 src/components 中新建目录 tree,并在 tree 下创建两个组件 tree.vue 和 node.vue。tree.vue 是组件的入口,用于接收和处理数据,并将数据传递给 node.vue;node.vue 就是一个递归组件,它构成了每一个节点,即一个可展开 / 关闭的按钮(+或-)、一个多选框(使用第 7 节的 Checkbox 组件)、节点标题以及递归的下一级节点。可能现在听起来比较困惑,不要慌,下面逐一分解。

tree.vue 主要负责两件事:

定义了组件的入口,即组件的 API;
对接收的数据 props:data 初步处理,为了在 tree.vue 中不破坏使用者传递的源数据 data,所以会克隆一份数据(cloneData)。
因为传递的数据 data 是一个复杂的数组结构,克隆它要使用深拷贝,因为浅拷贝数据仍然是引用关系,会破坏源数据。所以在工具集 src/utils/assist.js 中新加一个深拷贝的工具函数 deepCopy:

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
34
35
36
37
38
39
40
41
42
43
// assist.js,部分代码省略
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)];
}
// deepCopy
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 { deepCopy };

deepCopy 函数会递归地对数组或对象进行逐一判断,如果某项是数组或对象,再拆分继续判断,而其它类型就直接赋值了,所以深拷贝的数据不会破坏原有的数据(更多深拷贝与浅拷贝的内容,可阅读扩展阅读 1)。

先来看 tree.vue 的代码:

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
<!-- src/components/tree/tree.vue -->
<template>
<div>
<tree-node
v-for="(item, index) in cloneData"
:key="index"
:data="item"
:show-checkbox="showCheckbox"
></tree-node>
</div>
</template>
<script>
import TreeNode from './node.vue';
import { deepCopy } from '../../utils/assist.js';

export default {
name: 'Tree',
components: { TreeNode },
props: {
data: {
type: Array,
default() {
return [];
},
},
showCheckbox: {
type: Boolean,
default: false,
},
},
data() {
return {
cloneData: [],
};
},
created() {
this.rebuildData();
},
watch: {
data() {
this.rebuildData();
},
},
methods: {
rebuildData() {
this.cloneData = deepCopy(this.data);
},
},
};
</script>

在组件 created 时(以及 watch 监听 data 改变时),调用了 rebuildData 方法克隆源数据,并赋值给了 cloneData。

在 template 中,先是渲染了一个 node.vue 组件(<tree-node>),这一级是 Tree 的根节点,因为 cloneDate 是一个数组,所以这个根节点不一定只有一项,有可能是并列的多项。不过这里使用的 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。

递归组件 node.vue

node.vue 是树组件 Tree 的核心,而一个 tree-node 节点包含 4 个部分:

  • 展开与关闭的按钮(+或-);
  • 多选框;
  • 节点标题;
  • 递归子节点。

先来看 node.vue 的基本结构:

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
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
<!-- src/components/tree/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>
<i-checkbox v-if="showCheckbox" :value="data.checked" @input="handleCheck"></i-checkbox>
<span>{{ data.title }}</span>
<tree-node
v-if="data.expand"
v-for="(item, index) in data.children"
:key="index"
:data="item"
:show-checkbox="showCheckbox"
></tree-node>
</li>
</ul>
</template>
<script>
import iCheckbox from '../checkbox/checkbox.vue';

export default {
name: 'TreeNode',
components: { iCheckbox },
props: {
data: {
type: Object,
default() {
return {};
},
},
showCheckbox: {
type: Boolean,
default: false,
},
},
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>
<style>
.tree-ul,
.tree-li {
list-style: none;
padding-left: 10px;
}
.tree-expand {
cursor: pointer;
}
</style>

props:data 包含了当前节点的所有信息,比如是否展开子节点(expand)、是否选中(checked)、子节点数据(children)等。

第一部分 expand,如果当前节点不含有子节点,也就是没有 children 字段或 children 的长度是 0,那就说明当前节点已经是最后一级节点,所以不含有展开 / 收起的按钮。

多选框直接使用了第 7 节的 Checkbox 组件(单用模式),这里将 prop: value 和事件 @input 分开绑定,没有使用 v-model 语法糖。value 绑定的数据 data.checked 表示当前节点是否选中,在点击多选框时,handleCheck 方法会修改 data.checked 数据,下文会分析。这里之所以不使用 v-model 而是分开绑定,是因为 @input 里要额外做一些处理,不是单纯的修改数据。

上一节我们说到,一个 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(上文已介绍):

1
2
3
4
5
6
7
8
// tree.vue,部分代码省略
export default {
methods: {
emitEvent(eventName, data) {
this.$emit(eventName, data, this.cloneData);
},
},
};

在 node.vue 中,通过 findComponentUpward 向上找到了 Tree 的实例,并调用它的 emitEvent 方法来触发自定义事件 @on-toggle-expand。之所以使用 findComponentUpward 寻找组件,而不是用 \$parent,是因为当前的 node.vue,它的父级不一定就是 tree.vue,因为它是递归组件,父级有可能还是自己。

这里有一点需要注意,修改 data.expand,我们是通过 Vue 的 \$set 方法来修改,并没有像下面这样修改:

1
this.data.expand = !this.data.expand;

这样有什么区别呢?如果直接用上面这行代码修改,发现数据虽然被修改了,但是视图并没有更新(原来是 + 号,点击后还是 + 号)。要理解这里,我们先看下,到底修改了什么。这里的 this.data,是一个 props,它是通过上一级传递的,这个上一级有两种可能,一种是递归的 node.vue,一种是根组件 tree.vue,但是递归的 node.vue,最终也是由 tree.vue 传递的,追根溯源,要修改的 this.data 事实上是 tree.vue 的 cloneData。cloneData 里的节点数据,是不一定含有 expand 或 checked 字段的,如果不含有,直接通过 this.data.expand 修改,这个 expand 就不是可响应的数据,Vue.js 是无法追踪到它的变化,视图自然不会更新,而 $set 的用法就是对可响应对象中添加一个属性,并确保这个新属性同样是响应式的,且触发视图更新。总结来说,如果 expand 字段一开始是存在的(不管 true 或 false),不管用哪种方式修改都是可以的,否则必须用 $set 修改,结合起来,干脆直接用 \$set 了。同理,后文的 checked 也是一样。

接下来是整个 Tree 组件最复杂的一部分,就是处理节点的响应状态。你可能会问,不就是选中或取消选中吗,跟 expand 一样,修改数据就行了?如果只是考虑一个节点,的确这样就可以了,但树组件是有上下级关系的,它们分为两种逻辑,当选中(或取消选中)一个节点时:

  1. 它下面的所有子节点都会被选中;
  2. 如果同一级所有子节点选中时,它的父级也自动选中,一直递归判断到根节点。

第 1 个逻辑相对简单,当选中一个节点时,只要递归地遍历它下面所属的所有子节点数据,修改所有的 checked 字段即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 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);
});
}
},
},
};

updateTreeDown 只是向下修改了所有的数据,因为当前节点的数据里,是包含其所有子节点数据的,通过递归遍历可以轻松修改,这也是第 1 种逻辑简单的原因。

再来看第 2 个逻辑,它的难点在于,无法通过当前节点数据,修改到它的父节点,因为拿不到。写到这里,正常的思路应该是在 this.updateTreeDown(this.data, checked); 的下面再写一个 updateTreeUp 的方法,向上遍历,问题就是,怎样向上遍历,一种常规的思路是,把 updateTreeUp 方法写在 tree.vue 里,并且在 node.vue 的 handleCheck 方法里,通过 this.tree 调用根组件的 updateTreeUp,并且传递当前节点的数据,在 tree.vue 里,要找到当前节点的位置,那还需要一开始在 cloneData 里预先给每个节点设置一个唯一的 key,后面的逻辑读者应该能想到了,就是通过 key 找到节点位置,并向上递归判断……但是,这个方法想着就麻烦。

正常的思路不太好解决,我们就换个思路。一个节点,除了手动选中(或反选),还有就是第 2 种逻辑的被动选中(或反选),也就是说,如果这个节点的所有直属子节点(就是它的第一级子节点)都选中(或反选)时,这个节点就自动被选中(或反选),递归地,可以一级一级响应上去。有了这个思路,我们就可以通过 watch 来监听当前节点的子节点是否都选中,进而修改当前的 checked 字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 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,
},
},
};

在 watch 中,监听了 data.children 的改变,并且是深度监听的。这段代码的意思是,当 data.children 中的数据的某个字段发生变化时(这里当然是指 checked 字段),也就是说它的某个子节点被选中(或反选)了,这时执行绑定的句柄 handler 中的逻辑。const checkedAll = !data.some(item => !item.checked); 也是一个巧妙的缩写,checkedAll 最终返回结果就是当前子节点是否都被选中了。

这里非常巧妙地利用了递归的特性,因为 node.vue 是一个递归组件,那每一个组件里都会有 watch 监听 data.children,要知道,当前的节点有两个”身份“,它既是下属节点的父节点,同时也是上级节点的子节点,它作为下属节点的父节点被修改的同时,也会触发上级节点中的 watch 监听函数。这就是递归。

Author: Fridolph
Link: http://blog.fridolph.wang/2019/01/13/【Vue】动态组件与递归组件(demo实现)/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.