本文以 Vue3 开发中最常用的第三方组件库的 Element-Plus 为例,向大家介绍在 Vue3 中如何封装一个通用的中后台项目的弹窗。
为什么需要封装弹窗
以 Element-Plus 弹窗最基础的弹窗举例
JS
<template>
<el-button text @click="dialogVisible = true">
click to open the Dialog
</el-button>
<el-dialog
v-model="dialogVisible"
title="Tips"
width="30%"
:before-close="handleClose"
>
<span>This is a message</span>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">Cancel</el-button>
<el-button type="primary" @click="dialogVisible = false">
Confirm
</el-button>
</span>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { ElMessageBox } from 'element-plus'
const dialogVisible = ref(false)
const handleClose = (done: () => void) => {
ElMessageBox.confirm('Are you sure to close this dialog?')
.then(() => {
done()
})
.catch(() => {
// catch error
})
}
</script>
- 开发者建立一个弹窗就需要维护一个 visible 状态去控制弹窗的显示,然后重复的进行状态绑定,较为繁琐
- 中后台项目中弹窗多应用于表单提交以及业务反馈,因此点击事件具备可复用的业务逻辑
- 声明式描述弹窗在会导致逻辑分散化,也就是说导致事件的绑定回调函数东一块,西一块。
引用弹窗的理想方式
声明式构建 dialog 对开发者来说确实不够友好,那么 Element-Plus 没有去解决么?
但事实上,主流的组件库 Element-Plus、Ant-Design 等已经提供了 Message-Box 的组件以及的 Modal confirm 去构建类似的消息提示窗。
JS
<template>
<el-button plain @click="open">Common VNode</el-button>
<el-button plain @click="open1">Dynamic props</el-button>
</template>
<script lang="ts" setup>
import { h, ref } from 'vue'
import { ElMessageBox, ElSwitch } from 'element-plus'
const open = () => {
ElMessageBox({
title: 'Message',
message: h('p', null, [
h('span', null, 'Message can be '),
h('i', { style: 'color: teal' }, 'VNode'),
]),
})
}
const open1 = () => {
const checked = ref<boolean | string | number>(false)
ElMessageBox({
title: 'Message',
// Should pass a function if VNode contains dynamic props
message: () =>
h(ElSwitch, {
modelValue: checked.value,
'onUpdate:modelValue': (val: boolean | string | number) => {
checked.value = val
},
}),
})
}
</script>
JS
import React from 'react';
import { ExclamationCircleOutlined } from '@ant-design/icons';
import { Button, Modal } from 'antd';
const { confirm } = Modal;
const destroyAll = () => {
Modal.destroyAll();
};
const showConfirm = () => {
for (let i = 0; i < 3; i += 1) {
setTimeout(() => {
confirm({
icon: <ExclamationCircleOutlined />,
content: <Button onClick={destroyAll}>Click to destroy all</Button>,
onOk() {
console.log('OK');
},
onCancel() {
console.log('Cancel');
},
});
}, i * 500);
}
};
const App: React.FC = () => <Button onClick={showConfirm}>Confirm</Button>;
export default App;
这也是大部分开发者封装 Dialog 的思路,组件对外暴露的接口就是一个函数,开发者需要自定义内容需要修改传参,同时函数提供传参也提供了足够灵活的方式对外拓展。
但是实际上 Element-Plus 这种方式上在业务开发中并不好用,原因就是协同项目开发中,无论是 Vue 的 h 函数、还是 React 的 createElement ,都不应该作为构建大型组件的方式。
该种方式具备多种缺陷
- 并不具备可读性
- 对 Vue 开发者来说,需要增加协同开发者的学习成本
- 不好维护
- 本末倒置,函数本身就可以通过编译器解析出来,为什么还要让开发者不去写 DSL,而去实现编译后的函数代码
笔者更推崇的是 Ant-Design 的封装方式,但是在公司内部实践中, JSX、TSX 并不是推荐的实现方式,虽然 Vue3 在 JSX 的开发体验已经逐渐接近于模板,但是考虑到性能、协同开发以及目前 Vue 的主流方式仍是模板,编译器无法在同一个文件进行模板 TSX 混淆编译,所以,笔者看来,建立弹窗的最好方式仍是通过模板进行二次封装组件库的弹窗组件,然后进行复用。
如何封装一个好用的中后台 dialog
写代码前,我们考虑下封装目标,就以 confirm 为例,封装目标力求达到 Ant-Design 的 confirm 的同等开发体验
既然使用模板,我们就需要在使用文件模板内部使用弹窗组件,同时为了让组件更为灵活,我们同样的构建三个插槽对外使用
footer 的默认内容就按照最常用的业务模型进行构建,也就是一个取消、一个确认,右上角的 x ,笔者并没有配置,这里目的是对 dialog 之前的内部渲染模块进行覆盖,有业务需求再进行补充
组件对外使用时,为增强对 ElDialog 的灵活度,提供 v-bind="$attrs"
以供外部 prop 对 ElDialog 进行更小粒度的配置
js
<template>
<ElDialog
:show-close="false"
v-model="visible"
title="dialog"
width="30%"
align-center
v-bind="$attrs"
>
<template #header>
<slot name="header"> </slot>
</template>
<template #footer>
<slot name="footer">
<ElButton
@click="onCancel"
>取消</ElButton
>
<ElButton
@click="onConfirm"
>确认</ElButton
>
</slot>
</template>
</ElDialog>
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue'
export default defineComponent({
interface DialogConfig {
handleCancel: () => Promise<any>
handleConfirm: () => Promise<any>
}
props: {
dialogConfig: {
type: Object as PropType<DialogConfig>,
default: () => ({
handleCancel: () => Promise.resolve(),
handleConfirm: () => Promise.resolve(),
})
}
},
setup(props, ctx) {
const visible = ref<boolean>(false)
const openDialog = () => (visible.value = true)
const closeDialog = () => (visible.value = false)
const onCancel = async () => {
}
const onConfirm = async () => {
}
ctx.expose({
openDialog,
closeDialog
})
return { visible, onConfirm, onCancel }
}
})
</script>
这里笔者为组件设计了 4 个接口
- openDialog 开启弹窗
- closeDialog 关闭弹窗
- handleConfirm 异步确认
- handleCancel 异步取消
为什么需要异步确认和异步取消,因为同步的事件绑定并不能满足业务需求。
以表单提交为例,点击确认按钮后,外部组件将表单数据进行请求确认,确认按钮要处于 loading 状态避免重复点击, 按钮的 loading 状态请求的状态来确认,弹窗的关闭与否也是同理,表单数据不符合要求则进行重填,符合则进行弹窗关闭。实际上,Element-Plus 的 dialog 也提供了足够的 prop来确认弹窗关闭前后的回调函数
但是并不完整,且分散在多个 prop 属性跟事件,弹窗的开启到关闭对开发者来说是线性的,如点击按钮之后干什么,成功后做什么,失败后做什么,另一方面是分散在各处的 prop 并不有利于组件维护,这也是构造 dialogConfig 一个 prop 的目的。
如何使用组件呢,笔者这里是通过 ref 引用到实例,然后通过组件暴露的 expose 方法进行弹窗打开关闭的控制。
js
<template>
<div>
<ElButton @click="handleClick1"> 点击触发 </ElButton>
<DialogComponent ref="dialogRef1"> </DialogComponent>
</div>
</template>
<script setup lang="ts">
import DialogComponent from '@/components/modal/DialogComponent.vue'
import { ref } from 'vue'
const dialogRef1 = ref()
const handleClick1 = () => {
if (dialogRef1.value) {
dialogRef1.value.openDialog()
}
}
</script>
之后就是对异步逻辑的实现,接口期望传入的 handleConfirm 和 handleCancel 为一个 Promise,然后在弹窗默认的确认和取消的绑定方法中进行调用,同时进行业务默认逻辑与传入异步逻辑的执行熟悉的确认,同时再为取消、确认按钮补充 prop 的传入,提高灵活性。
js
<template>
<ElDialog
:show-close="false"
v-model="visible"
title="dialog"
width="30%"
align-center
v-bind="$attrs"
>
<template #header>
<slot name="header"> </slot>
</template>
<template #footer>
<slot name="footer">
<ElButton
@click="onCancel"
v-bind="$props.dialogConfig?.cancelProps"
:loading="cancelLoading"
>取消</ElButton
>
<ElButton
v-bind="$props.dialogConfig?.confirmProps"
@click="onConfirm"
:loading="confirmLoading"
>确认</ElButton
>
</slot>
</template>
</ElDialog>
</template>
<script lang="ts">
import { defineComponent, ref, type PropType, onMounted, onUnmounted } from 'vue'
interface DialogConfig {
handleCancel: () => Promise<any>
handleConfirm: () => Promise<any>
handleError?: (e: unknown) => unknown
confirmProps?: any
cancelProps?: any
}
export default defineComponent({
props: {
dialogConfig: {
type: Object as PropType<DialogConfig>,
default: () => ({
handleCancel: () => Promise.resolve(),
handleConfirm: () => Promise.resolve(),
confirmProps: {},
cancelProps: {}
})
}
},
setup(props, ctx) {
const visible = ref<boolean>(false)
const confirmLoading = ref<boolean>(false)
const cancelLoading = ref<boolean>(false)
const openDialog = () => (visible.value = true)
const closeDialog = () => (visible.value = false)
const onCancel = async () => {
cancelLoading.value = true
try {
await props.dialogConfig.handleCancel()
closeDialog()
} catch (error) {
console.log('error: ', error)
} finally {
cancelLoading.value = false
}
}
const onConfirm = async () => {
confirmLoading.value = true
try {
await props.dialogConfig.handleConfirm()
closeDialog()
} catch (error) {
console.log('error: ', error)
} finally {
confirmLoading.value = false
}
}
ctx.expose({
openDialog,
closeDialog
})
const dialogRef = ref()
return { cancelLoading, confirmLoading, visible, onConfirm, onCancel }
}
})
</script>
<style lang="scss" scoped></style>
调用时我们模拟一个延迟返回的异步任务进行过测试
JS
const dialogConfig1 = {
handleConfirm: () => {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject(5)
}, 5000)
})
.then((res) => {
console.log('res11111: ', res)
})
.catch((error) => {
throw error
})
.finally(() => {
console.log('fina')
})
},
handleCancel: () => Promise.resolve(),
confirmProps: {
type: 'primary'
}
}
以上,我们的弹窗就封装完毕了
命令式弹窗的问题
因此,业务中笔者更倾向于使用命令式弹窗,但是开发过程中命令式弹窗就没有任何缺点么?
笔者在开发中,还是遇到不少命令式弹窗的问题的
- 弹窗内部状态与外部组件的状态的割裂,由于分开书写,外部组件若想对内部弹窗的状态进行修改,需要内部弹窗进行方法的声明与暴露,较为繁琐
- 不正确的封装导致弹窗的销毁,从而内存泄漏问题
- 组件难点还有路由跳转未销毁
第一点属于命令式弹窗的短处,但是对于业务单调的中后台系统来说,已经足够使用
第二点需要开发者注意了,弹窗类组件尤其要注意组件的销毁
路由跳转未销毁的问题,我们就需要对弹窗统一管理,以封装的弹窗为例,我们可以维护一系列管理组件实例的方法进行维护
弹窗组件挂载时进行实例注册,销毁时数据也同时移除,然后在声明一个 destroyAll 方法可以根据现存的实例销毁视图上所有的弹窗
JS
import type { ComponentPublicInstance, Ref } from 'vue'
interface DialogComponentExpose {
openDialog: () => void
closeDialog: () => void
}
type DialogComponentInstance = Ref<ComponentPublicInstance<DialogComponentExpose>>
const dialogSet = new Set<DialogComponentInstance>()
export const registerDialog = (dialog: DialogComponentInstance) => {
dialogSet.add(dialog)
console.log('dialogSet: ', dialogSet)
}
export const destroyAll = () => {
dialogSet.forEach((item) => {
if (item.value) {
item.value.closeDialog()
}
})
dialogSet.clear()
}
export const removeDialog = (dialog: DialogComponentInstance) => {
dialogSet.delete(dialog)
}
JS
onMounted(() => {
registerDialog(dialogRef)
})
onUnmounted(() => {
console.log('销毁')
removeDialog(dialogRef)
})
感谢您阅读本文,希望对您有所帮助。如果您觉得本文对您有价值,请点赞并收藏,以便日后查阅。谢谢!