Vuetify 系列 - 对 Dialog 函数的封装

概述

众所周知,Vuetify 提供了 Dialog、Snackbar 等的提示组件,但是他并没有像 Element 那样提供全局或局部的相关调用函数,比如 showDialog,所以要实现这个需求,得自己去封装一个函数。

功能设计

作为一个通用函数,在功能上肯定是越泛用越好,但又不能把所有的参数都交由开发者去调,这样就失去封装的意义。

描述一个 Dialog

首先考虑 Dialog 是什么和为什么需要它,Dialog 可以是提示框,让用户知道某个操作有风险,也可以是选择框,让用户选择是否接受条款,还可以是输入框,让用户提交自己的信息。

从功能上可以分成这三类,但这还是很抽象,输入框提交哪些数据?选择框的选项有哪些?提示框的内容样式有没有要求?如果要用一个对象来描述 Dialog,很难做到这么详细,如果将每一项需求都作为一个属性存入对象,那么这个对象将会很复杂,光是属性名就得记半天。

所以应该从 “Dialog 是怎么样的” 这个问题入手去考虑,常见的 Dialog,它在布局上可以分成 3 块,从上往下分别是标题栏、内容栏和动作栏,标题栏就是展示标题字符串的,内容栏不确定,动作栏是一组按钮,数量不确定。那么根据这些信息可以构建出如下的配置:

1
2
3
4
{
title: String,
btns: Array
}

按钮数量不定,但是样式是类似的,只是文字、颜色、动作不一样,所以按钮可以使用如下对象描述:

1
2
3
4
5
{
text: String,
color: String,
closable: Boolean
}

之所以不把按钮的动作放入对象中,是因为动作中会涉及到上下文,而在对象中不好指定上下文,所以只提供一个 closable 来控制按下后是否关闭 Dialog,为了提高灵活性,我会在按钮按下后通过 $emit 将自定义事件传递给调用 showDialog 的组件,所以还需要一个属性去记录调用 Dialog 的组件的上下文

现在的问题就在内容栏了, 要如何去描述”提示框”、”输入框”和”选择框”呢?从最简单的开始,提示框只是显示文字的,可以直接用一个字符串属性去描述,剩下的两种类型的具体表现是不确定的,所以可以将它们看成是组件,动态地将组件显示出来,通过 component 标签实现。

实际上动态渲染组件这种方式是万能的,甚至”提示框”都能通过这种方式实现,但是提示框的功能太简单了,没必要单独弄个组件去显示(这样反而更加复杂),所以可以将配置项独立出来,如果要显示复杂样式的提示框,也能使用动态渲染去实现。

现在确定的配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
title: String,
btns: Array,
context: VueComponent,
text?: String,
component?: VueComponent,
btns: [
{
text: String,
color: String,
closable: Boolean
}
]
}

储存 Dialog 的状态

由于 Dialog 是全局的,所以这个状态应该存放在 Vuex 中,showDialogcloseDialog 作为两个 Mutation,通过 showDialog 将配置放入 state.dialog 对象,调用 closeDialog 之后将 state.dialog 内容清空。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
showDialog (state, payload) {
state.dialog = {
title: '',
persistent: false,
btns: [
{ id: 0, color: 'success', text: 'OK', closable: true }
],
...payload
}
}

closeDialog (state) {
state.dialog = {}
}

为什么关闭 Dialog 后不把 state.dialog 设置为 null 呢?原因在于使用 null 之后,Dialog 组件中的相关取值处需要做多一次 null 判断,比如说 dialog && dialog.title 否则就容易出错,而当 dialog 为空对象时,取一个不存在的属性,返回的是 undefined,并不会报错。

确定 Dialog 的结构

根据刚刚提到的 Dialog 的三个部分 ——— 标题栏、内容栏和动作栏,可以确定一个大致的结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<v-dialog
v-model="shouldShowDialog"
@update:returnValue="closeDialog"
>
<v-card>
<v-card-title class="headline" v-if="dialog.title">{{ dialog.title }}</v-card-title>
<v-card-text>
<!-- 内容栏 -->
</v-card-text>
<v-card-actions >
<v-spacer></v-spacer>
<!-- 动作栏 -->
</v-card-actions>
</v-card>
</v-dialog>

