提升Vue 3开发效率:命名式弹窗的终极解决方案
背景
命令式弹窗,也称为函数式弹窗,是一种在Vue开发中动态控制弹窗显示与隐藏的方法。与传统的声明式弹窗不同,后者通常在模板中预先定义 <dialog> 元素,并通过数据属性控制其可见性。然而,在某些情况下,如在工具函数、hooks或插件中封装弹窗时,我们无法直接在模板中操作 <dialog> 元素,或者使用声明式弹窗会使代码显得冗余。在这些场景下,命令式弹窗因其灵活性和简洁性而成为更佳选择。
常规声明式弹窗使用方法
以下是使用Element Plus弹窗组件的一个常见示例,这种方法虽然普遍,但也存在一些不足之处。主要问题是,每个对话框都需要一个独立的变量来控制其显示与隐藏状态。如果仅仅是多维护一个变量,这或许还可以接受。然而,当一个Dialog组件既要在父组件中控制显示与隐藏,又要在子组件中进行控制时,项目中就会充斥着大量类似的变量,导致代码变得冗余和难以管理。
<script lang="ts" setup>
import { ref } from 'vue'
import { ElMessageBox } from 'element-plus'
const dialogVisible = ref(false)
</script>
<template>
<el-button plain @click="dialogVisible = true">
Click to open the Dialog
</el-button>
<el-dialog
v-model="dialogVisible"
:before-close="handleClose"
>
<span>This is a dialog content</span>
</el-dialog>
</template>
el-dialog
本身是不支持命令式调用的。
命令式弹窗
命令式弹窗应该是什么样的形式呢?我们拿element-plus来说明。
<script lang="ts" setup>
import { ElMessage, ElMessageBox } from 'element-plus'
import type { Action } from 'element-plus'
const open = () => {
ElMessageBox.alert('This is a message', 'Title', {
confirmButtonText: 'OK',
callback: (action: Action) => {
ElMessage({
type: 'info',
message: `action: ${action}`,
})
},
})
}
</script>
<template>
<el-button plain @click="open">Click to open the Message Box</el-button>
</template>
ElMessageBox.alert()
这种形式就是一个命令式弹窗的调用方式,但是ElMessageBox只能支持简单的内容弹窗。当我们的弹窗内容比较复杂时如何使用命令式弹窗呢?
ElMessageBox的VNode用法和html片段的方法也是无法满足我们的复杂弹窗需求的
这个时候就需要我们自己封装一些方法来比较优雅/方便的使用弹窗。
命令式弹窗理想的使用效果
<script setup lang="ts">
import { ElButton } from 'element-plus';
import Comp from 'components/Comp.vue';
import MyDialog from 'components/MyDialog.vue';
// 不需要额外的变量控制弹窗
const handleOpenDialog = () => {
// 处理 MyDialog
};
</script>
<template>
<div>
<ElButton @click="handleOpenDialog"> 打开弹窗 </ElButton>
<div>其他内容。。。</div>
</div>
// 不需要将MyDialog声明到template中
</template>
四个重点:
• 简化父组件操作:使用对话框时,父组件无需额外定义变量来控制对话框的显示与隐藏。
• 减少模板冗余:无需在模板中声明对话框,从而让模板更加简洁。
• 独立封装对话框:对话框组件可以作为一个独立的单文件组件(.vue)进行封装,这是最直接且常用的封装方式。
• 直接在函数中处理对话框逻辑:对话框的所有相关处理可以直接在函数中实现,简化了代码结构。
实现方法探讨命令式对话框的实现方式多种多样,以下是一些常见的方法:
方法一
// showMyDialog.ts文件
import { createApp } from 'vue'
import MyDialog from 'components/MyDialog.vue';
const showMyDialog = () => {
const div = document.createElement('div');
document.body.appendChild(div);
const app = createApp(MyDialog);
app.mount(div)
}
export default showMyDialog
MyDialog组件与showMyDialog是两个文件,增加了维护的成本。
方法二
利用.tsx文件特性,将Dialog和showDialog合并到一个文件。
同时利用@styils/vue
来方便写元素的样式。
// MyDialog.tsx文件。
import { createApp } from "vue";
import { ElButton } from "element-plus";
import { styled } from "@styils/vue";
const DivModal = styled('div', {
position: 'fixed',
width: '100%',
height: '100%',
// 其他css
});
const DivBox = styled('div', {
display: 'flex',
minWidth: '30%',
});
const DivText = styled('div', {
marginBottom: '1em'
});
const DialogBox = {
props: {
msg: {
type: String,
required: true
},
},
render(ctx: any) {
const { $props, $emit } = ctx;
return (
<DivModal class= "modal" >
<DivBox class="box" >
<DivText class="text" > { $props.msg } </DivText>
<div onClick = { $emit('onClick(e)') } >
<ElButton type="primary" > 确 定 </ElButton>
</div>
</DivBox>
</DivModal>
);
},
};
export function showDialog(props) {
const div = document.createElement("div");
document.body.appendChild(div);
const app = createApp(DialogBox,
{
...props,
}
);
app.mount(div);
};
这种方法看似挺好,也解决了命令式弹窗的一些问题。但是仔细想想,也存在以下一些问题。
• 一致性考量:虽然使用`.tsx`文件的方法在某些情况下能提供便利,但它可能与项目中普遍采用的模板(`template`)编写风格不一致。为了保持团队规范的统一性,引入这种新写法可能需要额外的考量和讨论。
• 写法便捷性:尽管`.tsx`的写法为对话框提供了一种新的可能性,但在处理多个对话框组件时,这种方法可能会显得不够高效。考虑到项目中对话框组件的频繁使用,寻找一种更简洁的解决方案是提高开发效率的关键。
• 兼容性与重构:现有的声明式对话框与新提出的命令式对话框之间可能存在兼容性问题。全面转向新的实现方式可能需要对现有代码进行重构,这是一个需要评估的工作量和成本。
终极方法思考
• 超越现有解决方案:虽然上述方法已经有效地解决了命令式对话框的问题,但我们是否可以进一步优化呢?让我们思考以下几个方面。
• 保持模板封装的便捷性:我们能否在不改变对话框组件的模板封装方式的前提下进行操作?因为这种写法是目前最为便捷和直观的。
• 实现命令式调用的灵活性:同时,我们也希望保持使用命令式方式调用对话框的能力,以增加开发的灵活性。
• 封装Hook的创新思路:为此,我们考虑封装一个Hook,或者将其实现在工具函数库(utils)中。这样的Hook能够将现有的业务对话框`MyDialog`转换为可以通过命令式方式调用的形式。
终极方法useDialog
封装
使用方法
<script setup lang="ts">
import { ElButton } from 'element-plus';
import MyDialog from 'components/MyDialog.vue';
const myDialog = useDialog(MyDialog);
const handleOpenDialog = () => {
// 打开弹窗
myDialog({
// 传入一些myDialog 需要的 props
title: "弹窗标题",
onSubmit: () => {
// 弹窗的回调处理,如果有的话
},
onClose: () =>{
// close 回调 , 如果需要的话
}
})
// myDialog.close()可以关闭弹窗
};
</script>
<template>
<div>
<ElButton @click="handleOpenDialog"> 打开弹窗 </ElButton>
<div>其他内容。。。</div>
</div>
// 不需要将MyDialog声明到template中
</template>
MyDialog 封装举例
通常在封装业务专用对话框组件时,我们会采用类似的方法,这样做既高效又便捷。
<script setup lang="ts" name="MyDialog">
const props = defineProps<{
visible: boolean;
title?: string;
onConfirm: (imgSrc: string, imgId: number) => void;
}>()
const emits = defineEmits<{
close: [],
}>()
const dialogVisible = ref(false)
// 取消选择
const cancel = () => {
dialogVisible.value = false
}
// 确认选择
const confirm = () => {
// 其他逻辑
props?.onConfirm()
cancel()
}
const dialogVisible = computed<boolean>({
get() {
return props.visible;
},
set(visible) {
emits('update:visible', visible);
if (!visible) {
emits('close');
}
},
});
</script>
<template>
<el-dialog
v-model="dialogVisible"
:title="props.title"
width="800"
:before-close="cancel"
>
<div>
弹窗内容
</div>
<template #footer>
<el-button @click="cancel">
取消
</el-button>
<el-button type="primary" @click="confirm">
确定
</el-button>
</template>
</el-dialog>
</template>
Dialog封装规范
props中含有visible
emits一个close事件
useDialog实现源码
import { AppContext, Component, ComponentPublicInstance, createVNode, getCurrentInstance, render, VNode } from 'vue';
export interface Options {
visible?: boolean;
onClose?: () => void;
appendTo?: HTMLElement | string;
[key: string]: unknown;
}
export interface DialogComponent {
(options: Options): VNode;
close: () => void;
}
const getAppendToElement = (props: Options): HTMLElement => {
let appendTo: HTMLElement | null = document.body;
if (props.appendTo) {
if (typeof props.appendTo === 'string') {
appendTo = document.querySelector<HTMLElement>(props.appendTo);
}
if (props.appendTo instanceof HTMLElement) {
appendTo = props.appendTo;
}
if (!(appendTo instanceof HTMLElement)) {
appendTo = document.body;
}
}
return appendTo;
};
const initInstance = <T extends Component>(
Component: T,
props: Options,
container: HTMLElement,
appContext: AppContext | null = null
) => {
const vNode = createVNode(Component, props);
vNode.appContext = appContext;
render(vNode, container);
getAppendToElement(props).appendChild(container);
return vNode;
};
export const useDialog = <T extends Component>(Component: T): DialogComponent => {
const appContext = getCurrentInstance()?.appContext;
if (appContext) {
const currentProvides = (getCurrentInstance() as any)?.provides;
Reflect.set(appContext, 'provides', { ...appContext.provides, ...currentProvides });
}
const container = document.createElement('div');
const close = () => {
render(null, container);
container.parentNode?.removeChild(container);
};
const DialogComponent = (options: Options): VNode => {
if (!Reflect.has(options, 'visible')) {
options.visible = true;
}
if (typeof options.onClose !== 'function') {
options.onClose = close;
} else {
const originOnClose = options.onClose;
options.onClose = () => {
originOnClose();
close();
};
}
const vNode = initInstance<T>(Component, options, container, appContext);
const vm = vNode.component?.proxy as ComponentPublicInstance<Options>;
for (const prop in options) {
if (Reflect.has(options, prop) && !Reflect.has(vm.$props, prop)) {
vm[prop as keyof ComponentPublicInstance] = options[prop];
}
}
return vNode;
};
DialogComponent.close = close;
return DialogComponent;
};
export default useDialog;
关键代码说明:
getAppendToElement
方法支持从props中传递appendTo
来自定义Dialog的挂载位置。默认挂载到document.body。initInstance
方法主要利用了createVNode和render函数将目标Dialog挂载到DOM树中,也就是渲染Dialog。createVNode
参考vue3官方文档:https://vuejs.org/guide/extras/render-function.html#creating-vnodes
getCurrentInstance()?.appContext
const appContext = getCurrentInstance()?.appContext;
if (appContext) {
const currentProvides = (getCurrentInstance() as any)?.provides;
Reflect.set(appContext, 'provides', { ...appContext.provides, ...currentProvides });
}
上面这段代码主要是兼容Provide / Inject场景,使数据不丢失。
另外getCurrentInstance方法在2021-08月份后的vue官方文档中已经移除了,但是该方法还是可以使用。
DialogComponent.close = close
将close方法挂载到DialogComponent上,方便myDialog.close()
这种方式直接关闭弹窗。
总结
将 useDialog 重命名为 useComponent 后,我们发现这个 Hook 不仅适用于对话框组件,还能广泛应用于其他类型的组件。实际上, useComponent 充当了一个命令式组件的桥梁,使得组件的使用更加灵活和方便。
评论区