实现:组件封装和高阶组件-弹窗

最近看的组件封装和高阶组件的相关概念,做一下简单的总结。

高阶组件是什么

在说高阶组件之前,有一个概念是高阶函数。
简单地说,高阶函数也就是传入一个函数,最后返回一个新函数。
那么高阶组件,就是函数接收一个组件,返回新的组件,并对组件做一层包装拓展。
在 vue 中,vue 单文件会被转换为对象,template 部分会被转换为对象中的 render 函数。
所以思路就是对该对象进行一些处理。
有点像装饰器模式。

恶心的弹窗组件

每次在写 admin 页面的时候,总是会出现这样的代码,以 elementUI 为例

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
<template>
<el-dialog :visible.sync="visible1" :title="title1">
<my-com />
<div slot="footer">
<el-button>Confirm</el-button>
</div>
</el-dialog>
<el-dialog :visible.sync="visible2" :title="title2">
<my-com />
<div slot="footer">
<el-button>Confirm</el-button>
</div>
</el-dialog>
<el-dialog :visible.sync="visible3" :title="title3">
<my-com />
<div slot="footer">
<el-button>Confirm</el-button>
</div>
</el-dialog>
<el-dialog :visible.sync="visible4" :title="title4">
<my-com />
<div slot="footer">
<el-button>Confirm</el-button>
</div>
</el-dialog>
</template>
<script>
// 我的组件,展示在弹窗内部
import MyCom from 'my-com'
export default {
data() {
return {
// 控制显示隐藏
visible1: false,
visible2: false,
visible3: false,
visible4: false,
// 标题
title1: 'title1',
title2: 'title2',
title3: 'title3',
title4: 'title4'
}
},
}
</script>

这样写非常重复,并且在状态很多的时候,管理起来会很恶心。

visible,title 这一类变量,其实属于弹窗自身的内部属性,如果在代码的其他地方没有使用,那么最好是隐藏在弹窗组件中,没必要暴露出来。

我们希望只关心弹窗内部的组件部分(my-com),而不是每次都做一些繁复的无用的代码拷贝。

组件封装

常见的做法,是将弹窗再封装一层,将 my-com 作为插槽的内容。如:

  • my-dialog.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
<template>
<el-dialog :visible.sync="visible">
<slot />
<div slot="footer">
<el-button>Confirm</el-button>
</div>
</el-dialog>
</template>
<script>
// 我的组件,展示在弹窗内部
import MyCom from 'my-com'
export default {
props: ['visible'],
data() {
return {
visible: true,
}
},
methods: [
open() {
this.visible = true
}
],
}
</script>

使用:

1
2
3
4
5
6
7
8
9
10
11
<template>
<my-dialog ref="dialog">
<my-com>
</my-dialog>
</template>
<script>
import MyCom from 'my-com'
export default {
components: { MyCom }
}
</script>

这时有一个问题,el-dialog 被我们包含在组件内部了,如果要为其添加一些属性怎么办,例如前面的 title。

一种方法是增加 props

  • my-dialog.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<template>
<!-- 增加 title -->
<el-dialog :visible.sync="visible" :title="title">
<slot />
<div slot="footer">
<el-button>Confirm</el-button>
</div>
</el-dialog>
</template>
<script>
export default {
// 增加 title
props: ['visible', 'title'],
}
</script>

缺点是灵活性较差,每新增一个属性就需要改一次组件、增加一个 prop。

更好的办法是使用 $attrs、$props、$listener 等。其中:

  • $attrs 包含了 dom 上所有属性
  • $props 包含了已经在 props 上声明的属性
  • $listeners 包含了所有事件属性

my-dialog.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<template>
<!-- 增加 title -->
<el-dialog :visible.sync="visible" v-bind="$attrs" v-on="$listeners">
<slot />
<div slot="footer">
<el-button>Confirm</el-button>
</div>
</el-dialog>
</template>
<script>
export default {
// 增加 title
props: ['visible'],
}
</script>

如果不想把从父组件传递的参数或事件全都传递给原组件,需要做部分筛选,可以使用计算属性 computed:

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
<template>
<!-- 增加 title -->
<el-dialog :visible.sync="visible" v-bind="dialogAttr" v-on="dialogListeners">
<slot />
<div slot="footer">
<el-button>Confirm</el-button>
</div>
</el-dialog>
</template>
<script>
export default {
// 增加 title
props: ['visible'],
computed: {
dialogProps() {
// 只取 title 绑定到 el-dialog
const { title } = this.$attrs
return {
title,
}
},
dialogListeners() {
// 只取 close 事件绑定到 el-dialog
const { close } = this.$listeners
return {
close,
}
},
},
}
</script>

但还有一种使用场景,我们希望使用起来更加简洁,类似于 elementUI 的 this.$confirm。
如果没有 elementUI, 或者项目中已经有一个实现好的弹窗了,这种情况怎么基于现有组件,实现类似于 this.$confirm 的调用呢。

