Vue 核心语法与组件模式篇:弹窗与抽屉组件封装 | 如何做一个全局可控的 Dialog 服务

37 阅读16分钟

同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~

(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)

你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?

你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?

就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。

一天只有24小时,时间永远不够用,常常感到力不从心。

技术行业,本就是逆水行舟,不进则退。

如果你也有同样的困扰,别慌。

从现在开始,跟着我一起心态归零,利用碎片时间,来一次彻彻底底的基础扫盲

这一次,我们一起慢慢来,扎扎实实变强。

不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,

咱们一起稳步积累,真正摆脱“面向搜索引擎写代码”的尴尬。

一、先说痛点:你一定经历过的"弹窗地狱"

1.1 最原始的写法

大多数人刚接触 Vue 时,弹窗都是这么写的:

<template>
  <div>
    <el-button @click="showDialog = true">打开弹窗</el-button>

    <el-dialog v-model="showDialog" title="提示">
      <p>确定要删除吗?</p>
      <template #footer>
        <el-button @click="showDialog = false">取消</el-button>
        <el-button type="primary" @click="handleConfirm">确定</el-button>
      </template>
    </el-dialog>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const showDialog = ref(false)

const handleConfirm = () => {
  console.log('用户点了确定')
  showDialog.value = false
}
</script>

这段代码能跑,也没错。但问题是——一个页面如果有 5 个弹窗呢?

你会看到:

const showDeleteDialog = ref(false)
const showEditDialog = ref(false)
const showDetailDialog = ref(false)
const showConfirmDialog = ref(false)
const showUploadDialog = ref(false)

模板里 5 个 <el-dialog>,script 里 5 组 ref + handler。这就是我说的 "弹窗地狱"

1.2 痛点总结

问题表现
状态散落每个弹窗一个 ref,页面一复杂就找不到谁控制谁
模板臃肿<template> 里堆满了弹窗代码,实际页面逻辑被淹没
复用困难同样的确认弹窗,A 页面写一遍,B 页面再写一遍
流程断裂想在弹窗确认后继续执行逻辑,需要靠回调层层传递

你有没有想过——能不能像调函数一样调弹窗?

// 梦想中的写法
const result = await dialog.confirm('确定要删除吗?')
if (result) {
  await deleteItem(id)
}

这就是我们今天要做的事。

二、设计思路:从"模板驱动"到"命令式调用"

2.1 两种思维模式

Vue 的核心是声明式——你在模板里写好结构,数据变了,视图自动更新。弹窗用 v-model 控制显隐,这是标准的声明式用法。

但弹窗这个场景比较特殊,它更接近一个**"一次性动作":打开 → 用户操作 → 关闭,然后就没了。它更适合命令式**——我告诉你打开,你告诉我结果。

模式适合场景弹窗场景的体验
声明式(模板驱动)持久存在的 UI,如表单、列表需要维护额外状态,模板臃肿
命令式(函数调用)一次性交互,如确认框、通知调用简洁,流程连贯

2.2 核心设计

我们要封装的 Dialog 服务,核心就三件事:

  1. 选项配置:通过一个配置对象描述弹窗长什么样(标题、内容、按钮文案等)
  2. 回调支持:点确定/取消时能触发对应的回调函数
  3. Promise 化:让弹窗的结果可以被 await,融入异步流程

三、第一步:封装基础 Dialog 组件

先别急着搞全局服务,我们从一个配置式的基础弹窗组件开始。

3.1 定义配置类型

// types/dialog.ts

export interface DialogOptions {
  /** 弹窗标题 */
  title?: string
  /** 弹窗内容,可以是字符串,也可以是 VNode */
  content?: string | VNode
  /** 确认按钮文案 */
  confirmText?: string
  /** 取消按钮文案 */
  cancelText?: string
  /** 是否显示取消按钮 */
  showCancel?: boolean
  /** 弹窗宽度 */
  width?: string | number
  /** 点击确认的回调 */
  onConfirm?: () => void | Promise<void>
  /** 点击取消的回调 */
  onCancel?: () => void
  /** 弹窗关闭后的回调(无论确认还是取消) */
  onClosed?: () => void
}

