手把手教你实现纯 JS 创建弹窗

1,126 阅读9分钟

背景

在日常开发中,使用到 element-ui/element-plus 组件库的 Dialog 组件时, 都会写出这样一段模板代码:

<el-dialog
  v-model="dialogVisible"
  title="Tips"
  width="500"
  :before-close="handleClose"
>
  <span>This is a message</span>
  <template #footer>
    <div class="dialog-footer">
      <el-button @click="dialogVisible = false">Cancel</el-button>
      <el-button type="primary" @click="dialogVisible = false">
        Confirm
      </el-button>
    </div>
  </template>
</el-dialog>
import { ref } from "vue";
import { ElMessageBox } from "element-plus";

const dialogVisible = ref(false);

const handleClose = (done: () => void) => {};

当然,上面这段 HTML 可以单独封装成业务组件,如下所示:

引用组件

<CustomDialog v-model="visible" title="xxx"></CustomDialog>

定义组件

<template>
  <el-dialog :title="props.title" v-model="visible">
    <!-- 业务UI -->
    <template #footer>
      <el-button @click="okFn">确定</el-button>
      <el-button @click="cancelFn">取消</el-button>
    </template>
  </el-dialog>
</template>
import { ref } from "vue";

const props = defineProps({
  title: String,
});
const visible = ref(true);
const okFn = (ref) => {
  // 业务逻辑
  visible.value = false;
};
const cancelFn = (reject) => {
  visible.value = false;
};

然而,这样还是存在几个问题:

  1. 需要自行维护 visible 的状态,多个弹窗的状态要分别控制。这样会造成多处出现“模板代码”,开发起来重复琐碎,降低了开发效率。
  2. 弹窗中的自定义组件,状态不会随着弹窗关闭而重置,而是一直存在。(由于弹窗只是隐藏了,并没有销毁自定义组件)

我们希望有一种更优雅的方式来打开弹窗。

设计

如何设计出这样的一种打开弹窗的方式呢?

我们先参考一下类似的实现方案。

首先想到的就是和 Dialog 很类似的组件 -- MessageBox,用法如下:

const open = async () => {
  const { value } = await ElMessageBox.prompt(
    "Please input your e-mail",
    "Tip"
  );
};

也可以使用 JSX 自定义 HTML

ElMessageBox.alert(`alert`, {
  message: () => {
    return (
      <div>
        name:
        <el-input value={name.value} onInput={changeMessageBoxFn}></el-input>
      </div>
    );
  },
});
优点缺点
点击“确定”,弹窗自动关闭,自动 resolve。
无需控制 visible 状态
不能把内部的自定义内容,通过组件的方式传入
不能传入 ok,cancel 的回调
不支持异步关闭

再看第二个组件库--Antd VueModal,也有提供纯 JS 方式打开弹窗的 API:

modal.confirm({
  title: "Do you Want to delete these items?",
  icon: h(ExclamationCircleOutlined),
  content: h("div", { style: "color:red;" }, "Some descriptions"),
  onOk() {
    console.log("OK");
  },
  onCancel() {
    console.log("Cancel");
  },
  class: "test",
});
优点缺点
传入 ok,cancel 的回调不能把内部的自定义内容,通过组件的方式传入
支持异步关闭

参考一下其他技术栈的组件库:AntdAngular 实现--NG-ZORRO

const modal = this.modal.create<NzModalCustomComponent, IModalData>({
  nzTitle: "Modal Title",
  nzContent: NzModalCustomComponent,
  nzViewContainerRef: this.viewContainerRef,
  nzData: {
    favoriteLibrary: "angular",
    favoriteFramework: "angular",
  },
  nzOnOk: () => new Promise((resolve) => setTimeout(resolve, 1000)),
  nzFooter: [
    {
      label: "change component title from outside",
      onClick: (componentInstance) => {
        componentInstance!.title = "title in inner component is changed";
      },
    },
  ],
});
优点缺点
有提供传入组件的能力
传入 ok,cancel 的回调
支持异步关闭
底部按钮能自定义

