用Vue3和TS封装一个还行的命令式弹窗
背景
最近在做toB项目,弹窗+表单提交的使用场景非常多。每次都要去写差不多的 <el-dialog> 模板,传差不多的参数,重复维护它的状态,十分繁琐,所以想实现一个类似element的 $confirm('xxx') 组件那样用法的通用弹窗,来减少写template和维护状态的重复代码。这里记录下我的实现思路,水平有限,感谢大佬们的批评指正。
需求
- 支持命令式调用,并且可以使用
then和catch来执行弹窗关闭后的回调任务import { createDialog } from './common-dialog' import Content from './Content.vue' createDialog({ title: '窗口标题', content: Content }).then(() => { console.log('确认回调') }).catch(() => { console.log('取消回调') }) - 支持弹窗组件内部维护弹窗状态,同时需要暴露接口以通知弹窗组件做一些操作
- 支持多弹窗嵌套调用
设计与实现
概览
根据需求大概需要的流程如下
- 使用方
Page.vue通过调用createDialog(Content)函数来创建一个弹窗,调用时将内容组件Content.vue作为参数传递进去 createDialog创建一个窗体,将接收的内容组件插入到窗体中,同时将控制窗体的hidetoggleLoadingcallback等函数注入到内容组件中供其调用Content.vue中实现原本弹窗需要实现的业务,例如填写并提交表单,完成流程后可以调用被注入的hide来关闭窗体,调用callback来进入调用方createDialog的回调流程进行刷新列表等操作
命令式
createDialog() 如何创建窗体?在vue2的时候用的是 new Vue 来创建一个新的实例,这里用到的则是vue3的 createApp
// basic-dialog.tsx
import { createApp, App } from 'vue'
import { ElDialog } from 'element-plus'
let instance: App
export const createDialog = (content) => {
// 创建实例
instance = createApp({
setup() {
const ContentComponent = () => h(content)
return () => (
<ElDialog><ContentComponent /></ElDialog>
)
}
})
const elem = document.createElement('div')
elem.id = 'basic-dialog'
document.body.append(elem)
// 挂载
instance.mount(elem)
}
状态维护
维护窗体状态是不太优雅的事情,原本用 <template> 调用弹窗时我们必须至少维护一个窗体的开关状态,如果是表单则需要多一个确认按钮的loading状态,这些其实都不是业务内容却混在业务中,大致是这样
<template>
<dialog v-model="visible">
<content />
<footer>
<button :loading="loading" @click="handleSumbit">确认</button>
</footer>
</dialog>
</template>
<script setup lang="ts">
const visible = ref(false) // 窗体状态
const loading = ref(false) // 确认按钮的loading状态
// 按下按钮需要手动开启按钮loading
const handleSumbit = () => {
loading.value = true
// do something ...
visible.value = false
loading.value = false
}
</script>
使用命令式弹窗的时候,可以简化这个流程,业务方只需要调用 createDialog 来创建一个窗体,不再关心窗体相关状态,让内容组件去接力流程。
因为命令式弹窗调用的时候实际上已经将窗体的状态 visible 设为 true,我们需要做的是让内容组件可以控制窗体以及loading状态的能力。
有一种实现的方式是约定好确认按钮的函数名(假设叫submit),外层窗体通过 $children[0].submit({ callback, hide, toggleLoading }) 去调用内容组件中约定好的函数,并把控制函数通过参数传入。但这样并不太优雅,而且在vue3中实现会比较曲折。
更好的方式是可以利用 provide 将控制函数注入到内容组件中。
// basic-dialog.tsx
// 规范一下注入函数的类型
export const IHide: InjectionKey<() => void> = Symbol('IHide')
export const IToggleLoading: InjectionKey<(status?: boolean) => void> = Symbol('IToggleLoading')
export const ICallback: InjectionKey<(value?: unknown) => void> = Symbol('ICallback')
let instance: App
export const createDialog = (content) => {
// 创建实例
instance = createApp({
setup() {
// 控制相关
const visible = ref<boolean>(true) // 窗体状态
const [confirmLoading, toggleLoading] = useToggle() // confirm 按钮的 loading 状态
const hide = () => { // 关闭 dialog 的函数
toggleLoading(false)
visible.value = false
}
const callback = () => {
// do something
}
// 注入
provide(IHide, hide)
provide(IToggleLoading, toggleLoading)
provide(ICallback, callback)
const ContentComponent = () => h(content)
return () => (
<ElDialog modelValue={visible.value} onClose={hide}>
<ContentComponent />
</ElDialog>
)
}
})
// ...
}
<!-- Content.vue -->
<template>
<form>whatever...</form>
</template>
<script setup lang="ts">
import { IHide, IToggleLoading, ICallback } from 'basic-dialog'
import { inject } from 'vue'
// do something...
inject(IToggleLoading)?.(false) // 关闭loading
inject(ICallback)?.('xx结果拿去') // 回调
inject(IHide)?.() // 关闭弹窗
</script>
回调
如何让内容组件 callback 的时候进入调用方的 then 函数呢?我们需要把原先的 createDialog 包装成一个 Promise,然后在注入的 callback 中调用一下 resolve 就可以了
// 原先的 createDialog
const _createDialog = (content, resolve, reject) => {
// 创建实例
instance = createApp({
setup() {
// ...
const callback = () => {
resolve() // 在注入的 callback 中调用一下 resolve
}
// ...
}
})
}
export const createDialog = (content) => {
return new Promise((resolve, reject) => {
_createDialog(content, resolve, reject)
})
}
嵌套调用
在实际使用中经常有嵌套调用的情况,比如弹窗打开一个表单,在表单中的某项数据需要再次弹窗去选择参数等等,现在的实现是无法满足的。弹窗先创建先销毁的模式很容易想到栈,我们可以使用栈来维护弹窗实例
// 使用一个栈来维护弹窗实例
const dialogStack: { uid: number, instance: App }[] = []
const _createDialog = (content, resolve, reject) => {
cosnt instance = createApp({
// ...
// 需要升级一下hide函数
const hide = () => { // 关闭 dialog 的函数
toggleLoading(false)
visible.value = false
dialogStack.pop() // 将实例取出
}
// ...
})
// 将实例压入栈中
dialogStack.push({ uid: instance._uid, instance })
const elem = document.createElement('div')
elem.id = 'basic-dialog'
document.body.append(elem)
// 挂载
instance.mount(elem)
}
扩展
封装组件的时候不能失去被封装组件(本案例中是 <ElDialog>)原先的能力,例如调用方需要更改 <ElDialog> 的 width 属性,我们需要暴露修改的方式,这个实现也比较简单,从 createDialog 中传入,绑定到 render 的组件上就可以
const _createDialog = (options, resolve, reject) => {
cosnt instance = createApp({
setup() {
// ...
// 其他属性也可以用相同方式传入和绑定
return () => (
<ElDialog
title={options.title} modelValue={visible.value} onClose={hide}
{ ...(options.dialogAttrs || {})} >
<ContentComponent />
</ElDialog>
)
}
})
// ...
}
// 更多参数
export type Options = {
title: string
content: any
dialogAttrs?: Partial<InstanceType<typeof ElDialog>['props']> // 这样调用时会有类型推断
}
export const createDialog = (options: Options) => {
return new Promise((resolve, reject) => {
_createDialog(options, resolve, reject)
})
}
效果
调用方
import Content from './Content.vue'
// 刷新列表
const refreshList = () => { /** whatever **/ }
// 创建弹窗
const toggleDialog = () => {
createDialog({
title: '标题',
content: Content
}).then((res) => {
console.log(res) // ok
refreshList()
})
}
内容组件
<!-- Content.vue -->
<template>
<div>haha</div>
</template>
<script>
import { inject } from 'vue'
import { ICallback, IHide } from 'basic-dialog'
const callback = inject(ICallback)
const hide = inject(IHide)
callback?.('ok')
hide?.()
</script>
不足
- 调用方在
createDialog传入参数到内容组件时没有类型推断 - 内容组件为表单提交类型时,调用注入函数还是比较繁琐
- 欢迎补充