为什么要定义类型? 不是为了装,是为了让调用方有提示。你用 dialog.confirm() 时,IDE 能告诉你可以传什么参数,这是实实在在提升效率的事。

3.2 基础弹窗组件

<!-- components/BaseDialog.vue -->
<template>
  <el-dialog
    v-model="visible"
    :title="options.title || '提示'"
    :width="options.width || '420px'"
    :close-on-click-modal="false"
    @closed="handleClosed"
  >
    <!-- 内容区域 -->
    <div class="dialog-content">
      <!-- 如果 content 是字符串,直接渲染 -->
      <template v-if="typeof options.content === 'string'">
        {{ options.content }}
      </template>
      <!-- 如果是 VNode,用 component 渲染 -->
      <component v-else :is="options.content" />
    </div>

    <!-- 底部按钮 -->
    <template #footer>
      <el-button
        v-if="options.showCancel !== false"
        @click="handleCancel"
      >
        {{ options.cancelText || '取消' }}
      </el-button>
      <el-button
        type="primary"
        :loading="confirmLoading"
        @click="handleConfirm"
      >
        {{ options.confirmText || '确定' }}
      </el-button>
    </template>
  </el-dialog>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import type { DialogOptions } from '@/types/dialog'

const props = defineProps<{
  options: DialogOptions
}>()

const visible = ref(false)
const confirmLoading = ref(false)

/** 打开弹窗 */
const open = () => {
  visible.value = true
}

/** 关闭弹窗 */
const close = () => {
  visible.value = false
}

/** 点击确认 */
const handleConfirm = async () => {
  if (props.options.onConfirm) {
    try {
      confirmLoading.value = true
      // 支持 onConfirm 返回 Promise,按钮自动 loading
      await props.options.onConfirm()
    } finally {
      confirmLoading.value = false
    }
  }
  close()
}

/** 点击取消 */
const handleCancel = () => {
  props.options.onCancel?.()
  close()
}

/** 弹窗关闭动画结束后 */
const handleClosed = () => {
  props.options.onClosed?.()
}

defineExpose({ open, close })
</script>

3.3 踩坑点:v-model vs closed 事件的时机

这里要特别注意一个细节:@close@closed 是两个不同的事件。

  • @close:弹窗开始关闭时触发(动画还没结束)
  • @closed:弹窗关闭动画完全结束后触发

为什么用 @closed 而不是 @close 因为如果你在 @close 里就销毁组件或清理数据,用户会看到弹窗内容"闪一下空白"再消失,体验很差。等动画结束再清理,过渡才是丝滑的。

四、第二步:回调模式的使用方式

有了基础组件,我们先看看回调模式怎么用:

<template>
  <div>
    <el-button @click="handleDelete">删除</el-button>
    <BaseDialog ref="dialogRef" :options="dialogOptions" />
  </div>
</template>

<script setup>
import { ref } from 'vue'
import BaseDialog from '@/components/BaseDialog.vue'

const dialogRef = ref()
const dialogOptions = ref({})

const handleDelete = () => {
  dialogOptions.value = {
    title: '确认删除',
    content: '删除后不可恢复,确定要继续吗?',
    confirmText: '删除',
    onConfirm: async () => {
      await api.deleteItem(123)
      ElMessage.success('删除成功')
      fetchList() // 刷新列表
    },
    onCancel: () => {
      console.log('用户取消了')
    }
  }
  dialogRef.value.open()
}
</script>

这已经比最初的写法好多了——弹窗的配置和业务逻辑写在一起,不用到处找 ref。但还是有两个问题:

  1. 模板里还是要放一个 <BaseDialog />
  2. 逻辑被"打断"了——你得把确认后的操作塞进 onConfirm 回调里

如果删除操作后面还有其他逻辑呢?回调套回调,又开始嵌套了。

所以我们需要 Promise 化。

五、第三步:Promise 化——让弹窗像 await 一样丝滑

5.1 核心思路

Promise 化的核心思想非常简单:

创建一个 Promise,把它的 resolve 和 reject 交给弹窗的确认和取消按钮。