参考以上 3 种组件,我们希望 dialog

  1. 能通过 JS 来弹出,不需要自行控制 visible 的状态。
  2. 弹窗创建后,visible 就是 true
  3. 弹窗关闭后直接销毁
  4. 弹窗中的内容可以自定义
  5. 每次打开弹窗,自定义组件中的状态自动初始化。
  6. 可通过在业务代码中调用函数来销毁弹窗,支持异步关闭
  7. 支持嵌套弹窗(在父弹窗中调用 dialog.open 打开子弹窗)

进一步细化,得到以下几个问题及解决方案:

问题解决方案
弹窗中的内容如何自定义?用自定义组件,还是直接用插槽嵌入?还是直接传入 JSX?自定义组件
如果弹窗内容用自定义组件,组件的参数如何传入?创建弹窗时传入
如果弹窗内容的自定义组件有事件绑定,如何处理?创建弹窗时传入
自定义组件如果有内部状态,在外部如何获取到?换句话说,自定义组件的 ref 引用,外部如何获取到?创建弹窗后返回自定义组件的 ref 引用
弹窗的关闭,是用 visible 实现,还是直接销毁弹窗的 DOM?直接销毁 DOM

至此,我们设计的打开弹窗的方式如下:

const open = async () => {
  try {
    const temp = MyDialog.open(
      {
        title: "测试弹窗",
        width: "",
        component: <自定义组件>,
        props: {},
        callback: {
          ok: ()=>{},
          cancel: ()=>{},
        },
      }
    );
  } catch (err) {
    console.error(err);
  }
};

如果要获取自定义组件的引用,就使用

temp.componentRef;

如果要手动关闭弹窗,就用

temp.destroyFn();

Dialog.open 的参数、返回值写成表格的形式:

参数

字段名作用类型
title弹窗标题,同 element-plus 的 Dialog 组件String
width弹窗宽度,同 element-plus 的 Dialog 组件String
component内嵌组件Component
event内嵌组件的事件Record<string,Function>
callback弹窗的回调,只有 ok 和 cancel{

ok:()=>{},
cancel
:()=>{}
}

返回值:

Promise<{ componentRef; destroyFn }>;
属性作用类型
componentRef弹窗内嵌的组件的引用。用于获取内嵌组件的状态和函数
destroyFn销毁弹窗的函数,供手动销毁弹窗使用()=>void

实现

函数设计好了,如何实现呢?

我们先封装一个弹窗组件 MyDialog,把 el-dialogvisible 封装进来。

MyDialog.vue

<el-dialog :title="title" :visible="visible" @close="cancelFn">
  <template #footer>
    <el-button @click="okFn">确定</el-button>
    <el-button @click="cancelFn">取消</el-button>
  </template>
</el-dialog>

备注:由于刚开始写,所以 el-dialog 中的默认插槽,暂时没有嵌入内容。接下来会讲如何嵌入组件。

如何把弹窗挂载到 dom 上

由于一创建完弹窗,就马上显示,所以需要把弹窗挂载到 DOM

分别考虑 Vue3Vue2 的实现方式

Vue3 下:

  1. 创建一个 div 节点
  2. 使用 createVNode 来创建 vnode 节点
  3. 对 vnode 绑定当前的上下文
  4. 使用 render 函数来把 vnode 渲染到 div 节点中
  5. document.body.appendChilddiv 节点挂载到 document.body

createVNode 第 1 个参数是要挂载的组件 MyDialog,第 2 个参数是组件的参数对象,第 3 个参数是 children

实现代码如下:

Vue3

const container = document.createElement("div");
container.id = `dialog-container-${new Date().getTime()}`;
const instance = getCurrentInstance(); // 上下文通过getCurrentInstance获得

const vnode = createVNode(
  MyDialog,
  {
    name: "my-dialog",
    title: props.title,
    width: props.width,

    ok: okFn,
    cancel: cancelFn,
  },
  null
);
if (instance) {
  vnode.appContext = instance.appContext; // 当前上下文通过instance.appContext来获取,目的是在弹窗的默认插槽中,也能使用Vue应用中注册的组件
}
render(vnode, container);

