Post

Imperative Modal in Vue3

Exploring a cleaner way to trigger modals programmatically in Vue3 using the h function and createApp.

Imperative Modal in Vue3

管弹窗这件事,折腾过几个阶段。

最初是 :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: '编辑' })。里面绕了几圈,调用方不用知道。

This post is licensed under CC BY 4.0 by the author.