用户点确认 → resolve(),点取消 → reject()resolve(false)

// 伪代码,感受一下
function confirm(content) {
  return new Promise((resolve, reject) => {
    打开弹窗({
      content,
      onConfirm: () => resolve(true),
      onCancel: () => resolve(false)
    })
  })
}

5.2 实现 useDialog 组合式函数

这是整篇文章最核心的代码,我们一步步拆:

// composables/useDialog.ts

import { createApp, ref, h, type VNode, type Component } from 'vue'
import BaseDialog from '@/components/BaseDialog.vue'
import type { DialogOptions } from '@/types/dialog'
import ElementPlus from 'element-plus'

/**
 * 命令式调用弹窗
 * 内部原理:动态创建一个 Vue 应用实例,挂载到临时 DOM 节点上
 */
function createDialog(options: DialogOptions): Promise<boolean> {
  return new Promise((resolve) => {
    // 1. 创建一个容器节点
    const container = document.createElement('div')
    document.body.appendChild(container)

    // 2. 记录是否已经 resolve,防止重复调用
    let resolved = false

    const safeResolve = (val: boolean) => {
      if (resolved) return
      resolved = true
      resolve(val)
    }

    // 3. 合并选项:把 Promise 的 resolve 注入到回调中
    const mergedOptions: DialogOptions = {
      ...options,
      onConfirm: async () => {
        // 如果用户传了自己的 onConfirm,先执行
        if (options.onConfirm) {
          await options.onConfirm()
        }
        safeResolve(true)
      },
      onCancel: () => {
        options.onCancel?.()
        safeResolve(false)
      },
      onClosed: () => {
        options.onClosed?.()
        // 动画结束后,清理 DOM 和 Vue 实例
        app.unmount()
        container.remove()
      }
    }

    // 4. 创建 Vue 应用实例并挂载
    const app = createApp({
      setup() {
        const dialogRef = ref()

        // 挂载后自动打开弹窗
        const onMounted = () => {
          // 用 nextTick 确保 DOM 已就绪
          setTimeout(() => dialogRef.value?.open(), 0)
        }

        return () =>
          h(BaseDialog, {
            ref: dialogRef,
            options: mergedOptions,
            onVnodeMounted: onMounted
          })
      }
    })

    // 5. 注册 Element Plus(因为是独立的 app 实例)
    app.use(ElementPlus)
    app.mount(container)
  })
}

5.3 关键踩坑:独立 App 实例的样式和插件问题

这里有一个非常容易踩的坑,很多文章不会告诉你:

通过 createApp 创建的实例,和你主应用是完全隔离的!

这意味着:

  • 主应用注册的 Element Plus,新实例里用不了
  • 主应用的 provide/inject,新实例里拿不到
  • 主应用的全局组件、指令,新实例里没有

所以你会看到代码里有一行 app.use(ElementPlus)——这不是多余的,是必须的

如果你的项目用了 Pinia、Vue Router、自定义插件,且弹窗里要用到,也得在新实例里注册:

// 如果弹窗组件里要用 store 或 router
import { createPinia } from 'pinia'
import router from '@/router'

app.use(createPinia())
app.use(router)
app.use(ElementPlus)

更优雅的做法:把主应用用到的插件列表抽出来,封装一个 installPlugins 函数,让主应用和弹窗实例共用:

// plugins/index.ts
import type { App } from 'vue'
import ElementPlus from 'element-plus'
import { createPinia } from 'pinia'

export function installPlugins(app: App) {
  app.use(createPinia())
  app.use(ElementPlus)
  // 其他插件...
}

六、第四步:封装成全局 Dialog 服务

6.1 暴露友好的 API

// services/dialog.ts

import { type VNode } from 'vue'
import type { DialogOptions } from '@/types/dialog'
import { createDialog } from '@/composables/useDialog'

/**
 * 全局 Dialog 服务
 * 用法:
 *   await dialog.confirm('确定删除?')
 *   await dialog.alert('操作成功')
 *   await dialog.open({ title: '自定义', content: h(MyComponent) })
 */
