这才是vue弹窗组件的正确使用姿势!
前言
Dialog
组件是我们日常开发中使用最多的组件之一,怎样使用才能让代码更加优雅是我们一直讨论的问题,特别是现在Vue
的CompositionApi
更加方便我们逻辑的拆分,在这种背景下,我们怎么更好的使用弹窗才能使代码更加清晰呢?
日常开发现状
通过状态去驱动弹窗的状态
思考
如果按上面的做法,每写一个弹窗组件则父组件里面需要引入组件
、在模板中使用组件
、声明visible
、声明data
、声明点击显示以及关闭的事件
。虽然这样看似没什么问题,但如果弹窗有多个呢?visible1、visible2...
吗?业务再复杂点呢?这简直是恐怖片,毕竟开发过程中最困扰我的就是变量的命名。
那我们要怎么去优化这些痛点呢?其实visible
的值到底是什么我们并不关注,我们只需要显示关闭的方法
而已,而data
这个变量一般都是为了将数据传给Dialog
组件使用而已,如果可以直接传给子组件
的话,我们都不需要这个变量。当然,如果可以不在父组件引入这些Dialog
组件那就更好了,这样逻辑拆分就可以更加彻底.
我期望的
- 尽可能少的命名变量。
- 使用一个函数暴露出来关闭/显示的方法。
- 不需要声明一个变量来传递数据给
Dialog组件
。 - 是否能不在模板中挂载组件,这样逻辑更加聚焦,可以的话一个
hook
就是一个弹窗相关的所有代码,而使用者只需要关注怎么关闭/打开弹窗就行。 - 类型推导更加便捷,因为我是
typescript
重度患者
实现思路
封装一个hook
管理弹窗的显示/隐藏,并且暴露出开启关闭的方法。同时将弹窗的内容用函数来渲染(h函数
或者JSX
都行,重点推荐JSX
)。
那么还有一个问题,弹窗不想主动挂载到模板中要怎么实现?这里我借鉴了大多组件库的思路,将大量公共的东西使用Provide
挂载在顶级节点。每次使用hook
就会主动在最顶层创建一个弹窗,并且管理起来。
实现方案
// Provider.tsx
import { defineComponent, ref, provide } from 'vue';
import { ElDialog, ElButton } from 'element-plus';
import {
IDialogRefsItem,
ICreateDialogOptions,
DialogInjectProps,
} from './type';
export default defineComponent({
name: 'Provider',
components: {
ElDialog,
ElButton,
},
setup() {
const dialogRefs = ref<IDialogRefsItem[]>([]);
const createId = () => `${Date.now()}_${Math.random().toString(16)}`;
const create = (config: () => ICreateDialogOptions) => {
const id = createId();
dialogRefs.value.push({
id,
config,
visible: false,
});
return id;
};
const open = (id: string) => {
const row = dialogRefs.value.find(v => v.id === id);
if (row) {
row.visible = true;
}
};
const close = (id: string) => {
const row = dialogRefs.value.find(v => v.id === id);
if (row) {
row.visible = false;
const { onClose } = row.config();
onClose?.();
}
};
const destroy = (id: string) => {
const index = dialogRefs.value.findIndex(v => v.id === id);
if (index !== -1) {
dialogRefs.value.splice(index, 1);
}
};
provide<DialogInjectProps>('DIALOG_INJECT_KEY', {
create,
open,
close,
destroy,
});
return {
dialogRefs,
close,
};
},
render() {
return (
<>
{this.$slots?.default?.()}
{this.dialogRefs.map((v) => {
const { config, id, visible } = v;
const {
showCancelBtn = true,
showConfirmBtn = true,
confirmText = '确定',
cancelText = '取消',
onConfirm = () => {},
render,
renderHeader,
renderFooter,
content,
onClose,
onCancel = () => {
this.close(id);
},
...rest
} = config();
return (
<el-dialog
{...rest}
model-value={visible}
onClose={() => this.close(id)}
v-slots={{
default: render ?? (() => content),
header: renderHeader ?? undefined,
footer:
renderFooter ?? (() => (
<>
{showCancelBtn && (
<el-button onClick={onCancel}>{cancelText}</el-button>
)}
{showConfirmBtn && (
<el-button type='primary' onClick={onConfirm}>
{confirmText}
</el-button>
)}
</>
)),
}}
/>
);
})}
</>
);
},
});
// useDialog.ts
import { inject, onBeforeUnmount } from 'vue';
import {
DialogInjectProps,
ICreateDialogOptions,
} from './type';
export const useDialog = (options: () => ICreateDialogOptions) => {
const dialogInjectProps = inject<DialogInjectProps>('DIALOG_INJECT_KEY');
const dialogId = dialogInjectProps?.create(options);
if (!dialogInjectProps) {
console.error('useDialog需要配合DialogProvider使用');
}
/** 打开弹窗 */
const openDialog = () => {
if (dialogId) {
dialogInjectProps?.open(dialogId);
}
};
/** 关闭弹窗 */
const closeDialog = () => {
if (dialogId) {
dialogInjectProps?.close(dialogId);
}
};
/** 组件销毁时销毁弹窗 */
onBeforeUnmount(() => {
if (dialogId) {
dialogInjectProps?.destroy(dialogId);
}
});
return {
openDialog,
closeDialog,
};
};
// type.ts
import type { DialogProps } from 'element-plus';
export interface ICreateDialogOptions extends DialogProps {
/** Dialog 对话框 Dialog 的内容 */
content?: string;
/** 当关闭 Dialog 时,销毁其中的元素 */
render?: () => JSX.Element | null | string | number | JSX.Element[];
/** 当关闭 Dialog 时,销毁其中的元素 */
renderHeader?: () => JSX.Element | null | string | number | JSX.Element[];
/** 当关闭 Dialog 时,销毁其中的元素 */
renderFooter?: () => JSX.Element | null | string | number | JSX.Element[];
/** 弹窗关闭回调事件 */
onClose?: () => void;
/** 确定按钮文案 */
confirmText?: string;
/** 点击确定按钮 */
onConfirm?: () => void;
/** 取消按钮文案 */
cancelText?: string;
/** 点击取消按钮 */
onCancel?: () => void;
/** 是否显示确定按钮 */
showConfirmBtn?: boolean;
/** 是否显示取消按钮 */
showCancelBtn?: boolean;
}
export interface IDialogRefsItem {
id: string;
visible: boolean;
config: () => ICreateDialogOptions;
}
export interface DialogInjectProps {
/** 创建弹窗 */
create: (options: () => ICreateDialogOptions) => string;
/** 打开弹窗 */
open: (id: string) => void;
/** 关闭弹窗 */
close: (id: string) => void;
/** 销毁弹窗 */
destroy: (id: string) => void;
}
效果展示
在App.vue
挂载Provider
<template>
<Provider>
<router-view />
</Provider>
</template>
父组件
<template>
<el-button @click="handleUpdate">修改</el-button>
</template>
<script>
const { handleUpdate } = useUpdate();
</script>
对应编辑的hook
export const useUpdate = () => {
// 表单数据
const formData = ref({
userId: '',
username: '',
});
const { openDialog, closeDialog } = useDialog(() => ({
title: '编辑账号',
width: 600,
render() {
return (
<el-form />
);
},
}));
const handleUpdate = async (data) => {
formData.value.userId = data.userId;
formData.value.username = data.username ?? '';
openDialog();
};
return {
handleUpdate
};
};
结语
本方案未必是最优解,只是暂时对我来说相对比较方便,当然还有一些小问题,比如因为挂载在Provider
下导致ref='el'
的语法糖失效,还有作用域失效,在render
中非全局引入组件只能直接引入组件使用(<x-button/>
无法resolve
,只能<XButton/>
),具体要怎么解决有兴趣的可以探索一下,类似element-plus
的MessageBox
中的修改appContext
的方法或许可行。
转载自:https://juejin.cn/post/7359893250188378175