document.body.appendChild(container);

备注:需要将弹窗上下文和 Vue3 应用的上下文绑定,否则 Vue 不会解析弹窗中用到的 element-plus 组件,如下图所示。

20240706_181038_image.png

Vue2 下:

  1. 使用 Vue.extend 基于 MyDialog 来继承出一个子类 MyDialogInstance
  2. new 创建子类 MyDialogInstance 的实例 app ,并调用 $amount()
  3. document.body.appendChildapp.$el 挂载到 document.body

实现代码如下:

const props = { xxx }; // 自定义组件需要的参数
const MyDialog = Vue.extend(MyDialogVue);
const app = new MyDialog({
  propsData: {
    name: "my-dialog",
    title: props.title,
    width: props.width,

    ok: okFn,
    cancel: cancelFn,
  },
}).$mount();
document.body.appendChild(app.$el);

如果需要在弹窗内容组件中访问 vuex 的状态,那么,在使用 Vue.extend 创建子类时,需要传入 store 参数

store 通过 props 传入

const MyDialog = Vue.extend({
  ...MyDialogVue,
  store: props.store,
});

动态渲染自定义组件 component

现在的弹窗中是没有任何内容的,我们希望传入一个组件 component,创建弹窗时,在 el-dialog 中渲染 component。 由于 componentComponent 类型的,在 Vue2 / Vue3 中,可以用:

<component ref="componentRef" :is="component" />

这里添加了 ref,用于获取组件的引用.

如果 component 组件需要传参数,或者绑定事件,需要在 createVNode(vue3)或者 创建子类实例(vue2)时传入参数,和事件回调组成的对象.

示例如下:

Vue3

<component
  ref="componentRef"
  v-bind="props.props"
  v-on="props.event"
  :is="component"
/>
const props = defineProps({
  title: String,
  width: String,
  component: Object,
  event: Object, // 新增参数
  props: Object,
  ok: Function,
  cancel: Function,
  init: Function,
});
const vnode = createVNode(
  MyDialog,
  {
    name: "my-dialog",
    title: props.title,
    width: props.width,
    component: props.component,
    event: props.event, // 新增参数
    props: props.props,
    ok: okFn,
    cancel: cancelFn,
  },
  null
);

Vue2

<component
  ref="componentRef"
  v-bind="props.props"
  v-on="event"
  :is="component"
/>
export default {
  props: {
    title: String,
    width: String,
    component: Object,
    event: Object, // 新增参数
    props: Object,
    ok: Function,
    cancel: Function,
    init: Function,
  },
};
const props = { xxx }; // 自定义组件需要的参数
const app = new MyDialog({
  propsData: {
    name: "my-dialog",
    title: props.title,
    width: props.width,
    component: props.component,
    event: props.event, // 新增参数
    props: props.props,
    ok: okFn,
    cancel: cancelFn,
  },
}).$mount();

如何在外部获取到 component 的引用?

createVNode(vue3)或者 创建子类实例(vue2)时传入一个回调函数;

在外部定义一个临时变量,在回调函数内部给临时变量赋值;

MyDialog.vue mounted 时执行回调函数

为什么在 mounted 时可以拿到自定义组件 component 的引用呢?

复习一下 Vue生命周期

mounted:在组件被挂载之后调用。已挂载的含义是:所有同步子组件都已经被挂载,其自身的 DOM 树已经创建完成并插入了父容器中。

mounted 的执行顺序:先执行子组件(这里为 component 组件)的 mounted 钩子,再执行父组件(这里为 MyDialog.vue 组件)的 mounted 钩子。

所以父组件 mounted 时,意味着子组件已挂载完毕。

先写出以下代码(以 Vue3 为例)

