Vue 3 进阶实战:基于 Promise 的通用弹窗管理器(命令式调用)
🚀 前言
在 Vue 项目开发中,弹窗(Dialog/Modal/Drawer)是交互的核心载体。然而,随着业务复杂度的攀升,传统的声明式弹窗管理逐渐暴露出“样板代码多”、“状态分散”、“逻辑割裂”等痛点。
本文将介绍一种基于 Promise 驱动的命令式弹窗管理方案,它能让你像调用 API 一样从 JS/TS 中直接唤起弹窗,并获取用户操作结果,彻底告别繁琐的 visible 变量。
😫 痛点分析:传统模式的“四宗罪”
看看这段典型的父组件代码,你是否感到熟悉且头疼?
<template>
<!-- 满屏的弹窗占位 -->
<UserEditDialog v-model="showUserEdit" :id="curId" @success="refresh" />
<FilePicker v-model="showFilePicker" @confirm="onSelect" />
<ConfirmDialog v-model="showDelete" @confirm="doDelete" />
</template>
<script setup>
// 😭 状态爆炸:每个弹窗都要维护 visible 和 props
const showUserEdit = ref(false);
const showFilePicker = ref(false);
const showDelete = ref(false);
const curId = ref(0);
// 😭 逻辑割裂:打开是一个函数,回调又是另一个函数
const handleOpen = () => { showUserEdit.value = true; }
const refresh = () => { /* ... */ }
</script>核心痛点:
- 状态爆炸:每增加一个弹窗,就要增加一套
ref和props变量。 - 模板污染:组件即使未被使用,也必须在
<template>中预先挂载,影响 DOM 结构清晰度。 - 复用困难:想在 Pinia 或纯 JS 文件中唤起弹窗?传统方式几乎做不到。
- 流程割裂:触发逻辑与回调逻辑在代码空间上分离,难以阅读。
🛠️ 解决方案:Promise 驱动的命令式调用
我们借鉴 Element Plus ElMessageBox 的设计思想,实现一套通用的管理器。
调用体验对比:
// ✅ 优化后:像 await 异步函数一样流畅
const handleEdit = async (user) => {
try {
// 一行代码唤起,直到用户操作才继续往下执行
const formData = await modal.open({
component: UserEditDialog,
props: { userId: user.id }
});
// 拿到结果直接调用 API
await updateUser(formData);
Message.success('更新成功');
} catch (e) {
console.log('用户取消了操作');
}
}🧠 硬核解析:useModal 核心实现原理
为了让技术实现更透彻,我们需要像“手术刀”一样剖析 useModal 内部到底发生了什么。
1. 核心设计图解
整个管理器可以看作是一个 "Promise 句柄劫持中心"。
- 调用
open():创建一个Promise,但暂时不 resolve。 - 劫持句柄:将
resolve和reject函数提取出来,连同组件信息(Props, Component)挂载到一个响应式数组modals中。 - 渲染层响应:全局挂载的渲染组件监测到
modals变化,自动渲染对应的弹窗组件。 - 用户交互:用户在弹窗点击“确定”,触发渲染层的
confirm方法。 - 归还句柄:管理器找到对应的
resolve函数并执行,Promise 状态翻转,await结束,父组件拿到数据。
2. 代码实现 (Hooks)
先定义类型
// src/hooks/useModal/types.ts
import type { Component } from "vue";
/** 弹窗持久化配置 */
export interface PersistentOptions {
/** 手动指定唯一 key(推荐) */
key?: string;
/** 根据 props 动态生成 key */
keyGenerator?: (props: Record<string, any>) => string;
/** 全局单例模式(整个应用只有一个实例,无论 props 是什么) */
singleton?: boolean;
}
/** 打开弹窗参数 */
export interface OpenOptions {
/** 弹窗组件 */
component: Component;
/** props */
props?: Record<string, any>;
/** 是否常驻 */
persistent?: boolean | PersistentOptions;
}
/** 弹窗实例 */
export type ModalInstance<T = any> = {
modalId: symbol;
visible: boolean;
component: Component;
props?: Record<string, any>;
resolve: undefined | ((value: T) => void);
reject: undefined | ((reason?: any) => void);
};
/** 受控的弹窗组件基础props */
export interface BaseModalProps {
modalId: symbol;
}
/** 受控的弹窗组件基础emit事件 */
export interface BaseModalEmit {
confirm: [val: any];
cancel: [reason?: any];
}接着定义核心逻辑
// src/hooks/useModal/index.ts
import type { ModalInstance, OpenOptions, PersistentOptions } from "./types";
export type * from "./types";
interface InternalModal<T = any> extends ModalInstance<T> {
persistent?: boolean | PersistentOptions;
_key?: string | symbol; // 内部计算出的唯一 key
}
class ModalManager {
private modals = ref<InternalModal[]>([]);
private componentIds = new WeakMap<Component, symbol | string>();
/** 打开弹窗 */
public open<T = any>(options: OpenOptions): Promise<T> {
const { component, props, persistent } = options;
// 计算唯一 key,用于复用
const key = this.computeKey(options);
// 查看是否已经存在可复用弹窗
const existing = key ? this.modals.value.find((m) => m._key === key && m.persistent) : null;
if (existing) {
existing.props = props;
existing.visible = true;
// 必须重置 Promise 句柄,因为上一次的 promise 早已结束
return new Promise((resolve, reject) => {
existing.resolve = resolve;
existing.reject = reject;
});
}
// 创建弹窗
return new Promise((resolve, reject) => {
const modalId = Symbol("modal");
const _key = key ?? modalId;
const instance: InternalModal<T> = {
modalId,
visible: false,
component: markRaw(component),
props,
// 关键点:将 Promise 的控制权暴露给外部
resolve,
reject,
persistent,
_key
};
this.modals.value.push(instance);
// 记录组件引用(用于单例模式)
this.componentIds.set(component, _key);
// HACK: Element Plus 弹窗的open事件会在 “由关闭 → 打开” 的状态变化中触发,所以这里等下一个 tick 时设置为true
// 确保组件先挂载(v-if/v-for生效),下一帧再设置 visible=true,
// 这样才能正确触发 ElementPlus/AntDesign 等 UI 库的进入动画
nextTick().finally(() => {
const modal = this.getByModalId(modalId);
if (!modal) return;
modal.visible = true;
});
});
}
/** 确认关闭(resolve) */
public confirm<T>(modalId: symbol, value?: T) {
const modal = this.getByModalId(modalId);
if (!modal) return;
modal.resolve?.(value as T);
modal.resolve = void 0;
modal.reject = void 0;
// 关闭弹窗,不销毁
modal.visible = false;
}
/** 取消关闭(reject) */
public cancel(modalId: symbol, reason?: any) {
const modal = this.getByModalId(modalId);
if (!modal) return;
modal.reject?.(reason);
modal.resolve = void 0;
modal.reject = void 0;
// 关闭弹窗,不销毁
modal.visible = false;
}
/** 关闭弹窗的回调方法,用于销毁非常驻弹窗 */
public closed(modalId: symbol) {
const modal = this.getByModalId(modalId);
if (!modal) return;
if (typeof modal.reject === "function") {
modal.reject(new Error("用户取消了"));
modal.resolve = void 0;
modal.reject = void 0;
}
// 非常驻弹窗,销毁
if (!modal.persistent) {
this.removeByModalId(modal.modalId);
}
}
/** 获取当前弹窗栈(仅供渲染层使用) */
public get list() {
return this.modals.value as readonly ModalInstance[];
}
/** 手动销毁常驻弹窗实例 */
public destroyPersistent(options: OpenOptions) {
if (!options.persistent) return;
const key = this.computeKey(options);
if (!key) return;
this.modals.value = this.modals.value.filter((m) => m._key !== key);
this.componentIds.delete(options.component);
}
/** 销毁全部弹窗 */
public destroyAll() {
this.modals.value = [];
}
/** 计算唯一 key */
private computeKey(options: OpenOptions): symbol | string | undefined {
const { component, props = {}, persistent } = options;
if (!persistent) return void 0;
const config = typeof persistent === "boolean" ? {} : persistent;
// 情况1:手动指定 key
if (config.key) return config.key;
// 情况2:动态生成 key
if (config.keyGenerator) return config.keyGenerator(props);
// 情况3:单例模式
const key = this.componentIds.get(component);
return key;
}
/** 通过modalId获取弹窗实例 */
private getByModalId(modalId: symbol): InternalModal | undefined {
return this.modals.value.find((m) => m.modalId === modalId);
}
/** 通过modalId删除弹窗实例 */
private removeByModalId(modalId: symbol) {
this.modals.value = this.modals.value.filter((m) => m.modalId !== modalId);
}
}
// 单例导出
const modalManager = new ModalManager();
export function useModal() {
return modalManager;
}渲染层实现 (The Renderer)
渲染层需要处理一个关键问题:Props 透传与事件监听。
// src/components/GlobalModalRenderer.vue
<template>
<div class="global-modal-container">
<template v-for="item in modal.list" :key="item.modalId">
<component
:is="item.component"
:modal-id="item.modalId"
v-bind="item.props"
v-model="item.visible"
@confirm="(val: any) => modal.confirm(item.modalId, val)"
@cancel="(reason?: any) => modal.cancel(item.modalId, reason)"
@closed="() => modal.closed(item.modalId)"
/>
</template>
</div>
</template>
<script setup lang="ts">
import { useModal } from "@/hooks/useModal";
const modal = useModal();
</script>
<style lang="scss" scoped>
.global-modal-container {
height: 0;
overflow: hidden;
}
</style>渲染层主要处理以下事项:
:modal-id="item.modalId"自动注入唯一 ID。v-bind="item.props"批量绑定业务 Props。v-model="item.visible"双向绑定 visible,控制显示隐藏- 事件绑定,监听业务事件,桥接到 Promise。
最后我们在App.vue中挂载这个组件:
// App.vue
<template>
<GlobalModalRenderer />
</template>
<script setup lang="ts">
import GlobalModalRenderer from "@/components/GlobalModalRenderer.vue";
</script>💻 场景实战:更复杂的业务场景
光有理论不够,我们来看几个真实的业务场景代码。
场景一:带表单验证的用户编辑(数据回传)
这是最常见的场景:打开弹窗 -> 填表 -> 校验 -> 点击确定 -> 返回数据。
UserEditModal.vue
<template>
<el-dialog v-model="visible" title="编辑用户" width="500px">
<el-form ref="formRef" :model="formData" :rules="rules">
<el-form-item label="用户名" prop="name">
<el-input v-model="formData.name" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="handleConfirm">保存</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue';
// 1. 标准定义
const visible = defineModel<boolean>({ required: true });
const emit = defineEmits(['confirm']);
const props = defineProps<{ userInfo?: { name: string } }>(); // 接收 props
const formRef = ref();
const formData = reactive({
name: props.userInfo?.name || ''
});
const rules = {
name: [{ required: true, message: '请输入姓名' }]
};
const handleConfirm = async () => {
if (!formRef.value) return;
// 2. 内部校验逻辑
await formRef.value.validate((valid) => {
if (valid) {
// 3. 校验通过,将表单数据作为 Promise 的 resolve 结果返回
emit('confirm', { ...formData });
}
});
};
</script>调用方:
const openEdit = async () => {
try {
// 等待弹窗关闭并拿到 formData
const formData = await modal.open({
component: UserEditModal,
props: { userInfo: { name: 'Jack' } }
});
// 只有校验通过才会走到这里
console.log('用户提交的数据:', formData);
await api.updateUser(formData);
} catch (e) {
console.log('用户取消或关闭了弹窗');
}
}场景二:弹窗内部处理异步逻辑(Loading 状态)
有时我们希望在弹窗内部点击“确定”后,先调用 API,成功后再关闭弹窗。
AsyncActionModal.vue
<template>
<el-dialog v-model="visible" title="异步操作">
<p>确定要执行敏感操作吗?</p>
<template #footer>
<!-- 按钮 Loading 状态 -->
<el-button type="primary" :loading="loading" @click="handleConfirm">
确定执行
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref } from 'vue';
const visible = defineModel<boolean>({ required: true });
const emit = defineEmits(['confirm']);
const loading = ref(false);
const handleConfirm = async () => {
try {
loading.value = true;
// 模拟内部 API 调用
await new Promise(r => setTimeout(r, 2000));
// API 成功后,才通知管理器关闭,并返回结果
emit('confirm', 'success');
} catch (error) {
// 失败处理
} finally {
loading.value = false;
}
};
</script>💾 进阶:持久化 (Persistence) 详解
弹窗的创建和销毁是有性能开销的。对于地图选择器、富文本编辑器等重型组件,我们希望它关闭后只是 v-show="false" 隐藏,而不是销毁 DOM。
配置解析:
全局单例 (Singleton)
无论调用多少次,整个应用生命周期内该组件只有一个实例。modal.open({ component: HeavyEditorModal, persistent: { singleton: true } // 与 singleton: true 等价 });动态 Key (Key Generator)
根据传入的参数决定是否复用。例如:聊天窗口,同一个人复用一个窗口,不同人创建新窗口。modal.open({ component: ChatWindow, props: { userId: 101 }, persistent: { // 返回相同的 string 就会复用实例 keyGenerator: (props) => `chat-${props.userId}` } });指定 Key (推荐)
如果你需要复用的弹窗组件引用不变,但是参数不同,又希望他们能区分复用,比如参数A的弹窗一个实例,参数B的弹窗一个实例,虽然都基于SimpleDialog,但是两个都是独立的,那么使用指定key的方式来实现。// 场景 A:选择源目录(状态独立) modal.open({ component: FilePicker, persistent: { key: "source-dir-picker" }, props: { title: "请选择源目录" } }); // 场景 B:选择输出目录(状态独立) modal.open({ component: FilePicker, persistent: { key: "output-dir-picker" }, props: { title: "请选择输出目录" } });
手动销毁实例
持久化的弹窗不会自动销毁(只会隐藏)。如果需要释放内存或重置状态,可以使用 destroyPersistent。
// 销毁指定配置的持久化实例
modal.destroyPersistent({
component: FilePicker,
persistent: { key: "source-dir-picker" } // persistent的配置也必须和open时传入的配置相同
});
// 或者暴力销毁所有
modal.destroyAll();📝 总结
通过 useModal,我们成功将弹窗管理重构为Promise 驱动的命令式服务。
它的核心优势在于:
- 清洁的代码:父组件 0 状态变量,逻辑线性流畅。
- 极致的解耦:弹窗组件与调用方彻底解耦,甚至可以在非 Vue 组件文件(如 axios 拦截器)中唤起登录弹窗。
- 强大的扩展:天然支持 TS 类型推导,支持持久化缓存,支持多种 UI 库。
本文系作者 @木灵鱼儿 原创发布在木灵鱼儿站点。未经许可,禁止转载。
暂无评论数据