const dialog = {
  /**
   * 确认弹窗(有确定和取消按钮)
   * 返回 true 表示用户点了确认,false 表示取消
   */
  confirm(
    content: string | VNode,
    options?: Partial<DialogOptions>
  ): Promise<boolean> {
    return createDialog({
      title: '确认',
      content,
      showCancel: true,
      ...options
    })
  },

  /**
   * 提示弹窗(只有确定按钮)
   * 用户点确定后 resolve
   */
  alert(
    content: string | VNode,
    options?: Partial<DialogOptions>
  ): Promise<boolean> {
    return createDialog({
      title: '提示',
      content,
      showCancel: false,
      ...options
    })
  },

  /**
   * 完全自定义弹窗
   * 传入完整的配置对象
   */
  open(options: DialogOptions): Promise<boolean> {
    return createDialog(options)
  }
}

export default dialog

6.2 实际业务中使用

现在来看看调用有多舒服:

<template>
  <div class="user-list">
    <el-table :data="userList">
      <el-table-column prop="name" label="姓名" />
      <el-table-column prop="email" label="邮箱" />
      <el-table-column label="操作">
        <template #default="{ row }">
          <el-button size="small" @click="handleEdit(row)">编辑</el-button>
          <el-button size="small" type="danger" @click="handleDelete(row)">
            删除
          </el-button>
        </template>
      </el-table-column>
    </el-table>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import dialog from '@/services/dialog'

const userList = ref([/* ... */])

/** 删除用户——注意看,流程多清晰 */
const handleDelete = async (row) => {
  // 第一步:询问用户
  const confirmed = await dialog.confirm(
    `确定要删除用户「${row.name}」吗?删除后不可恢复。`,
    { title: '删除确认', confirmText: '确认删除' }
  )

  // 第二步:用户取消,直接 return
  if (!confirmed) return

  // 第三步:调接口删除
  try {
    await api.deleteUser(row.id)
    ElMessage.success('删除成功')
    fetchUserList()
  } catch (err) {
    ElMessage.error('删除失败,请重试')
  }
}

/** 批量删除——串行多个弹窗也很自然 */
const handleBatchDelete = async (ids: number[]) => {
  const step1 = await dialog.confirm(`即将删除 ${ids.length} 条记录`)
  if (!step1) return

  const step2 = await dialog.confirm(
    '此操作不可逆,是否已经备份相关数据?',
    { title: '二次确认', confirmText: '已备份,继续删除' }
  )
  if (!step2) return

  await api.batchDelete(ids)
  await dialog.alert('批量删除完成')
  fetchUserList()
}
</script>

对比一下之前的写法——没有额外的 ref,没有模板里的 <el-dialog>,流程像读文章一样从上往下。这就是 Promise 化的威力。

七、进阶:在弹窗里渲染自定义组件

确认框只是最简单的场景。实际业务中,弹窗里经常要放表单详情甚至是一个完整的子页面

7.1 渲染自定义组件

import { h } from 'vue'
import EditUserForm from '@/components/EditUserForm.vue'

const handleEdit = async (row) => {
  const confirmed = await dialog.open({
    title: '编辑用户',
    width: '600px',
    content: h(EditUserForm, {
      userId: row.id,
      // 可以通过 props 传值给弹窗内的组件
    }),
    onConfirm: async () => {
      // 这里怎么拿到表单数据?继续看下面
    }
  })
}

7.2 踩坑:弹窗和内部组件的通信

这是一个高频踩坑点:弹窗的确认按钮在外面,表单在里面,点确认时要拿到表单数据并校验——这个数据怎么传出来?

方案一:通过 ref 拿子组件实例(不推荐)

createApp 方案中,你很难直接拿到弹窗内部组件的 ref,因为是动态创建的。

方案二:通过事件 / 回调传递(推荐)

改造一下,让自定义组件通过回调把数据"交"出来:

// 用一个中间变量承接表单组件的数据和方法
const formActions = { validate: null, getData: null }