MyDialog.open = (props, instance) => {
  let componentRef = null;
  const vnode = createVNode(
    MyDialog,
    {
      name: "my-dialog",
      title: props.title,
      width: props.width,
      component: props.component,
      event: props.event,
      props: {},
      ok: okFn,
      cancel: cancelFn,
      init: (comRef) => {
        componentRef = comRef;
      },
    },
    null
  );
  return { componentRef, destroyFn };
};

MyDialog.vue

onMounted(() => {
  console.log("my dialog mounted");
  props.init(componentRef.value);
});
问题 1

运行代码,发现获取的引用 componentRef.valueundefined

20240706_172413_image.png

打印出自定义组件和 MyDialogmounted 输出语句,发现

20240706_153409_image.png

为啥 component 会在 MyDialog 后面 mounted 呢?

这就需要看 Vue 源码中component 的实现了。推测里面使用了 nextTick 或者 setTimeout 等异步操作,导致子组件的 mounted 时机延后.

解决

外面包一层 nextTick

onMounted(() => {
  nextTick(() => {
    console.log("my dialog mounted");
    props.init(componentRef.value);
  });
});

运行效果如下:

20240706_170828_image.png

问题 2

生命周期钩子执行顺序正确,但是获取到的组件引用是 Null.

解决

想起来,component 组件是动态渲染的,挂载成功后才执行回调,赋值给临时变量。但是我们直接返回了临时变量。返回临时变量时,回调还没有执行。

考虑到异步渲染,我们改下 Dialog.open 函数的返回类型,返回 Promise<{componentRef,destroyFn}>

调用Dialog.open时,new 一个 promise 并返回。

啥时候 resolve 呢?就在执行回调函数 init 的时候。

执行回调函数 init 时如何获取到组件引用呢?

很简单,通过参数传进来即可。init 函数加参数:componentRefresolve 时,传入组件引用 componentRef。

调用Dialog.open时,先 await 这个 promise,再获取 componentRef

既然可以通过 resolve 来返回组件引用,那么临时变量就不需要了。

代码改写如下:

MyDialog.open = async (props, instance) => {
  const promise = new Promise((resolve) => {
    const vnode = createVNode(
      MyDialog,
      {
        name: "my-dialog",
        title: props.title,
        width: props.width,
        component: props.component,
        event: props.event,
        props: {},
        ok: okFn,
        cancel: cancelFn,
        init: (comRef) => {
          resolve(comRef);
        },
      },
      null
    );

    if (instance) {
      vnode.appContext = instance.appContext;
    }

    render(vnode, container);

    document.body.appendChild(container);
  });
  const componentRef = await promise;

  return { componentRef };
};

获取 componentRef 的值,结果如下:

20240706_173748_image.png

已经可以获取到自定义组件 component 的引用了。

关闭并销毁弹窗

弹窗已经显示出来了,点击关闭后就马上销毁弹窗,如何销毁弹窗的 DOM 呢?

销毁弹窗在 Vue3Vue2 下的做法如下:

Vue3

把创建时的 div 元素删掉

const container = document.createElement("div");
container.id = `dialog-container-${new Date().getTime()}`;

const destroyFn = () => {
  container.remove(); // 这里通过闭包引用到创建弹窗时生成的div元素
};

Vue2

创建组件实例时,用临时变量保存下来,在销毁弹窗时,

  1. 执行组件实例的 $destroy 函数
  2. 把组件元素的 $el remove
let currentApp = null;
const destroyFn = () => {
  currentApp.$destroy();
  currentApp.$el.remove();
};
const app = xxx; //为了简洁,这里省略创建组件实例的代码,也省略了创建promise的代码
currentApp = app;

有时候需要异步销毁弹窗:点击“确定”后,先调接口,接口调成功了,再销毁弹窗。

为了提供手动销毁弹窗的能力,我们需要暴露出销毁弹窗的函数。

可以在 Dialog.open 函数返回的对象里面,加上销毁弹窗的函数。

Dialog.open 的返回值如下:

return { componentRef, destroyFn };

至此,JS 创建的弹窗从创建到销毁,全生命周期都实现了

