为什么要使用函数式调用弹窗?
常规方式使用弹窗的痛点
对于最基本的确定提示框弹窗,使用通常需要以下几步
- 创建visible变量,为按钮创建点击事件,点击事件设置更改visible
- 引入组件,v-model绑定visible,组件绑定弹框确认取消事件
上述方式的弊端很明显,主要在于
- 需要声明一个visible变量去管理,当一个页面挂载多个弹窗时会有一大堆visible,容易混淆
- 由于没有状态,链式调用多个弹窗和管理弹窗状态时会比较恶心,往往写着写着就忘记哪个地方需要给visible设为true/false
- 将弹窗移动到另一个页面时需要额外写的东西很多
使用该版本函数式弹窗的优点
- 使用Promise进行弹窗管理,很适配多步骤多弹窗的需求
- visible变量内置在组件内部,既可自动管理也可手动管理,相较于Element-plus的 ElMessageBox.confirm,能够在点击确认后异步关闭弹窗
- 只需要一行引入组件代码,一行调用代码即可直接生成弹窗,弹窗复用成本大幅降低
代码详情
调用示例
主体分为两个api:
- useDialogControl 用于在弹窗组件中初始化弹窗
- useDialogShow 用于弹窗的调用
常规使用(点击确认后关闭)
useDialogShow('ConfirmDlg', {
title: '确定删除?',
subTitle: '删除后不可恢复,请谨慎操作'
}).then(() => {
...
})
异步关闭弹窗
useDialogShow('ConfirmDlg', {
// ...任意组件需要的参数
onConfirm: (res: any, exposed: any) => {
// 此处添加await异步逻辑
exposed.emit('resolve')
}
})
以上弹窗调用不仅仅局限于确认弹窗,包括表单弹窗,进度条弹窗等任意模态弹窗都可以套用该弹窗调用模版
要使弹窗能够支持以上调用,由于js调用组件时会丢失上下文,需要先挂载Vue的上下文到全局以便获取(个人使用是封装在组件库里的,直接在install里面获取)
const app = createApp(App)
window.lsuiApp = app
弹窗组件中需要调用useDialogControl进行初始化,使其能够被useDialogShow调用
import { useDialogControl } from '@/hooks/useDialog'
const { visible } = useDialogControl(getCurrentInstance())
完整子组件代码
<template>
<el-dialog
:model-value="visible"
:close-on-click-modal="false"
:before-close="onBeforeClose"
:width="width"
>
<template #header>
<span class="el-dialog__title">
<fs-icon icon="error" color="#F8721D" style="margin-right: 0.5rem" />
{{ title }}
</span>
</template>
<div class="sub-title">
{{ subTitle }}
</div>
<slot />
<LsConfirmFooter @confirm="confirm" @cancel="visible = false" />
</el-dialog>
</template>
<script setup lang='ts'>
defineOptions({ name: 'ConfirmDlg' })
withDefaults(defineProps<{
title: string;
subTitle: string;
width: string;
icon: string;
}>(), {
title: '',
subTitle: '',
width: '28rem',
icon: 'error'
})
const emit = defineEmits(['confirm'])
const confirm = () => {
emit('confirm', { visible })
}
import { useDialogControl } from '@/hooks/useDialog'
const { visible, onBeforeClose } = useDialogControl(getCurrentInstance())
</script>
<style lang="less" scoped>
.sub-title {
line-height: 1.375rem;
min-height: 3rem;
font-size: 1rem;
margin-left: 1rem;
word-break: break-all;
white-space: pre-line;
}
</style>
完整代码
import { createVNode, render, ref, Component } from 'vue'
import type { Ref, ComponentInternalInstance } from 'vue'
import { watch } from 'vue'
// 当前由useDialog创建的全部弹窗
const dialogList: {id: string, visible: Ref<boolean>}[] = []
// 注册的弹窗
const diaLogMap: Record<string, Component> = {}
// 弹窗控制器初始化
export function useDialogShow<T = Record<string, any>>(dlg: string | Component, options: {[key:string]: any} = {}) {
options?.dlgId && clearDialog(options.dlgId)
return new Promise<T & {
visible: Ref<boolean>,
changed: Ref<boolean>
}>((resolve, reject) => {
const container = document.createElement('div')
if (options?.dlgId) {
container.id = options?.dlgId
}
const vNode = createVNode(
typeof dlg === 'string' ? diaLogMap[dlg] : dlg,
{
onDestroy: () => {
setTimeout(() => {
render(null, container)
container.remove()
}, 1000)
reject()
},
// 用于在confirm里面手动回调触发promise的resolve
onResolve: () => {
exposed.visible.value = false
resolve(exposed)
},
...options,
onConfirm: (emitParam: any) => {
if (options?.onConfirm) {
options.onConfirm(emitParam, exposed)
} else {
exposed.visible.value = false
resolve(exposed)
}
}
},
null
)
// 更新节点上下文以引用全局组件
// @ts-ignore
vNode.appContext = window.lsuiApp._context
render(vNode, container)
document.body.appendChild(container)
const instance = vNode.component as any
const exposed = instance?.exposed
if (exposed) {
exposed.visible.value = true
}
})
}
export function useDialogControl(contextData: ComponentInternalInstance | null, id = 'fs-dialog' + Date.now() + Math.floor(Math.random() * 1000)) {
const context = contextData as ComponentInternalInstance
const visible = ref<boolean>(false) // 弹窗显示双向绑定
const changed = ref<boolean>(false) // 标识数据是否发生了变化,外界回调获取更新
if (!context.exposed) {
context.exposed = {}
}
context.exposed.visible = visible
context.exposed.changed = changed
context.exposed.emit = context.emit
const onBeforeClose = () => {
visible.value = false
}
dialogList.push({ id: context.attrs.dlgId as string || id, visible })
watch(() => visible.value, (value) => {
if (!value) {
context.emit('destroy')
const listIndex = dialogList.findIndex(val => val.id === id)
if (listIndex !== -1) {
dialogList.splice(listIndex, 1)
}
}
})
return { visible, changed, onBeforeClose }
}
export function clearDialog(id?: string) {
if (id) {
for (const curDialog of dialogList.filter(val => val.id === id)) {
curDialog.visible.value = false
}
document.querySelectorAll(`#${id}`).forEach(val => val.remove())
} else {
for (const dialog of dialogList) {
dialog.visible.value = false
}
}
}
export function installDialog(dlgList: Component[]) {
for (let comp of dlgList) {
diaLogMap[comp.name as string] = comp
}
}
代码分析
上述代码使用了vue的一个特性,即在组件中on-前缀的属性会被自动解析为事件监听器
- 故只要在你想关闭弹窗的按钮上绑定emit confirm事件,点击后即可触发useDialogShow的.then事件
如果visible没有通过emit事件就触发了visible的关闭,则会触发useDialogShow的.catch事件
- 而当传递了onConfirm事件后,默认的onConfirm则会被覆盖,这时你便可以在onConfirm的回调中对useDialogControl暴露出来的数据进行处理,无论是返回值/visible还是触发自定义的任意回调函数
- 使用installDialog即可注册弹窗组件,根据name使用字符串调用
还存在的问题&结语
对于我个人最近一年的开发经历,这样管理弹窗基本覆盖100%的弹窗场景,但还是存在一些痛点
- 这样调用后有时开发时会不触发热更新,还没找到好的解决方式
- 由于是将弹窗过程化了,在外部比较难在不点击弹窗按钮的前提下去操作弹窗,不过对于模态弹窗来说,基本不存在这种场景
总的来说,自从使用该函数后,刚参加工作时最麻烦最恶心的各种弹窗交互现在用起来舒服了很多,体感可能还存在可优化的点以及不规范的地方,希望路过的大佬不吝赐教