Imperative Modal in Vue3
Exploring a cleaner way to trigger modals programmatically in Vue3 using the h function and createApp.
管弹窗这件事,折腾过几个阶段。
最初是 :show="isShow" 这套,每个弹窗声明一个变量,传进去。简单,但弹窗一多,页面顶部就开始堆变量,看着难受。后来换成 ref 调子组件方法——弹窗自己管自己的显隐,父组件只负责触发:
1
2
3
4
5
6
7
8
9
10
11
12
13
<!-- 父组件 -->
<template>
<EditDialog ref="editRef" @on-confirm="handleConfirm" />
</template>
<script>
export default {
methods: {
open() {
this.$refs.editRef.open();
},
},
};
</script>
用得多,但一直觉得不够干净——还得在 template 里把组件挂上去,还得声明 ref,调用方总是要感知弹窗组件的存在。
然后看到一些组件库的 message 组件,ElMessage.success('操作成功') 这种写法,直接一个函数调用,不用在 template 里写任何东西。那弹窗能不能也做成这样?
先把弹窗渲染出来
思路是用 Vue 的 h 函数手动创建 vnode,再通过 createApp 挂载到页面上——跟 main.ts 里挂载根 app 的方式一样,只是挂载点是动态创建的 div:
1
2
3
4
5
6
7
8
9
10
11
import { h, createApp } from "vue";
import { Modal } from "ant-design-vue";
export const showModal = (component, props, modalProps) => {
const dialog = h(Modal, { ...modalProps, open: true }, { default: () => h(component, props) });
const app = createApp(dialog);
const div = document.createElement("div");
document.body.appendChild(div);
app.mount(div);
};
component 渲染到默认插槽(就是 default 函数),open: true 让弹窗直接显示。调用之后,在 element 面板能看到和 <div id="app"> 同级多了一个 <div>,弹窗出来了。
关闭按钮——没动画,然后没响应式
Modal 有 onCancel 回调,直接在里面 app.unmount() 就行:
1
2
3
onCancel() {
app.unmount();
},
能关掉,但没有关闭动画,弹窗直接消失。
想了一下,动画是靠 open 属性控制的,先改 open 触发动画,再卸载——用一个响应式变量:
1
2
3
4
5
6
7
8
9
10
11
12
const open = ref(true);
const dialog = h(
Modal,
{
...modalProps,
open: open.value,
onCancel() {
open.value = false;
},
},
{ default: () => h(component, props) }
);
onCancel 确实触发了,但弹窗纹丝不动。
想了一下才反应过来:响应式数据不能单独工作,必须在副作用函数里才能建立依赖关系、触发更新。现在 dialog 是一个 vnode,不是函数,open.value 被读取的时候没有追踪,改了也不会重新渲染。
解法:把 dialog 改成返回 vnode 的函数,它就成了副作用函数,响应式才真正生效:
1
2
3
4
5
6
7
8
9
10
11
12
const dialog = () =>
h(
Modal,
{
...modalProps,
open: open.value,
onCancel() {
open.value = false;
},
},
{ default: () => h(component, props) }
);
现在能关了,动画也有了。但 app 和 div 还留在页面上,得清理。顺手在 onCancel 里加上:
1
2
3
4
5
onCancel() {
open.value = false;
app.unmount();
document.body.removeChild(div);
},
动画又没了。原因一样——open 刚改成 false,动画还没跑完,div 就被删掉了。
查文档,Modal 有个 afterClose,在关闭动画结束后才触发。把清理逻辑移过去:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const dialog = () =>
h(
Modal,
{
...modalProps,
open: open.value,
onCancel() {
open.value = false;
},
afterClose() {
app.unmount();
document.body.removeChild(div);
},
},
{ default: () => h(component, props) }
);
确定按钮——怎么拿到子组件实例
弹窗能开能关了,但确定按钮还没接表单 submit。问题是:怎么在 onOk 里调到插槽里那个组件的方法?
子组件先把方法 expose 出来:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<script setup>
import { ref } from "vue";
const formRef = ref();
const sendData = () => {
return new Promise((resolve) => {
setTimeout(() => {
resolve(formState);
}, 1000);
});
};
const submit = async () => {
await formRef.value.validate();
return sendData();
};
defineExpose({ submit });
</script>
然后在 ts 这边,给 component 额外传一个 ref 属性,Vue 会把组件实例挂上去:
1
2
3
4
const instance = ref<{ submit: () => void } | null>(null);
// ...
{ default: () => h(component, { ref: instance, ...props }) }
onOk 里拿 instance 调 submit,加上 loading 状态:
1
2
3
4
5
6
7
8
9
async onOk() {
confirmLoading.value = true;
try {
await instance.value?.submit?.();
unmount();
} finally {
confirmLoading.value = false;
}
},
约定表单提交方法统一叫 submit,调用方不用关心里面具体怎么实现。
接到实际项目——组件找不到、国际化没了
在项目里用,打开弹窗直接报警告:Failed to resolve component: a-input。
原因是 createApp 创建的是一个全新的 app 实例,跟主 app 完全隔离——主 app 上 use 过的插件、全局注册的组件,这个新 app 都没有。得手动再 use 一次:
1
2
app.use(Antd);
app.mount(div);
项目里还有国际化,主 app 是用 a-config-provider 包着整个应用做 locale 配置的,弹窗这边也得包一层,不然按钮文案会变回默认英文:
1
2
3
4
5
6
7
8
const dialog = () =>
h(
ConfigProvider,
{ locale: i18n.global.locale.value == "en" ? enUS : zhCN },
{
default: () => h(Modal, { /* ... */ }, { /* ... */ }),
}
);
最终完整实现:
1
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
59
60
61
62
import { createApp, h, ref } from "vue";
import { Modal, ConfigProvider } from "ant-design-vue";
import Antd from "ant-design-vue";
import i18n from "@/lang";
import enUS from "ant-design-vue/es/locale/en_US";
import zhCN from "ant-design-vue/es/locale/zh_CN";
export const showModal = (component: any, props: any, modalProps: any) => {
const open = ref(true);
const confirmLoading = ref(false);
const instance = ref<{ submit: () => void } | null>(null);
const unmount = () => {
open.value = false;
};
const dialog = () =>
h(
ConfigProvider,
{ locale: i18n.global.locale.value == "en" ? enUS : zhCN },
{
default: () =>
h(
Modal,
{
...modalProps,
open: open.value,
confirmLoading: confirmLoading.value,
async onOk() {
confirmLoading.value = true;
try {
await instance.value?.submit?.();
unmount();
} finally {
confirmLoading.value = false;
}
},
onCancel() {
unmount();
},
afterClose() {
app.unmount();
document.body.removeChild(div);
},
},
{
default: () => h(component, { ref: instance, ...props }),
}
),
}
);
const app = createApp(dialog);
const div = document.createElement("div");
document.body.appendChild(div);
app.use(Antd);
app.use(i18n);
app.mount(div);
return { instance, unmount };
};
调用侧只需要一行:showModal(FormComponent, props, { title: '编辑' })。里面绕了几圈,调用方不用知道。