定义 MyDialog.open 函数

如果我们的弹窗组件叫 MyDialog.vue,那么我们新建一个 MyDialog.ts,在里面引入 MyDialog.vueComponent,再往 Component 上挂 open 函数

示例代码如下:

import MyDialog from "./MyDialog.vue";

MyDialog.open = () => {
  return xxx;
};
export default MyDialog;

MyDialog.open 函数有哪些参数?

上面介绍了 Vue3Vue2 下的实现方式:

Vue3 由于 createVNode 时需要传入当前上下文,而 getCurrentInstance 只能在 vue 组件中调用(在 MyDialog.ts 中调用,会返回 null),所以 MyDialog.open 函数还需要传入 instance 参数

(props, instance) => Promise<{ componentRef; destroyFn }>;

Vue2 不需要依赖上下文,所以 props 一个参数就够了。

(props) => Promise<{ componentRef; destroyFn }>;

Dialog.open 的全部实现如下:

Vue3

MyDialog.ts

import { render, createVNode } from "vue";
import MyDialog from "./MyDialog.vue";

MyDialog.open = (props, instance) => {
  const container = document.createElement("div");
  container.id = `dialog-container-${new Date().getTime()}`;

  const destroyFn = () => {
    container.remove();
  };
  const okFn = () => {
    if (typeof props.callback.ok === "function") {
      props.callback.ok();
    }
  };
  const cancelFn = () => {
    if (typeof props.callback.cancel === "function") {
      props.callback.cancel();
    }
    destroyFn();
  };

  const promise = new Promise((resolve) => {
    const vnode = createVNode(
      MyDialog,
      {
        name: "my-dialog",
        title: props.title,
        width: props.width,
        component: props.component,
        event: props.event,
        props: props.props,
        ok: okFn,
        cancel: cancelFn,
        init: (comRef) => {
          resolve(comRef);
        },
      },
      null
    );

    if (instance) {
      vnode.appContext = instance.appContext;
    }

    render(vnode, container);

    document.body.appendChild(container);
  });
  const componentRef = await promise;

  return { componentRef, destroyFn };
};

MyDialog.vue

template

<template>
  <el-dialog :title="props.title" v-model="visible">
    <component
      ref="componentRef"
      v-bind="props.props"
      v-on="props.event"
      :is="component"
    />
    <template #footer>
      <el-button @click="okFn()">确定</el-button>
      <el-button @click="cancelFn">取消</el-button>
    </template>
  </el-dialog>
</template>

script setup

import { ref, onMounted, nextTick } from "vue";

const componentRef = ref();
const visible = ref(true);

const props = defineProps({
  title: String,
  width: String,
  component: Object,
  event: Object,
  props: props.props,
  ok: Function,
  cancel: Function,
  init: Function,
});
async function open() {}

const okFn = () => {
  if (typeof props.ok === "function") {
    props.ok();
  }
};
const cancelFn = () => {
  visible.value = false;
  if (typeof props.cancel === "function") {
    props.cancel();
  }
};
const getCustomRef = () => {
  return componentRef.value;
};

onMounted(() => {
  nextTick(() => {
    console.log("my dialog mounted");
    props.init(componentRef.value);
  });
});
defineExpose({
  open,
  getCustomRef,
});

Vue2

MyDialog.js

import Vue from "vue";
import MyDialogVue from "./MyDialog.vue";

export default class MyDialog {
  static open(props) {
    let currentApp = null;
    const destroyFn = () => {
      const app = currentApp;
      if (!app) {
        return;
      }

      app.$destroy();
      app.$el.remove();
    };
    const okFn = () => {
      if (typeof props.callback.ok === "function") {
        props.callback.ok();
      }
    };
    const cancelFn = () => {
      if (typeof props.callback.cancel === "function") {
        props.callback.cancel();
      }
      destroyFn();
    };

    const promise = new Promise((resolve) => {
      const MyDialogInstance = Vue.extend(MyDialogVue);
      const app = new MyDialogInstance({
        propsData: {
          name: "my-dialog",
          title: props.title,
          width: props.width,
          component: props.component,
          event: props.event,
          props: props.props,
          ok: okFn,
          cancel: cancelFn,
          init: (comRef) => {
            resolve(comRef);
          },
        },
      }).$mount();
      currentApp = app;

      document.body.appendChild(app.$el);
    });
    const componentRef = await promise;

    return { componentRef, destroyFn };
  }
}

