同学们好,我是 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 服务,核心就三件事:
- 选项配置:通过一个配置对象描述弹窗长什么样(标题、内容、按钮文案等)
- 回调支持:点确定/取消时能触发对应的回调函数
- 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。但还是有两个问题:
- 模板里还是要放一个
<BaseDialog /> - 逻辑被"打断"了——你得把确认后的操作塞进
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() | 在 onClosed 中 app.unmount() + container.remove() |
| 5 | 确认按钮点了弹窗就关了,但接口还没调完 | onConfirm 没有 await 异步操作 | onConfirm 支持返回 Promise,按钮自动 loading |
| 6 | 表单校验失败弹窗也关了 | 没有处理 onConfirm 中的错误 | onConfirm 抛错时 return 不调用 close() |
| 7 | 多次快速点击打开了多个弹窗 | 没做防重复打开的控制 | 加一个标志位或用防抖 |
| 8 | Promise 被 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,你的电子学友,我们下一篇干货见~