标题栏是确定的,所以写好了,内容栏得分成两种情况,动作栏需要对 btns 做一个遍历。所以内容栏和动作栏分别可以写成:

1
2
3
4
5
6
7
8
9
10
11
12
<!-- 内容栏 -->
<template v-if="dialog.component">
<component ref="component" :is="dialog.component"></component>
</template>
<template v-else-if="dialog.text">
{{ dialog.text }}
</template>

<!-- 动作栏 -->
<template v-for="(btn, index) in dialog.btns">
<v-btn v-if="btn.text" :color="btn.color" @click="handleClick(btn)" flat :key="index">{{ btn.text }}</v-btn>
</template>

处理 Dialog 的动作

从上面的代码片段可以看出,对按钮动作的处理全靠 handleClick 函数完成,这个函数的具体功能前文已经说过了,在按钮被点击后向调用 Dialog 的组件发一个自定义事件,让那个组件去做处理,接着根据 closable 属性去决定是否关闭 Dialog。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
handleClick (btn) {
if (!this.shouldShowDialog) {
return
}
if (this.dialog.context) {
this.dialog.context.$emit('dialog:click', {
btn,
component: this.$refs.component
})
}
if (btn.closable) {
this.closeDialog()
}
}

传递 Component 的状态

其实这一节应该和上一节合并的,这一节要讲的是获取动态渲染组件的状态,毕竟 Dialog 作为输入框时,就是为了获得输入框中的文字。

showDialog 设置 Vuex 的状态,closeDialog 清空状态,并没有很好的方式去获得自定义组件的内容,所以这里我使用了 ref 去获取组件的实例,直接将实例通过 handleClick 传递给使用 Dialog 的组件,我的想法是”既然调用了 Dialog 去显示自定义组件,那肯定知道自定义组件内哪些属性是需要的,可以自行取用“。

这里可以通过在自定义组件内设置一个函数去搜集状态,比如:

1
2
3
4
5
6
returnValue () {
return {
value: this.value,
name: this.name
}
}

要考虑两种情况,一是如果开发者没注意,漏写了这个函数,那么就无法正确搜集状态,二是函数名,可能与组件内部相关属性冲突,所以我还是选择了直接传递组件实例

完整的 Dialog 组件

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
<template>
<v-dialog
:fullscreen="dialog.fullscreen"
:hide-overlay="dialog.fullscreen"
:transition="dialog.fullscreen && 'dialog-bottom-transition'"
v-model="shouldShowDialog"
@update:returnValue="closeDialog"
max-width="60%"
:persistent="dialog.persistent"
>
<v-card>
<v-card-title class="headline" v-if="dialog.title">{{ dialog.title }}</v-card-title>
<v-card-text>
<template v-if="dialog.component">
<component ref="component" :is="dialog.component"></component>
</template>
<template v-else-if="dialog.text">
{{ dialog.text }}
</template>
</v-card-text>
<v-card-actions >
<v-spacer></v-spacer>
<template v-for="(btn, index) in dialog.btns">
<v-btn v-if="btn.text" :color="btn.color" @click="handleClick(btn)" flat :key="index">{{ btn.text }}</v-btn>
</template>
</v-card-actions>
</v-card>
</v-dialog>
</template>

<script>
import { mapState, mapMutations } from 'vuex'

export default {
computed: {
...mapState(['dialog']),
shouldShowDialog () {
return !!Object.keys(this.dialog).length
}
},
methods: {
...mapMutations(['closeDialog']),
handleClick (btn) {
if (!this.shouldShowDialog) {
return
}
if (this.dialog.context) {
this.dialog.context.$emit('dialog:click', {
btn,
component: this.$refs.component
})
}
if (btn.closable) {
this.closeDialog()
}
}
}
}
</script>