MyDialog.vue

template

<template>
  <el-dialog :title="title" :visible="visible" @close="cancelFn">
    <component
      ref="componentRef"
      v-bind="props.props"
      v-on="event"
      :is="component"
    />
    <template #footer>
      <el-button @click="okFn">确定</el-button>
      <el-button @click="cancelFn">取消</el-button>
    </template>
  </el-dialog>
</template>

script

export default {
  props: {
    title: String,
    width: String,
    component: Object,
    event: Object,
    props: Object,
    ok: Function,
    cancel: Function,
    init: Function,
  },
  data() {
    return {
      visible: true,
    };
  },
  mounted() {
    this.$nextTick(() => {
      console.log("my dialog mounted");
      this.init(this.$refs.componentRef);
    });
  },
  methods: {
    okFn() {
      if (typeof this.ok === "function") {
        this.ok();
      }
    },
    cancelFn() {
      this.visible = false;
      if (typeof this.cancel === "function") {
        this.cancel();
      }
    },
    getCustomRef() {
      return this.$refs.componentRef;
    },
  },
};

验证

Vue3 为例,验证单个弹窗和嵌套弹窗的场景。

单个弹窗

传入 param test 参数,内部组件包含一个表单,有个叫 name2 的属性,初始值 111

测试代码

const okFn = () => {
  console.log("click ok");
  const comRef = temp.componentRef;
  const data = comRef.getData();
  console.log(data);
  setTimeout(() => {
    console.log("begin destroy dialog.");
    temp.destroyFn();
  }, 3000);
};
const temp = await MyDialog.open(
  {
    title: "测试弹窗",
    width: "",
    component: InnerContent,
    props: {
      test: "param test",
    },
    event: {
      success: (val) => {
        console.log(val);
      },
    },
    callback: {
      ok: okFn,
      cancel: cancelFn,
    },
  },
  instance
);

console.log(temp);

运行效果

20240706_194624_image.png

点击“成功”按钮,触发自定义组件的事件

20240706_194813_image.png

嵌套弹窗

有个外部弹窗,里面有个按钮,点击打开内部弹窗

内部弹窗是个表单,属性 name2,值为 111

测试代码

外部弹窗

const okFn = () => {
  console.log("click ok");
  const comRef = temp.componentRef;
  const data = comRef.getData();
  console.log("outer data: ", data);
  setTimeout(() => {
    console.log("begin destroy outer dialog.");
    temp.destroyFn();
  }, 3000);
};
const temp = await MyDialog.open(
  {
    title: "外部弹窗",
    width: "",
    component: NestContent,
    props: {},
    callback: {
      ok: okFn,
      cancel: cancelFn,
    },
  },
  instance
);

console.log(temp);

内部弹窗

const okFn = () => {
  console.log("click inner ok");
  const comRef = temp.componentRef;
  const data = comRef.getData();
  console.log("inner data: ", data);
  outerData.value = data;
  setTimeout(() => {
    console.log("begin destroy inner dialog.");
    temp.destroyFn();
  }, 3000);
};
const temp = await MyDialog.open(
  {
    title: "内部弹窗",
    width: "",
    component: InnerContent,
    event: {
      success: (val) => {
        console.log("inner event success. ", val);
      },
    },
    props: {
      test: "inner param test",
    },
    callback: {
      ok: okFn,
      cancel: cancelFn,
    },
  },
  instance
);

运行效果

外部弹窗

20240706_191225_image.png

内部弹窗

20240706_195154_image.png

组件状态

20240706_195412_image.png