const confirmed = await dialog.open({
  title: '编辑用户',
  width: '600px',
  content: h(EditUserForm, {
    userId: row.id,
    // 表单组件挂载后,把自己的方法暴露出来
    onReady: (actions) => {
      formActions.validate = actions.validate
      formActions.getData = actions.getData
    }
  }),
  onConfirm: async () => {
    // 先校验
    const valid = await formActions.validate()
    if (!valid) throw new Error('校验不通过') // 抛错可以阻止弹窗关闭
    // 再提交
    const data = formActions.getData()
    await api.updateUser(row.id, data)
    ElMessage.success('更新成功')
  }
})

表单组件那边:

<!-- components/EditUserForm.vue -->
<template>
  <el-form ref="formRef" :model="form" :rules="rules">
    <el-form-item label="姓名" prop="name">
      <el-input v-model="form.name" />
    </el-form-item>
    <el-form-item label="邮箱" prop="email">
      <el-input v-model="form.email" />
    </el-form-item>
  </el-form>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'

const props = defineProps<{
  userId: number
}>()

const emit = defineEmits<{
  ready: [actions: { validate: () => Promise<boolean>; getData: () => any }]
}>()

const formRef = ref()
const form = ref({ name: '', email: '' })

const rules = {
  name: [{ required: true, message: '请输入姓名' }],
  email: [{ required: true, message: '请输入邮箱' }]
}

onMounted(async () => {
  // 加载用户数据
  const data = await api.getUser(props.userId)
  form.value = data

  // 把校验和取数方法暴露给外部
  emit('ready', {
    validate: () => formRef.value.validate().catch(() => false),
    getData: () => ({ ...form.value })
  })
})
</script>

7.3 踩坑:onConfirm 抛错阻止关闭

注意上面 onConfirm 里的 throw new Error('校验不通过')。我们需要改造一下 BaseDialog 的确认逻辑,让它支持"校验不通过时不关闭":

// BaseDialog.vue 中修改 handleConfirm
const handleConfirm = async () => {
  if (props.options.onConfirm) {
    try {
      confirmLoading.value = true
      await props.options.onConfirm()
    } catch (e) {
      // onConfirm 抛错了,不关闭弹窗,只取消 loading
      confirmLoading.value = false
      return // 注意这里 return 了,不会走到下面的 close()
    } finally {
      confirmLoading.value = false
    }
  }
  close()
}

这个设计非常实用onConfirm 正常执行完 → 自动关闭;onConfirm 抛错 → 不关闭,用户可以修改后重试。

八、同理可得:抽屉(Drawer)服务

抽屉和弹窗的封装思路完全一致,只是底层组件从 el-dialog 换成 el-drawer。我们可以复用同一套逻辑:

// services/drawer.ts

import { createApp, ref, h } from 'vue'
import BaseDrawer from '@/components/BaseDrawer.vue'
import type { DrawerOptions } from '@/types/drawer'

export interface DrawerOptions {
  title?: string
  content?: string | VNode | Component
  /** 抽屉方向 */
  direction?: 'rtl' | 'ltr' | 'ttb' | 'btt'
  /** 抽屉宽度/高度 */
  size?: string | number
  onConfirm?: () => void | Promise<void>
  onCancel?: () => void
  onClosed?: () => void
}

function createDrawer(options: DrawerOptions): Promise<boolean> {
  // 和 createDialog 几乎一模一样
  // 只是内部渲染的是 BaseDrawer 组件
  return new Promise((resolve) => {
    const container = document.createElement('div')
    document.body.appendChild(container)

    let resolved = false
    const safeResolve = (val: boolean) => {
      if (resolved) return
      resolved = true
      resolve(val)
    }

    const mergedOptions = {
      ...options,
      onConfirm: async () => {
        await options.onConfirm?.()
        safeResolve(true)
      },
      onCancel: () => {
        options.onCancel?.()
        safeResolve(false)
      },
      onClosed: () => {
        options.onClosed?.()
        app.unmount()
        container.remove()
      }
    }

    const app = createApp({
      setup() {
        const drawerRef = ref()
        const onMounted = () => {
          setTimeout(() => drawerRef.value?.open(), 0)
        }
        return () =>
          h(BaseDrawer, {
            ref: drawerRef,
            options: mergedOptions,
            onVnodeMounted: onMounted
          })
      }
    })

    app.use(ElementPlus)
    app.mount(container)
  })
}

