背景
在日常开发中,使用到 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;
};
然而,这样还是存在几个问题:
- 需要自行维护
visible的状态,多个弹窗的状态要分别控制。这样会造成多处出现“模板代码”,开发起来重复琐碎,降低了开发效率。 - 弹窗中的自定义组件,状态不会随着弹窗关闭而重置,而是一直存在。(由于弹窗只是隐藏了,并没有销毁自定义组件)
我们希望有一种更优雅的方式来打开弹窗。
设计
如何设计出这样的一种打开弹窗的方式呢?
我们先参考一下类似的实现方案。
首先想到的就是和 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 Vue 的 Modal,也有提供纯 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 的回调 | 不能把内部的自定义内容,通过组件的方式传入 |
| 支持异步关闭 |
参考一下其他技术栈的组件库:Antd 的 Angular 实现--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:
- 能通过 JS 来弹出,不需要自行控制
visible的状态。 - 弹窗创建后,
visible就是true - 弹窗关闭后直接销毁
- 弹窗中的内容可以自定义
- 每次打开弹窗,自定义组件中的状态自动初始化。
- 可通过在业务代码中调用函数来销毁弹窗,支持异步关闭
- 支持嵌套弹窗(在父弹窗中调用
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-dialog 和 visible 封装进来。
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 上
分别考虑 Vue3 和 Vue2 的实现方式
Vue3 下:
- 创建一个
div节点 - 使用
createVNode来创建vnode节点 - 对 vnode 绑定当前的上下文
- 使用
render函数来把vnode渲染到div节点中 - 用
document.body.appendChild把div节点挂载到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 组件,如下图所示。
Vue2 下:
- 使用
Vue.extend基于MyDialog来继承出一个子类MyDialogInstance - 用
new创建子类MyDialogInstance的实例app,并调用$amount() - 用
document.body.appendChild把app.$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。
由于 component 是 Component 类型的,在 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.value 是 undefined
打印出自定义组件和 MyDialog 的 mounted 输出语句,发现
为啥 component 会在 MyDialog 后面 mounted 呢?
这就需要看 Vue 源码中component 的实现了。推测里面使用了 nextTick 或者 setTimeout 等异步操作,导致子组件的 mounted 时机延后.
解决
外面包一层 nextTick
onMounted(() => {
nextTick(() => {
console.log("my dialog mounted");
props.init(componentRef.value);
});
});
运行效果如下:
问题 2
生命周期钩子执行顺序正确,但是获取到的组件引用是 Null.
解决
想起来,component 组件是动态渲染的,挂载成功后才执行回调,赋值给临时变量。但是我们直接返回了临时变量。返回临时变量时,回调还没有执行。
考虑到异步渲染,我们改下 Dialog.open 函数的返回类型,返回 Promise<{componentRef,destroyFn}>
调用Dialog.open时,new 一个 promise 并返回。
啥时候 resolve 呢?就在执行回调函数 init 的时候。
执行回调函数 init 时如何获取到组件引用呢?
很简单,通过参数传进来即可。init 函数加参数:componentRef;resolve 时,传入组件引用 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 的值,结果如下:
已经可以获取到自定义组件 component 的引用了。
关闭并销毁弹窗
弹窗已经显示出来了,点击关闭后就马上销毁弹窗,如何销毁弹窗的 DOM 呢?
销毁弹窗在 Vue3 和 Vue2 下的做法如下:
Vue3
把创建时的 div 元素删掉
const container = document.createElement("div");
container.id = `dialog-container-${new Date().getTime()}`;
const destroyFn = () => {
container.remove(); // 这里通过闭包引用到创建弹窗时生成的div元素
};
Vue2
创建组件实例时,用临时变量保存下来,在销毁弹窗时,
- 执行组件实例的
$destroy函数 - 把组件元素的
$elremove掉
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.vue 的 Component,再往 Component 上挂 open 函数
示例代码如下:
import MyDialog from "./MyDialog.vue";
MyDialog.open = () => {
return xxx;
};
export default MyDialog;
MyDialog.open 函数有哪些参数?
上面介绍了 Vue3 和 Vue2 下的实现方式:
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);
运行效果
点击“成功”按钮,触发自定义组件的事件
嵌套弹窗
有个外部弹窗,里面有个按钮,点击打开内部弹窗
内部弹窗是个表单,属性 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
);
运行效果
外部弹窗
内部弹窗
组件状态