背景

Vue3 弹窗状态抽离:一个 TypeScript 泛型 Hooks 实践

发表于 2026/06/11 20:00
🌺 摘要
通过 DialogOpenOptions 与 DialogValue 两个泛型类型,把 Vue3 项目里常见的弹窗 open/close 逻辑抽成可复用 hooks。

业务页面里弹窗写法往往高度相似:visible 控制显隐、data 承载当前行数据,有时还要附带 optionType 这类模式字段。每个弹窗都手写一遍,既重复又容易漏类型。

下面是一个通用封装思路:用 TypeScript 泛型把「业务数据」和「可扩展字段」拆开,让 hooks 既能复用,又不丢类型提示。

设计目标

  • 统一入口open(options) 打开,visible 关闭;
  • 类型安全data 随业务变化,open 入参有完整推断;
  • 可扩展:新增/编辑等场景可通过第二个泛型参数扩展字段;

类型定义

/**
 * 通用弹窗
 * @template T 弹窗业务数据(如某条列表行)
 * @template E 可扩展字段(如 optionType),默认无额外字段
 *
 * @example
 * // 仅 data
 * const dialog = useDialogProps<RowItem>();
 * dialog.value.open({ data: row });
 *
 * @example
 * // 扩展 optionType
 * const dialog = useDialogProps<RowItem, { optionType: 'add' | 'edit' }>({
 *   optionType: 'add',
 * });
 * dialog.value.open({ data: row, optionType: 'edit' });
 */
export type DialogOpenOptions<T extends object, E extends object = {}> = {
  data?: T;
} & (keyof E extends never ? object : Partial<E>);

export type DialogValue<T extends object, E extends object = {}> = {
  visible: boolean;
  data?: T;
  open: (options?: DialogOpenOptions<T, E>) => void;
} & E;

两个泛型分别管什么

泛型含义示例
T弹窗绑定的业务数据列表行、表单草稿
Edata 外的扩展状态{ optionType: 'add' | 'edit' }

DialogOpenOptions 里用 (keyof E extends never ? object : Partial<E>) 做条件类型:没有扩展字段时,open 只接受 { data? };有扩展字段时,open 可传入对应的 Partial<E>

Hooks 实现

import { ref } from 'vue';

export const useDialogProps = <T extends object, E extends object = {}>(
  initial?: E
) => {
  const dialog = ref({
    visible: false,
    ...(initial ?? {}),
    open() {},
  } as DialogValue<T, E>);

  dialog.value.open = (options?: DialogOpenOptions<T, E>) => {
    if (options?.data !== undefined) {
      dialog.value.data = options.data as DialogValue<T, E>['data'];
    }
    if (options) {
      const { data: _data, ...extra } = options;
      Object.assign(dialog.value, extra);
    }
    dialog.value.visible = true;
  };

  return dialog;
};

实现要点

  1. initial 注入默认值:例如 { optionType: 'add' },弹窗初始即带模式;
  2. open 内合并扩展字段data 单独赋值,其余字段通过 Object.assign 写入;
  3. as DialogValue<T, E>:先占位 open,再挂载真实方法,避免循环引用时的类型报错。

使用示例

场景一:只传业务数据

interface UserRow {
  id: number;
  name: string;
}

const userDialog = useDialogProps<UserRow>();

// 打开并带入当前行
userDialog.value.open({ data: { id: 1, name: '张三' } });

// 模板中
// <UserDialog v-model:visible="userDialog.visible" :data="userDialog.data" />

场景二:新增 / 编辑共用弹窗

interface FormRecord {
  id?: number;
  title: string;
}

const formDialog = useDialogProps<FormRecord, { optionType: 'add' | 'edit' }>({
  optionType: 'add',
});

// 新增
formDialog.value.open({ optionType: 'add' });

// 编辑
formDialog.value.open({
  data: { id: 10, title: '示例标题' },
  optionType: 'edit',
});

// formDialog.value.optionType 类型为 'add' | 'edit'

子组件可通过 defineProps 接收 visibledataoptionType,标题文案按 optionType 切换即可。

为什么用泛型而不是 any

// ❌ 失去类型约束
const dialog = ref<{ visible: boolean; data?: any; open: Function }>({ ... });

// ✅ 调用 open 时 data、optionType 均有推断
const dialog = useDialogProps<FormRecord, { optionType: 'add' | 'edit' }>();
dialog.value.open({ data: row, optionType: 'edit' });

泛型让「弹窗壳子」与「业务数据」解耦:hooks 本身不 import 任何业务 VO,却在每个使用点拿到精确类型。

可继续扩展的方向

  • 增加 close(),统一重置 visible 与可选字段;
  • open 支持 Promise,配合 await dialog.value.open() 做提交后刷新;
  • readonly 包装 data,避免弹窗内误改外部列表引用;
  • 多个弹窗共用同一 T、不同 E,按页面组合即可。

小结

弹窗逻辑不复杂,难在「每个页面 copy 一份、类型还各自为政」。把 T(数据)和 E(扩展)拆成两个泛型参数,再用条件类型约束 open 的入参,就能在 Vue3 里得到一份轻量、可复用、类型完整的弹窗 hooks。

文章发表于 2026/06/11 20:00
没有上一篇文章
下一篇 字体压缩工具使用说明

评论

加载中...