业务背景
我们做后台管理系统的时候,通常会有很多重复的页面逻辑,重复的控制流程需要做,最常见的例子应该便是确认删除。

基本使用
以 element-plus 举例,大家通常都是直接使用:
typescript
const handleDelete = async (id: number) => {
try {
await ElMessageBox.confirm("确定要删除这条数据吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
});
console.log("请求参数:", id);
await api();
ElMessage.success("删除成功");
} catch (error) {
if (error === "cancel") return;
}
};
重复冗余代码
这种方式乍一看没啥问题,但在实际业务开发中,并不是只有一个删除的业务流程。也许还会存在 支付 、发布 、修改状态 等等流程确认操作。此时我们的代码也许就会演变成
typescript
const handleDelete = async () => {
try {
await ElMessageBox.confirm("确定要删除这条数据吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
});
// 删除中...
} catch (error) {
if (error === "cancel") return;
}
};
const handlePayment = async () => {
try {
await ElMessageBox.confirm("是否确认支付当前订单?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
});
// 支付中...
} catch (error) {
if (error === "cancel") return;
}
};
const handleUpdateStatus = async () => {
try {
await ElMessageBox.confirm("是否确认修改当前数据状态?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
});
// 修改状态...
} catch (error) {
if (error === "cancel") return;
}
};
createConfirmFlow 函数封装
写到这里大家就会发现,整个项目中会有大量类似这种结构的重复代码,聪明的小伙伴已经开始对这段代码进行提取封装优化了。于是会得到:
typescript
import { ElMessageBox, type ElMessageBoxOptions } from "element-plus";
type CallBack = (...args: any[]) => Promise<void> | void;
const createConfirmFlow = async (
callback: CallBack,
options?: ElMessageBoxOptions
) => {
return async (...args: Parameters<CallBack>) => {
try {
await ElMessageBox.confirm(options.message, options);
await callback(...args);
} catch (error) {
if (error === "cancel") return;
console.error(error);
}
};
};
此时使用 createConfirmFlow 来创建流程处理函数就会变得无比简洁
typescript
// 创建删除流程
const handleDelete = createConfirmFlow(
async () => {
// 执行修改状态的逻辑...
},
{ message: "确定要删除这条数据吗?", title: "提示" }
);
// 创建支付流程
const handlePayment = createConfirmFlow(
async () => {
// 执行修改状态的逻辑...
},
{ message: "是否确认支付当前订单?", title: "提示" }
);
// 创建更新状态流程
const handleUpdateStatus = createConfirmFlow(
async () => {
// 执行修改状态的逻辑...
},
{ message: "是否确认修改当前数据状态?", title: "提示" }
);
业务场景思考
基本使用基本上是没有什么大问题。但是在业务开发中,我习惯对业务需求做一个大致的流程梳理,以便优化自己的代码。以下是我自己的一些想法。
支付前置验证
我想大多数人第一反应应该就是在流程处理函数中支付接口请求前验证一次即可,我们来试一下。
typescript
const validate = () => {
return false;
};
const handlePayment = createConfirmFlow(
async (row) => {
console.log("发起支付", row);
if (!validate()) {
ElMessage.error("验证失败,无法支付");
throw new Error("验证失败");
}
await api();
ElMessage.success("支付成功");
},
{
title: "支付提示",
message: `总价为 ¥188 是否确认支付?`,
}
);

我们会发现虽然验证函数确实成功拦截了后续接口请求的操作,但是确认框却关闭了。我认为这样的交互流程是比较意外的,既然是失败的话我期望的是这个确认窗口能保持显示。等待用户手动关闭即可。 当然了,这个属于小问题,大多数情况下应该是会忽略不管,每个人的看法不一。我这里只当是抛砖引玉,大家可以根据实际情况考虑。
解决方案
解决方案也很简单,ElMessage 为我们提供了 beforeClose 函数来处理关闭前的情况,只需要将验证函数提取到 beforeClos 中即可。
typescript
const validate = () => {
return false;
};
const handlePayment = createConfirmFlow(
async (row) => {
console.log("发起支付", row);
await api();
ElMessage.success("支付成功");
},
{
title: "支付提示",
message: `总价为 ¥188 是否确认支付?`,
beforeClose: async (action, instance, done) => {
if (action === "confirm") {
console.log("先干点别的");
instance.confirmButtonLoading = true;
instance.confirmButtonText = "支付中...";
await sleep(3000);
if (!validate()) {
ElMessage.error("验证失败,无法支付");
instance.confirmButtonLoading = false;
instance.confirmButtonText = "OK";
throw new Error("验证失败");
}
}
done();
},
}
);
这样就达到我想要的效果了

