弹窗方式的探索与优化

如何优雅方便的实现弹窗功能

Posted by My on September 30, 2024

一、前言

弹窗都不陌生了,在一些面试中,问到 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 文件,导出一个函数,如上接收三个参数: componentpropsmodalProps,然后渲染 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 };
};