你需要更优雅的 Dialog/Modal 使用姿势!弹窗组件如何实现业务自驱!
你好,我是泰罗凹凸曼,弹窗组件是我们平时开发中使用的最多的组件之一,那么如何更优雅的使用弹窗组件,怎么样封装弹窗组件才更利于业务,怎么样让代码结构变得更清晰,已经是一个势在必行的问题了。
想得多不如做的多,做得多不如看得多,我的思路不代表最优秀的设计,仅是我日常工作中的一些缩影,希望可以帮助到你!
# 常见的弹窗
鉴于 Vue 的状态驱动的模型,我们的大部分弹窗都是状态驱动的,这是很完美的,也是很舒服的一种使用方式,但是我们很容易就会写出类似于下面的代码:
<template>
<button @click="openDialog"></button>
<some-dialog v-model:visible="showDialog" @success="handleSuccess" @close="closeDialog" />
</template>
<script setup>
const showDialog = ref(false)
function openDialog() {
showDialog.value = true
}
function handleSuccess(value) {
something.value = value
showDialog.value = false
}
function closeDialog() {
showDialog.value = false
}
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
这种写法是没问题的,但是这只是最简单的一种使用方式,基于上面的状态驱动的方式,我们很容易就可以写出很糟糕的代码,看看吧:
<template>
<button @click="openDialogA"></button>
<button @click="openDialogB"></button>
<button @click="openDialogC"></button>
<button @click="openDialogD"></button>
<button @click="openDialogE"></button>
<some-dialog-a v-model:visible="showDialogA" @success="handleSuccessA" @close="closeDialogA" />
<some-dialog-b v-model:visible="showDialogB" @success="handleSuccessB" @close="closeDialogB" />
<some-dialog-c v-model:visible="showDialogC" @success="handleSuccessC" @close="closeDialogC" />
<some-dialog-d v-model:visible="showDialogD" @success="handleSuccessD" @close="closeDialogD" />
<some-dialog-e v-model:visible="showDialogE" @success="handleSuccessE" @close="closeDialogE" />
</template>
<script setup>
const showDialogA = ref(false)
function openDialogA() {...}
function handleSuccessA(value) {...}
function closeDialogA() {...}
const showDialogB = ref(false)
function openDialogB() {...}
function handleSuccessB(value) {...}
function closeDialogB() {...}
// ...
</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
这样的代码,我们很容易就会发现,我们的代码逻辑就变得异常的复杂,这样的代码,如果有人要维护,那么就会变得异常的痛苦,因为代码的耦合度太高了,代码的可维护性就变得异常的差。
# 业务耦合
我们再看看,如果我们的业务逻辑变得更复杂的话,我们的代码会变成什么样子呢?
<template>
<button @click="openDialog"></button>
<some-dialog v-model:visible="showDialog" :isEdit="isEdit" :isCreate="isCreate" :isDetail="isDetail" @success="handleSuccess" @close="closeDialog" />
</template>
<script setup>
const showDialog = ref(false)
const isEdit = ref(false)
const isCreate = ref(false)
const isDetail = ref(false)
function openDialog() {
showDialog.value = true
isEdit.value = true
isCreate.value = false
isDetail.value = false
}
function handleSuccess(value) {
something.value = value
showDialog.value = false
}
function closeDialog() {
showDialog.value = false
isEdit.value = false
isCreate.value = false
isDetail.value = false
}
</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
相信诸位应该很容易在公司的业务代码中看到如上的代码片段,一个部分的改动有可能牵扯到全局的改动,大家可以想一想,如果,我又需要在弹窗中加一个编辑功能 isEdit
,我需要修改的内容到底有多少。
如果再夹杂一下其他的业务逻辑的话,那么这些弹窗的事件和状态将让整个逻辑变得异常臃肿!
所以我们就从组件使用者的角度出发,看看如何才能更优雅的去封装组件,我们今天的目标是:消除逻辑耦合,组件业务自驱!
# 从需求出发
"能力在修炼在产出结果之后,思维的修炼在产出结果之前",我们先准备一个简单的小需求,一步步带大家了解业务的复杂度是如何一步一步提高的。
我们先来看看,我们的需求是什么样子的?
- 准备一个学生名单,存在三个按钮,编辑,详情,删除
- 再准备一个新增学生的按钮
- 点击新增按钮。弹出新增学生的弹窗
- 点击编辑按钮,弹出新增学生的弹窗,不过当前的状态是编辑状态
- 点击详情按钮,弹出学生信息的弹出,就是新增弹窗的复用,内部的字段 disabled 或者是将表单字段替换为纯文本字段
- 点击删除按钮,弹出删除确认的弹窗,需要输入删除原因
相信大家做这个需求还是游刃有余的,但是随着需求的越来越多,我们来看看具体会变成什么样子!
特意为大家准备了小需求的实现代码:戳这里 (opens new window)
我们先看最基本的一个版本,也是状态驱动最常见的一个版本!我删除了一些不必要的代码,只保留了核心的代码,完整的代码可以戳这里 (opens new window)
<script setup lang="ts">
const studentList = ref<Student[]>(mockStudent)
const showDetail = ref(false)
const isForDetail = ref(false)
const editStudent = ref<Student>()
const toEdit = (stu?: Student, isDetail = false) => {
showDetail.value = true
editStudent.value = stu
isForDetail.value = isDetail
}
const handleStudentSave = (stu: Student) => {
// 如果 editStudent 中的 id 不为空,则为编辑,否则为新增
if (stu.id != null) {
const existIndex = studentList.value.findIndex(es => es.id === stu.id)
if (existIndex >= 0) {
studentList.value[existIndex] = {
...stu,
}
}
}
else {
studentList.value.push({
...stu,
id: studentList.value.length,
})
}
}
</script>
<template>
<div class="student-list">
<el-button type="primary" @click="toEdit()">
新增学生
</el-button>
<div class="list-wrap">
<div v-for="stu in studentList" :key="stu.id" class="list-item">
<div>{{ stu.name }}</div>
<div>{{ stu.age }}</div>
<div>{{ stu.gender === 'F' ? '女' : '男' }}</div>
<div>
<el-button type="primary" size="small" @click="toEdit(stu)">
编辑
</el-button>
<el-button type="info" size="small" @click="toEdit(stu, true)">
详情
</el-button>
<el-button type="danger" size="small">
删除
</el-button>
</div>
</div>
</div>
<StudentDetail v-model="showDetail" :edit-student="editStudent" :is-for-detail="isForDetail" @save="handleStudentSave" />
</div>
</template>
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
这是这个需求最最最基础的版本,我们还没有添加众多的迭代,那么一个详情弹窗对我们代码的侵入性有多强?
- 我们添加了
showDetail
控制弹窗的显隐 - 我们添加了
toEdit
来实现打开弹窗,控制其是新增,编辑或者详情 - 我们添加了
handleStudentSave
方法来处理弹窗中的返回值 - 我们还添加了
isForDetail
来控制是不是显示详情而非编辑 - ...
这一系列的状态变更,仅仅是我们需要一个学生的编辑弹窗,如果,我需要该页面再添加一个新增班级的弹窗呢?再添加一个新增老师的弹窗呢?复杂的业务级需求在实际工作中并不少见!
到如此,我们还没有实现删除功能,也就是说,我的状态又要多加一层!
可预见的是,当需求越来越复杂的时候,不可避免的我们的代码中会出现各种各样的状态声明,如 showXXX
、 handleXXX
、 toXXX
、 isXXX
,难道没办法了吗?当然有的!
# useDialog
我们知道,在 Vue3 里面,可以使用 Composition API 来完成组件逻辑的封装,使用方式也类似于 React hooks,所以一般来说,我一般喜欢称之为 Vue hooks,如何使用 hooks 来封装弹窗逻辑?
简单来讲,我们学生弹窗的业务属于学生弹窗的部分,不属于列表的业务需求,那这么多和列表页面耦合的状态我们能忍吗,当然不能忍!
hooks 在拆分业务逻辑上是有非常好的优势的,我们来看看如果使用了 hooks 的话,我们的代码会变成什么样子!
我们将业务逻辑一股脑的交给 useStudentDetail
去处理:
<script setup lang="ts">
const {
showDetail,
isForDetail,
editStudent,
toEdit,
handleStudentSave,
} = useStudentDetail(studentList)
</script>
<template>
<StudentDetail
v-model="showDetail"
:edit-student="editStudent"
:is-for-detail="isForDetail"
@save="handleStudentSave"
/>
</template>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
还是有点累赘,如果我在组件调用的时候需要知道 useStudentDialog
中的每一个参数与其含义,那么对使用者来说,是一种重大的心理负担,项目如果按照这种模式发展下去的话,那么就会越来越难以维护!
可不可以在使用 hooks 的时候,不需要知道每一个参数的含义,只需要调用即可:
如这样的形式呢?
<script setup lang="ts">
const { component: StudentDetail, toEdit } = useStudentDetail(studentList)
</script>
<template>
<StudentDetail />
</template>
2
3
4
5
6
7
那是必须可以的!
在我们的 useStudentDetail
中,声明 component
,借助 Vue
提供的 h
函数去渲染主要的弹窗,并代理其 props
:
const component = defineComponent(() => {
return () => h(StudentDetail, {
'modelValue': showDetail.value,
'onUpdate:modelValue': (v: boolean) => (showDetail.value = v),
'isForDetail': isForDetail.value,
'editStudent': editStudent.value,
'onSave': handleStudentSave,
})
})
return {
component,
toEdit,
}
2
3
4
5
6
7
8
9
10
11
12
13
14
如上,我们就可以在组件中使用 component
来渲染弹窗了,而不需要知道 useStudentDetail
中的每一个参数的含义!这里是源码 (opens new window)
不过,我们是不是满足于当前的现状呢?我们还可以做得更好!
# useDialog Promise
如果我们不想显式的导出 toEdit 的方法,且需要完全拆分业务逻辑的话,那么显示学生弹窗、修改信息、应该是属于学生弹窗的业务逻辑,但是保存学生信息,修改列表是列表组件的业务逻辑,那么这两段逻辑需要完全独立
如果不想显式导出 toEdit 方法,我们可以这么做:
const component = defineComponent(() => {
return () => h(StudentDetail, {
'modelValue': showDetail.value,
'onUpdate:modelValue': (v: boolean) => (showDetail.value = v),
'isForDetail': isForDetail.value,
'editStudent': editStudent.value,
'onSave': callback,
})
})
component.toEdit = toEdit
return component
2
3
4
5
6
7
8
9
10
11
12
这样子,我们在使用的时候就可以这样子使用了:
<script setup lang="ts">
const StudentDetail = useStudentDetail(studentList)
StudentDetail.toEdit(stu, true)
</script>
<template>
<StudentDetail />
</template>
2
3
4
5
6
7
8
9
callback 从什么地方来呢?我们可以直接在 toEdit 方法中返回一个 Promise,用来回调修改后的学生信息,并且将保存学生的业务逻辑交给列表组件来处理:
const toEdit = (stu?: Student, isDetail = false): Promise<Student> => {
showDetail.value = true
editStudent.value = stu
isForDetail.value = isDetail
return new Promise((resolve) => {
callback = resolve
})
}
2
3
4
5
6
7
8
9
这样子,我们在列表组件中定义的 toEdit 函数用来完成调用弹窗和保存信息的作用:
async function toEdit(stu?: Student, isDetail = false) {
const editedStudent = await StudentDetail.toEdit(stu, isDetail)
// 如果 editStudent 中的 id 不为空,则为编辑,否则为新增
...
}
2
3
4
5
这也是 @antfu (opens new window) 大佬开源的 vue-template-promise (opens new window) 中的思想,本段源码可以戳这里 (opens new window)
如果,我并不想导入任何 hooks, 也不想植入组件到当前页面,我想一句话直接使用,如 StudentDetail.toEdit({ ... })
,如何做?
# ElementUI 是如何实现的
在 Element 中,我们经常会这样子使用弹窗组件:
import { ElMessageBox, ElMessage, ElNotification } from 'element-plus'
ElMessageBox.confirm('Are you sure to close this dialog?')
ElMessage({
type: 'info',
message: `action`,
})
ElNotification({
title: 'Warning',
message: 'This is a warning message',
type: 'warning',
})
2
3
4
5
6
7
8
9
10
11
12
13
14
如果我想直接在页面中导入 StudentDetail,然后如下使用:
import StudentDetail from '@/components/student-detail'
StudentDetail.toEdit(stu, true)
2
3
我们来看看 ElementUI 是如何实现的!
在 ElementUI 源码的这个位置 (opens new window),我们可以看到:
- 创建了一个
vnode
对象,将MessageBox
组件挂载到vnode
上 - 将
vnode
挂载到body
或者指定的dom
上
可以简单理解的是,将我们的弹窗组件挂载到 vnode
上,然后将 vnode
挂载到 body
上,这样子就可以直接使用了!
我们尝试修改一下这个 toEdit
方法,让其可以自动实现挂载和卸载:
function toEdit(stu?: Student, isDetail?: boolean, appContext?: AppContext): Promise<Student> {
return new Promise((resolve) => {
const vnode = h(StudentDetail, {
onSave(editStu: Student) {
resolve(editStu)
},
})
vnode.appContext = appContext!
const container = document.createElement('div')
// 进行渲染
render(vnode, container)
vnode.component?.exposed?.open(stu, isDetail)
})
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
接着我们要修改一下 StudentDetail
组件,让其可以接受 open
方法:
const emit = defineEmits(['save'])
const showDialog = ref(false)
const student = ref<Student>({})
const isForDetail = ref(false)
function saveStudent() {
emit('save', student.value)
showDialog.value = false
}
defineExpose({
open: (stu: Student, isDetail = false) => {
showDialog.value = true
student.value = { ...stu }
isForDetail.value = isDetail
},
})
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
我们把原属于 StudentDialog
的内容完全交于组件本身进行管理,将编辑后的内容,如编辑学生或者新增学生的操作交于外部组件进行处理,这样子就完全实现了业务自驱!
怎么使用呢?
// 在列表组件中定义方法,用于打开弹窗,编辑或新增等操作完全交给弹窗自己处理,处理后的学生信息由列表组件自己处理
async function toEdit(stu?: Student, isDetail = false) {
const editedStudent = await StudentDetail.toEdit(stu, isDetail, instance!.appContext)
// 如果 editStudent 中的 id 不为空,则为编辑,否则为新增
...
}
2
3
4
5
6
当然,缺点也很明显!我们需要在调用的时候传递 instance!.appContext
给组件,是因为我们使用了 vnode
去挂载组件的话,组件相当于重新创建了一个 Vue
实例,这样子会导致我们在组件内部的直接使用的 ElementUI 组件无法被识别,需要解决这个问题,我们一般有两种解决方案:
- 传递本组件的
appContext
给子组件,让子组件使用appContext
来创建Vue
实例 - 在子组件内部手动挂载使用到的相关组件
Element 的实现是兼容以上两种模式的,可以传递 appContext
或者不传递,感兴趣的同学可以自己了解一下!
# 如何实现业务自驱
相信诸位看过上面的描述之后已经有了自己的答案!
我们认为,各组件的业务除非是强关联性,否则其业务都应该实现业务自驱,也就是说,组件内部应该实现自己的状态管理,而不是依赖于外部的状态管理,这样子的好处是:
- 更容易的使用一个组件,只需要了解基本的功能而不用摸清楚其每个参数的含义
- 一次定义,多处使用,因为其相对来说使用更加简单
此定义不适用于基础组件,如 Button
, Input
等,因为这些组件的功能是相对简单的,而且其功能也不是强关联性的,所以我们可以将其作为基础组件,而不是业务组件,业务组件都是具备一定的关联性的,如 StudentDetail
, StudentList
等,我们的目的就是解耦这些关联性,使其变得单一并好用!
借助于上方的 Dialog
示例,我们可以为弹窗抽离出一个简单好用的方法,这就是我们解决强关联组件的方法之一,这不代表着唯一答案,而是一种思路,我们可以根据自己的实际情况去选择合适的方案!
# 命令驱动 or 状态驱动 ?
状态驱动是 Vue
的优势之一,但如果项目变得很大,一个组件内的状态会随着需求越加越多,到最后就会变成 "屎山",这是我们对一个复杂难以维护的代码做出的一种评价,业内对于状态驱动或者命令驱动的讨论也是经久不衰,我们也不必须做更多的讨论,具体可以看看这里:https://www.zhihu.com/question/35820643/answer/64646527 (opens new window)
我们拆分独立业务,做到业务自驱的方法是一直在践行着命令驱动,但这一定不是最优解,你需要不断地去探索,去追求!
# 结语
今天带大家了解了一下如何做到 Dialog/Modal 组件的业务自驱,讲的也不是很全面,希望能帮助到你,如果你有更好的解决方案,欢迎在评论区留言!
我们在工作的时候会遇到各种各样的问题,但是我希望大家可以静下心来,磨刀不误砍柴工,当一个项目已经进行到难以维护的地步的时候,想再做优化就为时已晚,继续加油吧!
去探索,不知道的东西还多着呢,我是泰罗凹凸曼,M78 星云最爱写代码的,我们下一篇再会!