动态的 message
还是支付为例,获取订单中的商品数量跟价格,计算后赋值给 message 后再创建流程处理函数打开弹窗。
typescript
// row => { id: 2, name: "李四", quantity: 10, unitPrice: 88.0, ...... }
const handlePayment = (row: User) => {
const execute = createConfirmFlow(
async () => {
console.log("发起支付", row.quantity * row.unitPrice);
await api();
ElMessage.success("支付成功");
},
{
title: "支付提示",
message: `总价为 ¥${row.quantity * row.unitPrice} 是否确认支付?`,
beforeClose: async (action, instance, done) => {
if (action === "confirm") {
console.log("先干点别的");
instance.confirmButtonLoading = true;
instance.confirmButtonText = "支付中...";
await sleep(3000);
if (Math.random() < 0.5) {
ElMessage.error("验证失败,无法支付");
instance.confirmButtonLoading = false;
instance.confirmButtonText = "OK";
throw new Error("验证失败");
}
}
done();
},
}
);
execute();
};

自定义 message
自定义 message 实际上跟 ElMessageBox 一样,给 message 参数传入一个 VNode 即可。还是以上面支付的例子扩展一下。
typescript
import { h } from "vue"
const handlePayment = (row: User) => {
// + 使用 h 创建一个 VNode
const MessageVNode = h("p", [
"总价为",
h(
"strong",
{ style: "color: red;" },
` ¥${row.quantity * row.unitPrice} `
),
"是否确认支付?",
]);
const execute = createConfirmFlow(
async () => {
console.log("发起支付", row.quantity * row.unitPrice);
await api();
ElMessage.success("支付成功");
},
{
title: "支付提示",
message: MessageVNode, // 将创建的 VNode 传给 message
beforeClose: async (action, instance, done) => {
if (action === "confirm") {
console.log("先干点别的");
instance.confirmButtonLoading = true;
instance.confirmButtonText = "支付中...";
await sleep(3000);
if (Math.random() < 0.5) {
ElMessage.error("验证失败,无法支付");
instance.confirmButtonLoading = false;
instance.confirmButtonText = "OK";
throw new Error("验证失败");
}
}
done();
},
}
);
execute();
};

自定义确认组件
在实际开发中,可能确认流程内部封装的组件不一定适用所有业务需要,也许在同样的流程中,会有不同的 UI 展示效果,也许需要使用自定义的确认组件。所以为了我们封装的这个流程复用工具函数能够支持自定义的 UI 组件,我需要对他做以下扩展。
需要能扩展自定义组件的功能之前,需要抽象一些公共部分,并且约束自定义组件必须具备的特性,交给我们封装好的函数使用。
typescript
// 1. 声明自定义组件必须具备的特性
// Message 参考 ElMessagebox 的类型
export type Message = string | VNode | (() => VNode);
type CallBack = (...args: any[]) => Promise<void> | void;
// 声明配置类型,继承 ElMessageBoxOptions 的类型作为基本配置,并扩展我们自己的必须的类型
export interface ConfirmFlowOptions extends ElMessageBoxOptions {
message?: Message;
title?: string;
CustomComponent?: CustomConfirmMessage | Record<string, any>;
[key: string]: any;
}
// 这个是自定义组件的类型标注,必须要实现 confirm 函数
export interface CustomConfirmMessage {
confirm: (
message: ElMessageBoxOptions["message"],
options?: ConfirmFlowOptions
) => Promise<any>;
}
// 辅助校验自定义组件是否存在 confirm 函数
const isConfirmMessage = (obj: unknown): obj is CustomConfirmMessage =>
isObject(obj) && !isNull(obj) && "confirm" in obj && isFunction(obj.confirm);
// 获取自定义组件构造器,核心目的也是为了调用 confirm
const getMessageConstructor = (options: ConfirmFlowOptions = {}) => {
const { CustomComponent } = options;
if (isConfirmMessage(CustomComponent)) {
return CustomComponent;
}
return ElMessageBox;
};
export const createConfirmFlow = (
callback: CallBack,
options?: ConfirmFlowOptions
) => {
return async (...args: Parameters<CallBack>) => {
// + 获取流程确认组件
const ConfirmMessage = getMessageConstructor(options);
try {
await ConfirmMessage.confirm(options?.message, options);
await callback(...args);
// 修改状态...
} catch (error) {
if (error === "cancel") return;
console.error(error);
}
};
};
上述代码描述了自定义组件必须具备的函数,并且统一了添加了 getMessageConstructor 函数来获取消息组件。可以是自定义的,也可以是默认的 ElMessageBox
- 下面我写一个基本的自定义弹窗确认组件来演示这个自定义 ui 组件的功能是否正常
- 先创建一个 CustomConfirmDialog.vue组件
typescript
<template>
<el-dialog v-model="visible" :title="title" width="40%">
<p>{{ message }}</p>
<template #footer>
<el-button @click="cancel">取消</el-button>
<el-button type="primary" :loading="loading" @click="confirm">
{{ confirmText || "确定" }}
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { getCurrentInstance, ref } from "vue";
import type { Action } from "./customConfirmDialog";
const props = defineProps<{
title?: string;
message?: string;
confirmText?: string;
beforeClose?: Function;
}>();
const emit = defineEmits<{
(e: "action", action: Action): void;
}>();
const root = getCurrentInstance();
const visible = defineModel({ default: false });
const loading = ref(false);
const confirm = async () => {
await props.beforeClose?.("confirm", root?.exposed || {}, () => {
visible.value = false;
});
emit("action", "confirm");
};
const cancel = async () => {
await props.beforeClose?.("cancel", root?.exposed || {}, () => {
visible.value = false;
});
emit("action", "cancel");
};
const open = () => {
visible.value = true;
};
const close = () => {
visible.value = false;
};
defineExpose(
new Proxy(
{
open,
close,
confirmButtonLoading: loading.value,
},
{
get: (target, key, receiver) => {
return Reflect.get(target, key, receiver);
},
set: (target, key, value, receiver) => {
const result = Reflect.set(target, key, value, receiver);
if (key === "confirmButtonLoading") {
loading.value = value;
}
return result;
},
}
)
);
</script>
- 添加一个 customConfirmDialog.ts 将弹窗组件按命令式弹窗的方式使用 confirm 来创建。
typescript
import CustomConfirmDialogConstructor from "./CustomConfirmDialog.vue";
import type { ConfirmFlowOptions, CustomConfirmMessage } from "@/utils";
import type { ElMessageBoxOptions } from "element-plus";
import {
createVNode,
render,
type AppContext,
type ComponentInternalInstance,
} from "vue";
export type Action = "confirm" | "cancel";
const instanceMap = new Map<
ComponentInternalInstance,
{
options: any;
resolve: (res: any) => void;
reject: (reason?: any) => void;
}
>();
class CustomConfirmDialog implements CustomConfirmMessage {
async confirm(
message: ElMessageBoxOptions["message"],
options?: ConfirmFlowOptions,
appContext?: AppContext
) {
return new Promise((resolve, reject) => {
const instance = this.createInstance({ message, ...options }, appContext);
instanceMap.set(instance, { options, resolve, reject });
});
}
createInstance(options: ConfirmFlowOptions, appContext?: AppContext) {
const container = document.createElement("div");
const instance = this.initInstance(options, container, appContext)!;
instance.exposed?.open();
return instance;
}
initInstance(
props: any,
container: HTMLElement,
appContext: AppContext | null = null
) {
const vnode = createVNode(CustomConfirmDialogConstructor, props);
vnode.appContext = appContext;
render(vnode, container);
document.body.appendChild(container.firstElementChild!);
return vnode.component;
}
}
export default new CustomConfirmDialog();
- 页面使用验证功能是否正常
typescript
import { CustomConfirmDialog } from "./CustomConfirmDialog";
import { createConfirmFlow } from "@/utils/business";
// 自定义确认组件
const handleSensitiveOp = createConfirmFlow(
async () => {
await api();
ElMessage.success("操作成功");
},
{
CustomComponent: CustomConfirmDialog,
message: "此操作包含敏感数据修改,是否继续?",
title: "敏感操作确认",
beforeClose: async (action, instance, done) => {
if (action === "confirm") {
instance.confirmButtonLoading = true;
await sleep(2000); // 模拟异步操作
instance.confirmButtonLoading = false;
}
done();
},
}
);

