一、前言
弹窗都不陌生了,在一些面试中,问到 vue 的父子间通信,常常是举 「弹窗」 这个例子。
在我以往的开发中,最初是通过属性绑定,即:
的方式来实现弹窗的显示与隐藏,核心点就是需要声明一个变量,如 isShow
,然后传给弹窗组件 —— :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>
这是我用的最多的一种方式,但是还是觉得不够优雅,还在探索新的方式。
二、探索
通过一些组件库的 message
组件,发现这种使用方式很灵活,即 api 的形式,那么能不能将弹窗封装成这种形式呢?命令式的,或者说 hooks 形式的?
使用起来就跟以上的调用组件实例的方式一样,如:
1
2
3
4
<script setup>
const showTips = () => { tips.open({ tips,{ msg: "欢迎加入我们", }, {
title:'提示' } }) };
</script>
三、实现
这个是在之前官网的项目中探索的,所以用的也是 「ant design vue」的组件。
1. 渲染 modal 组件
创建一个 dialog.ts
文件,导出一个函数,如上接收三个参数: component
、props
、modalProps
,然后渲染 modal
组件。
使用 vue 提供的 h
函数将 modal
组件渲染到页面上,而 component
是渲染在默认插槽中的(之前提到过,默认插槽就是default
函数)。
1
2
3
4
5
6
import { h } from "vue";
import { Modal } from "ant-design-vue";
export const showModal = (component, props, modalProps) => {
const dialog = h(Modal, modalProps, { default: () => h(component, props) });
};
到此,「vnode」就生成了,我们借鉴 main.ts
挂载 app
的方式,将 dialog
组件渲染到页面上。
原先 html
上已经存在 id 为 app
的元素,因此它直接挂载在该元素上。目前我们没有相应的元素,因此我们得先生成一个元素到页面上,再将 dialog
组件渲染到该元素上。
1
2
3
4
5
6
7
8
9
10
export const showModal = (component, props, modalProps) => {
const dialog = h(Modal, modalProps, { default: () => h(component, props) });
const app = createApp(dialog);
const div = document.createElement("div");
document.body.appendChild(div);
app.mount(div);
};
到此,调用 showModal
函数,就能渲染出该组件,通过 element
面板发现,和 <div id="app"></div>
同级生成了一个 <div>
元素。但因为 Modal
还需要一个 open
属性才能显示出来。
1
2
3
4
5
const dialog = h(
Modal,
{ ...modalProps, open: true },
{ default: () => h(component, props) }
);
到目前为止,弹窗已经可以出现了,但是关闭不了。
2. 关闭 modal 组件
通过 Modal 文档,关闭弹窗时,会调用 onCancel
回调函数。因此,可以再该函数中将 app
卸载掉。
1
2
3
4
5
6
7
8
9
10
11
12
const dialog = h(
Modal,
{
...modalProps,
open: true,
onCancel() {
console.log("cancel");
app.unmount();
},
},
{ default: () => h(component, props) }
);
可以关掉了,但还是有点小瑕疵:弹窗直接关闭,但没有动画效果。
这里有个细节,刚才时直接卸载掉 app
,因此没有动画,那可以这么思考,「Modal」是通过 open
属性进行显示与隐藏的,那我们可以通过修改 open
属性来实现弹窗的关闭。
1
2
3
4
5
6
7
8
9
10
11
12
13
const open = ref(true);
const dialog = h(
Modal,
{
...modalProps,
open: open.value,
onCancel() {
console.log("cancel");
open.value = false;
},
},
{ default: () => h(component, props) }
);
到这里你会发现 onCancel
函数被调用了,但是弹窗没有关闭。换一种说法就是,响应式数据 open
没有生效。
这里可以思考一下 vue3 的响应式原理,「数据与使用这个数据之间的关系」,在这里,响应式数据是没办法单独工作的,必须依赖于副作用函数,这个组作用函数进行了「依赖收集」与「派发更新」。
那针对于以上代码,只需要把 「dialog」声明为一个函数,返回一个 「vnode」,那这时 「dialog」就是一个副作用函数了,响应式生效。
在关闭弹窗的时候,将 app
卸载并移除 div
元素即可。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
const dialog = () =>
h(
Modal,
{
...modalProps,
open: open.value,
onCancel() {
open.value = false;
app.unmount();
document.body.removeChild(div);
},
},
{ default: () => h(component, props) }
);
😪 又有问题了,立即卸载后,动画又没有了。查阅文档,有个 afterClose
属性,可以用来指定动画结束后的回调函数。可以在这个回调里移除 div
元素。
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) }
);
3. 提交功能
我们点击「确定」按钮,至少是要关闭弹窗的,为了方便调用,我们先把关闭弹窗的逻辑抽离出来。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const dialog = () =>
h(
Modal,
{
...modalProps,
open: open.value,
onCancel() {
unmount();
},
onOk() {
unmount();
},
afterClose() {
app.unmount();
document.body.removeChild(div);
},
},
{ default: () => h(component, props) }
);
const unmount = () => {
open.value = false;
};
到这里,简单的命令式弹窗就好了,但是以往的情况,我们点击「确定」按钮,是要进行一些表单的提交的,那以前是直接在子组件中调用方法提交。现在的关键点是点击「确定」时,要获取到默认插槽中组件的实例。
首先子组件中有一个 submit
方法,用于提交表单。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<script setup>
defineProps({
msg: {
type: String,
default: "你好",
},
});
const submit = () => {
console.log("submit");
};
defineExpose({
submit,
});
</script>
<template>
<div></div>
</template>
在 ts
中,给这个 component
额外传递一个 ref
属性,用于获取到组件的实例。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const dialog = () =>
h(
Modal,
{
...modalProps,
open: open.value,
onCancel() {
unmount();
},
onOk() {
instance.value?.submit();
unmount();
},
afterClose() {
app.unmount();
document.body.removeChild(div);
},
},
{ default: () => h(component, { ref: instance, ...props }) }
);
其实,表单中的方法不一定是 submit
,可以通过属性传进来,但这里为了方便并约定好,表单的提交就用 submit
命名。
四、 表单实践
最后,我们来实践一下,用弹窗实现一个表单的提交。 在子组件中就使用 「a-form」组件做一个简单的表单。
这时候打开弹窗时,会发现收到了警告:「Failed to resolve component: a-input」。意思就是找不到这个组件。
我们可以回顾一下这个 「dialog」挂载的过程,生成一个 div
元素,和 app
组件同级的,但是我们并没有在这个元素中 use
「ant design vue」的组件库。当全局导入的时候会发生这种情况,因此只需要再次引入并使用即可。
1
2
3
4
import Antd from "ant-design-vue";
// ....
app.use(Antd);
app.mount(div);
1. 表单组件
表单组件直接使用 「a-form」组件,并在弹窗中渲染。进行表单的异步提交,并暴露出 submit
方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<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>
2. 点击提交
点击「确定」按钮,触发 onOk
回调函数,调用 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;
}
},
3. 国际化
在以上操作中,一个命令式的弹窗已经完成了。 但是,因为项目中进行了国际化操作,我们就要考虑下国际化。在 main.ts
中,是导入了 App
组件,然后 const app = createApp(App)
,其中,在 App
中是使用了 a-config-provider
进行包裹,并处理相关属性,因此,在渲染弹窗组件时,也需要使用 a-config-provider
进行包裹。
综上,完整的弹窗实现如下:
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
63
64
65
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 unmount = () => {
open.value = false;
};
const instance = ref<{ submit: () => void } | null>(null);
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 };
};