const drawer = {
  open(options: DrawerOptions): Promise<boolean> {
    return createDrawer(options)
  }
}

export default drawer

使用方式:

import drawer from '@/services/drawer'
import UserDetail from '@/components/UserDetail.vue'

const handleViewDetail = async (row) => {
  await drawer.open({
    title: '用户详情',
    size: '40%',
    direction: 'rtl',
    content: h(UserDetail, { userId: row.id })
  })
}

九、终极优化:抽取公共逻辑,一个工厂搞定

你会发现,Dialog 和 Drawer 的 create 函数长得几乎一样。我们可以抽一个工厂函数:

// composables/createOverlayService.ts

import { createApp, ref, h, type Component } from 'vue'
import ElementPlus from 'element-plus'

interface OverlayOptions {
  onConfirm?: () => void | Promise<void>
  onCancel?: () => void
  onClosed?: () => void
  [key: string]: any
}

/**
 * 覆盖层服务工厂
 * @param OverlayComponent 底层组件(BaseDialog 或 BaseDrawer)
 */
export function createOverlayService<T extends OverlayOptions>(
  OverlayComponent: Component
) {
  return function create(options: T): Promise<boolean> {
    return new Promise((resolve) => {
      const container = document.createElement('div')
      document.body.appendChild(container)

      let resolved = false
      const safeResolve = (val: boolean) => {
        if (resolved) return
        resolved = true
        resolve(val)
      }

      const mergedOptions: T = {
        ...options,
        onConfirm: async () => {
          await options.onConfirm?.()
          safeResolve(true)
        },
        onCancel: () => {
          options.onCancel?.()
          safeResolve(false)
        },
        onClosed: () => {
          options.onClosed?.()
          app.unmount()
          container.remove()
        }
      }

      const app = createApp({
        setup() {
          const overlayRef = ref()
          const onMounted = () => {
            setTimeout(() => overlayRef.value?.open(), 0)
          }
          return () =>
            h(OverlayComponent, {
              ref: overlayRef,
              options: mergedOptions,
              onVnodeMounted: onMounted
            })
        }
      })

      app.use(ElementPlus)
      app.mount(container)
    })
  }
}

然后 Dialog 和 Drawer 服务各只需要几行:

// services/dialog.ts
import { createOverlayService } from '@/composables/createOverlayService'
import BaseDialog from '@/components/BaseDialog.vue'
import type { DialogOptions } from '@/types/dialog'

const createDialog = createOverlayService<DialogOptions>(BaseDialog)

export default {
  confirm: (content, options?) => createDialog({ title: '确认', content, showCancel: true, ...options }),
  alert: (content, options?) => createDialog({ title: '提示', content, showCancel: false, ...options }),
  open: (options) => createDialog(options)
}
// services/drawer.ts
import { createOverlayService } from '@/composables/createOverlayService'
import BaseDrawer from '@/components/BaseDrawer.vue'
import type { DrawerOptions } from '@/types/drawer'

const createDrawer = createOverlayService<DrawerOptions>(BaseDrawer)

export default {
  open: (options) => createDrawer(options)
}

十、踩坑汇总与最佳实践

10.1 高频踩坑清单

#原因解决方案
1弹窗里 Element Plus 组件不渲染createApp 创建的是独立实例,没注册 Element Plus新实例里也要 app.use(ElementPlus)
2弹窗里拿不到 Pinia store 数据独立实例没有注册 Pinia新实例里也要 app.use(pinia)
3关闭弹窗时内容"闪空"@close 而不是 @closed 里清理数据使用 @closed 事件做清理
4弹窗关闭后 DOM 节点没清理忘了 container.remove()onClosedapp.unmount() + container.remove()
5确认按钮点了弹窗就关了,但接口还没调完onConfirm 没有 await 异步操作onConfirm 支持返回 Promise,按钮自动 loading
6表单校验失败弹窗也关了没有处理 onConfirm 中的错误onConfirm 抛错时 return 不调用 close()
7多次快速点击打开了多个弹窗没做防重复打开的控制加一个标志位或用防抖
8Promise 被 resolve 了两次点确认后又触发了关闭按钮的逻辑safeResolve 加标志位防止重复 resolve

