🚀 前言

在 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>

核心痛点:

  1. 状态爆炸:每增加一个弹窗,就要增加一套 refprops 变量。
  2. 模板污染:组件即使未被使用,也必须在 <template> 中预先挂载,影响 DOM 结构清晰度。
  3. 复用困难:想在 Pinia 或纯 JS 文件中唤起弹窗?传统方式几乎做不到。
  4. 流程割裂:触发逻辑与回调逻辑在代码空间上分离,难以阅读。

🛠️ 解决方案: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 句柄劫持中心"

  1. 调用 open():创建一个 Promise,但暂时不 resolve
  2. 劫持句柄:将 resolvereject 函数提取出来,连同组件信息(Props, Component)挂载到一个响应式数组 modals 中。
  3. 渲染层响应:全局挂载的渲染组件监测到 modals 变化,自动渲染对应的弹窗组件。
  4. 用户交互:用户在弹窗点击“确定”,触发渲染层的 confirm 方法。
  5. 归还句柄:管理器找到对应的 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>

渲染层主要处理以下事项:

  1. :modal-id="item.modalId" 自动注入唯一 ID。
  2. v-bind="item.props" 批量绑定业务 Props。
  3. v-model="item.visible" 双向绑定 visible,控制显示隐藏
  4. 事件绑定,监听业务事件,桥接到 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。

配置解析:

  1. 全局单例 (Singleton)
    无论调用多少次,整个应用生命周期内该组件只有一个实例。

    modal.open({
      component: HeavyEditorModal,
      persistent: { singleton: true } // 与 singleton: true 等价
    });
  2. 动态 Key (Key Generator)
    根据传入的参数决定是否复用。例如:聊天窗口,同一个人复用一个窗口,不同人创建新窗口。

    modal.open({
      component: ChatWindow,
      props: { userId: 101 },
      persistent: {
        // 返回相同的 string 就会复用实例
        keyGenerator: (props) => `chat-${props.userId}`
      }
    });
  3. 指定 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 库。
分类: vue 项目实战 标签: promisedialogvue3弹窗管理器命令式调用

评论

暂无评论数据

暂无评论数据

目录