基于现有弹窗实现 this.$dialog

这种实现形式称为 HOC 高阶组件。
假设我们已经有一个弹窗组件(用 elementUI 的 el-dialog 为例)。

  1. 首先,引入弹窗组件。
1
2
3
// ? !多个实例需要销毁
import { Button as ElButton, Dialog as ElDialog } from 'element-ui'
import Vue from 'vue'
  1. 定义弹窗的几种状态,确定,关闭,和取消。
1
2
3
4
5
6
7
8
9
// 实例
let instance
let promise
// 每种操作对应的结果
const statusMap = {
confirm: 'resolve',
close: 'reject',
cancel: 'reject',
}
  1. 定义一个方法,这个方法接收两个参数:

    • 一是弹窗组件
    • 二是额外配置项

该方法接收弹窗组件,并在其基础上,包裹了一层,添加了新的方法,如 open,cancel 等等。

实现:

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
// 接受一个组件和配置
const hoc = function (com, options) {
return {
name: 'MyDialog',
// 声明子组件
components: { com },
// 显示隐藏
data() {
return { visible: false, loading: false }
},
methods: {
// 打开 ref
open() {
this.visible = true
},
// 关闭 ref
close() {
this.visible = false
promise && promise.reject()
},
// handler 类 confirm
handler() {
this.visible = true
return new Promise((resolve, reject) => (promise = { resolve, reject }))
},
// 处理 确认 or 取消
_action(type, beforeAction) {
// type === 'confirm' && (this.loading = true)
const done = () => {
this._hide(type)
}
// 如果有 before-close | before-confirm, 先执行, 并且必须在方法中调用 done
if (typeof beforeAction === 'function') {
beforeAction(done)
} else {
done()
}
},
// 关闭弹窗
_hide(type) {
this.visible = false
this.loading = false
if (promise) {
promise[statusMap[type]](this.$refs.child)
return
}
// 触发事件
this.$emit(type)
},
},
// 渲染函数
render() {
// ...
}
}
  1. 由于我们没办法在 js 中写 template 标签,这个时候 render 函数就派上用场了。(也可以用 JSX)
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
// 接受一个组件和配置
const hoc = function (com, options) {
return {
name: 'MyDialog',
// 声明子组件
components: { com },
// ...
// ...
// ...
// ...
// 渲染函数
render(h) {
const _this = this
// props
const args = {
props: {
...(options || {}),
...this.$attrs,
visible: this.visible,
},
on: {
...this.$listeners,
'update:visible': (val) => (_this.visible = val), // visible.sync
},
scopedSlots: this.$scopedSlots,
ref: 'dialog',
}
// footer
const footer = h('div', { slot: 'footer' }, [
h(
ElButton,
{
on: {
click: () => _this._action('cancel', args.props['before-cancel']),
},
},
'Cancel'
),
h(
ElButton,
{
props: {
type: 'primary',
loading: _this.loading,
},
on: {
click: () =>
_this._action('confirm', args.props['before-confirm']),
},
},
'Confirm'
),
])
return h(ElDialog, args, [
h(com, {
props: options?.props,
ref: 'child',
}),
footer,
])
},
}
}
  1. 挂载方法,将生成的弹窗挂载到指定的位置,如果没有指定位置,将插入到 body 中。

其中 Vue.extend 方法,会接受一个组件,并返回该组件的构造器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 用于挂载
const dialogService = function (com, options) {
// 如果传入了 props
const DialogConstructor = Vue.extend(hoc.call(this, com, options))
// 新实例
instance = new DialogConstructor()
// 选择挂载的目标 => dom or body
let target = document.body
// 如果是选择器
if (typeof options.el === 'string') {
target = document.querySelector(options.el) || target
}
// 如果是 dom
if (options.el instanceof HTMLElement) {
target = options.el
}
// 挂载
instance.$mount()
target.appendChild(instance.$el)
instance.visible = true
return instance
}
  1. 这里提供两种输出, 一是 MyDialog 组件实例, 二是 $dialog 方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// dialog 服务
const $dialog = function (component, options = {}) {
return dialogService.call(this, component, options)
}

// confirm 方法
$dialog.confirm = function (component, options = {}) {
return $dialog(component, options).handler()
}

// components
const MyDialog = hoc()

/**
* ? export what
* 1. 直接可用的 component
* 2. 生成 component 的函数
* 3. $dialog
*/
export { $dialog, MyDialog }
  1. 最后,提供一个 install 方法,方便用 Vue.use() 安装到全局 Vue 的原型上。
1
2
3
4
5
6
7
//! 使用方式: $dialog 使用需要挂载到 Vue 实例, 以获取 this
export default {
// for register
install(Vue) {
Vue.prototype.$dialog = $dialog
},
}

这样,就可以直接调用 this.$dialog,而不需要在模板中写新元素了。