10.2 选型建议

场景推荐方案原因
简单确认/提示dialog.confirm() / dialog.alert()一行代码搞定
弹窗内有简单表单dialog.open() + h(FormComponent)组件传 props + onReady 暴露方法
弹窗内有复杂页面级组件还是考虑声明式 <el-dialog>太复杂的组件动态创建会有各种边界问题
全局统一的删除确认封装 useDeleteConfirm Hook进一步收敛,一处修改全局生效

10.3 不要过度设计

最后说一句大实话:不是所有弹窗都需要命令式调用

如果一个弹窗:

  • 内部有很重的组件(富文本编辑器、地图等)
  • 需要和父组件频繁通信
  • 生命周期内要维护大量状态

那老老实实写在模板里,用 v-model 控制,反而更稳。

命令式服务最适合的场景是:轻量级、一次性、确认类的交互。别拿着锤子看什么都是钉子。

十一、完整项目结构一览

src/
├── components/
│   ├── BaseDialog.vue          # 基础弹窗组件
│   └── BaseDrawer.vue          # 基础抽屉组件
├── composables/
│   └── createOverlayService.ts # 覆盖层服务工厂函数
├── services/
│   ├── dialog.ts               # Dialog 服务(confirm / alert / open)
│   └── drawer.ts               # Drawer 服务
├── types/
│   ├── dialog.ts               # Dialog 配置类型定义
│   └── drawer.ts               # Drawer 配置类型定义
└── plugins/
    └── index.ts                # 插件统一注册

总结

阶段做了什么解决了什么问题
原始写法v-if + ref 控制能用,但状态散乱、模板臃肿
配置式组件把选项抽成对象传入复用性提升,但模板里还得放组件
回调模式onConfirm / onCancel逻辑集中了,但回调嵌套还是烦
Promise 化await 接收结果流程清晰如读代码,告别回调地狱
全局服务dialog.confirm() 一行调用任何地方都能用,零模板侵入
工厂抽象Dialog/Drawer 共用一套创建逻辑代码精简,扩展方便(Popover 服务也能用)

从"弹窗地狱"到"一行 await",核心就是三个关键词:配置化、回调化、Promise 化

掌握这套封装思路,不仅仅是弹窗——任何"打开 → 交互 → 关闭"的场景(抽屉、气泡确认、全屏预览等),都可以用同样的套路搞定。

🔍 本系列专栏导航

一、《Vue 核心语法与组件模式篇:从 Vue2 到 Vue3 | 语法差异与迁移时最容易懵的点》

二、《Vue 核心语法与组件模式篇:模板语法扫盲 | v-if、v-for、v-model、slot 的常见组合模式》

三、《Vue 核心语法与组件模式篇:Vue 组件通信全图 | props、emit、ref、provide-inject 全局状态》

四、《Vue 核心语法与组件模式篇:表单最佳实践 | 从 v-model 到自定义表单组件(含校验)》

五、《Vue 核心语法与组件模式篇:列表与表格最佳实践 | 分页、筛选、排序、批量操作》

六、《Vue 核心语法与组件模式篇:弹窗与抽屉组件封装 | 如何做一个全局可控的 Dialog 服务》

七、《Vue 核心语法与组件模式篇:组合式函数、Hooks | (Vue2 mixin、Vue3 composables) 的实战封装》

八、《Vue 核心语法与组件模式篇:后台权限与菜单渲染 | 基于路由和后端返回的几种实现方式》

👉 跟着系列慢慢学,把技术功底扎扎实实地打牢~


学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。

后续我还会继续用这种大白话、讲实战方式,带大家扫盲更多前端基础。

关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。

如果你觉得这篇内容对你有帮助,不妨点赞+收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。

我是 Eugene,你的电子学友,我们下一篇干货见~