2024-07-21 17:49:03
在业务开发里,弹窗几乎是最高频的交互之一。但一旦数量多起来,代码也很容易开始重复。比如这样:
<el-dialog v-model="visible1">content 1</el-dialog>
<el-dialog v-model="visible2">content 2</el-dialog>
<el-dialog v-model="visible3">content 3</el-dialog>
const visible1 = ref(false)
function open1() {
visible1.value = true
}
const visible2 = ref(false)
function open2() {
visible2.value = true
}
const visible3 = ref(false)
function open3() {
visible3.value = true
}
如果弹窗只有一两个,问题还不明显;但一旦数量多起来,即使拆成组件,还是会留下不少重复的状态管理和打开逻辑,连命名都会变得很痛苦。
所以我想把弹窗的打开和关闭逻辑再往上收一层,尽量做成函数调用的形式,而不是先维护一堆 visible 状态。
我期望的使用方式是:
useModal(component, props, modalProps)
这篇文章就简单记录一下 useModal 的实现。
实现目标
- 通过函数直接打开弹窗
- 支持
props的传递和弹窗的配置 - 调用方可以主动关闭
- 可以调用到组件实例暴露的方法
- 自动销毁
1:弹窗动态挂载
要通过函数调用,就不能再直接依赖页面里预先写好的 <el-dialog />。比较直接的做法是在调用时临时创建一个 vue 应用,再挂载到 document.body 上。
export function useModal(component, props, modalProps) {
const open = ref(false)
const dialog = () => {
const { slots: modalSlots, ...restModalProps } = modalProps ?? {}
const dialogProps = {
...restModalProps,
modelValue: open.value,
beforeClose(done: () => void) {
done()
}
}
const slots = {
default: () => h(component, props),
...modalSlots
}
return h(ElDialog, dialogProps, slots)
}
const app = createApp(dialog)
const div = document.createElement('div')
document.body.appendChild(div)
app.mount(div)
}
这样一来,弹窗内容就能在运行时动态渲染出来。
不过这里只是第一步,还有两个明显的问题:
- 每次打开都会创建实例,但没有销毁逻辑。
- 外部无法主动关闭弹窗。
2:补充关闭和销毁
因为是通过 createElement 动态创建节点的,所以关闭时不能只改 modelValue,还要把挂载出来的内容真正移除掉,不然只是视觉上关了。
所以补一个 unmount 方法,同时对外暴露 close:
export function useModal(component, props, modalProps) {
function unmount() {
if (!open.value)
return
open.value = false
app.unmount()
document.body.removeChild(div)
}
return {
close: unmount,
isOpen: open
}
}
这样虽然能正常销毁,但在测试后会发现一个新问题:关闭动画没了。
原因也很直接:open.value = false 之后立刻执行了 app.unmount(),动画还没走完,节点就已经被卸载了。
3:延迟销毁, 保留动画
问题既然出在卸载过早,那就把销毁往后放一点,等关闭动画结束之后再执行。
function unmount() {
if (!open.value)
return
open.value = false
setTimeout(() => {
app.unmount()
document.body.removeChild(div)
}, 1000)
}
4:调用弹窗内容组件的方法
在业务里,弹窗通常不只是展示内容,很多时候还会带着表单、校验、确认之类的逻辑。
defineExpose({
validate() {
// ...
}
})
处理方式也不复杂,给弹窗内容组件挂一个 ref 就够了。
export function useModal(component, props, modalProps) {
const componentInstance = ref<ComponentPublicInstance | null>(null)
const slots = {
default: () =>
h(component, {
...(props ?? {}) as Record<string, unknown>,
ref: componentInstance
}),
...modalSlots
}
return {
close: unmount,
instance: componentInstance,
isOpen: open
}
}
完整代码
import type { DialogProps } from 'element-plus'
import type { Component, ComponentPublicInstance } from 'vue'
import type { ExtractComponentProps, ModalInstance, ModalProps } from './types'
import { ElDialog } from 'element-plus'
import { createApp, h, ref } from 'vue'
import { loadPlugins } from '@/plugins'
const ANIMATION_DURATION = 1000
const DEFAULT_MODAL_PROPS: Partial<DialogProps> = {
width: '50%',
top: '15vh',
closeOnClickModal: false,
closeOnPressEscape: true,
destroyOnClose: true,
draggable: true,
lockScroll: true
} as const
export function useModal(
component: Component,
props?: ExtractComponentProps,
modalProps?: ModalProps
): ModalInstance {
const componentInstance = ref<ComponentPublicInstance | null>(null)
const open = ref(true)
const dialog = () => {
const { slots: modalSlots, ...restModalProps } = modalProps ?? {}
const dialogProps = {
...DEFAULT_MODAL_PROPS,
...restModalProps,
modelValue: open.value,
beforeClose(done: () => void) {
done()
unmount()
}
}
const slots = {
default: () =>
h(component, {
...(props ?? {}) as Record<string, unknown>,
ref: componentInstance
}),
...modalSlots
}
return h(ElDialog, dialogProps, slots)
}
const app = createApp(dialog)
const div = document.createElement('div')
loadPlugins(app)
document.body.appendChild(div)
app.mount(div)
function unmount() {
if (!open.value)
return
open.value = false
setTimeout(() => {
app.unmount()
document.body.removeChild(div)
}, ANIMATION_DURATION)
}
return {
close: unmount,
instance: componentInstance,
isOpen: open
}
}