完全没有问题。至此也就完成了这样一个流程复用的工具函数。
confirmFlow 函数完整代码
typescript
import { ElMessageBox, type ElMessageBoxOptions } from "element-plus";
import { isFunction, isNull, isObject } from "lodash-es";
import type { VNode } from "vue";
export type Message = string | VNode | (() => VNode);
type CallBack = (...args: any[]) => Promise<void> | void;
export interface ConfirmFlowOptions extends ElMessageBoxOptions {
message?: Message;
title?: string;
CustomComponent?: CustomConfirmMessage | Record<string, any>;
[key: string]: any;
}
export interface CustomConfirmMessage {
confirm: (
message: ElMessageBoxOptions["message"],
options?: ConfirmFlowOptions
) => Promise<any>;
}
const isConfirmMessage = (obj: unknown): obj is CustomConfirmMessage =>
isObject(obj) && !isNull(obj) && "confirm" in obj && isFunction(obj.confirm);
const getMessageConstructor = (options: ConfirmFlowOptions = {}) => {
const { CustomComponent } = options;
if (isConfirmMessage(CustomComponent)) {
return CustomComponent;
}
return ElMessageBox;
};
export const createConfirmFlow = (
callback: CallBack,
options?: ConfirmFlowOptions
) => {
return async (...args: Parameters<CallBack>) => {
const ConfirmMessage = getMessageConstructor(options);
try {
await ConfirmMessage.confirm(options?.message, options);
await callback(...args);
// 修改状态...
} catch (error) {
if (error === "cancel") return;
console.error(error);
}
};
};
总结 & 问题
- 示例中的自定义命令式弹窗组件有一个问题,就是没有卸载组件,每次调用 confirm 时都会创建一个新的 dialog 而旧的未销毁,大家也可以分享一下该如何处理,或指正一下我是否写的有问题。我用 render(null,container) 跟 container.remove() 也无法销毁。
- 大家会发现实际上封装一个这样的流程处理函数并不是难事,难的是我们在开发的过程中是否能够想到这些代码优化的方式。我也是看到了其他大佬的分享才学到了这些知识并应用在了实际工作中。如有问题大家一起讨论学习。后续我还会出一个通用的错误流程处理函数跟这个是类似的。
- 大家如果有一些工作中比较实用的一些编码思维等等,也可以一起分享出来学习学习。