在实际开发中,几乎每个前端项目都离不开消息提示(如 Toast 成功提示)、模态弹窗(如确认框)、全局通知等。如何优雅地封装这些组件,并使它们具备「全局可调用」「跨组件通信」「异步回调」等能力?本篇将从零开始,带你构建属于你团队的全局提示解决方案。
一、为什么需要全局消息提示组件?
在 Vue3 的项目开发中,我们经常遇到以下场景:
- 表单提交成功后,希望在任意页面快速提示 "操作成功"。
- 用户进行危险操作时,弹出全局确认框(Dialog),确认是否继续。
- 接口异常或鉴权失败时,统一弹出错误提示。
- 在业务组件内触发提示,但又不希望耦合 UI 逻辑到具体组件中。
让我们看一个典型的例子:
vue
<!-- 不封装时 -->
<script setup>
import Dialog from '@/components/Dialog.vue'
const showDialog = ref(false)
function handleClick() {
showDialog.value = true
}
</script>
<template>
<Dialog v-if="showDialog" @confirm="doSomething" @cancel="showDialog = false" />
<button @click="handleClick">删除</button>
</template>
虽然逻辑清晰,但每个组件都要重复写一遍。
全局提示系统的优势:
能力 | 说明 |
---|---|
全局可用 | 无需每次都引入组件,只要调用 API 即可提示 |
解耦业务组件与 UI | 组件只管业务逻辑,提示交由提示系统处理 |
支持异步处理(如 Dialog) | 弹出确认框 → 用户点击确认 → 返回 Promise |
支持多种提示类型 | 成功、警告、错误、加载中等 |
支持统一样式和主题 | 避免团队成员各自写一套弹窗样式 |
常见用法目标:
我们想实现类似这样的调用方式:
js
// Toast 使用
useToast().success('保存成功!')
// Dialog 使用
const confirmed = await useDialog().confirm('确定删除这条数据吗?')
if (confirmed) {
deleteItem()
}
而不需要每次手动引入组件,也不需要每个组件中定义各种 ref 和回调处理。
一个合理的提示系统应该是 低侵入性、高复用性、语义明确、易于调用 的。
二、Toast、Dialog、Modal 有什么区别?
虽然我们常常将 提示组件 一概而论,但在实际项目中,"Toast"、"Dialog" 与 "Modal" 各自有着不同的用途、交互行为和设计语义。
为了更高质量地封装全局提示系统,我们必须先理清它们的定位和差异。
1. Toast:轻量级消息提示
-
用途:用于反馈操作结果,如"提交成功"、"保存失败"等。
-
特点:
- 非阻断式:不会打断用户当前操作。
- 自动消失:通常 1.5~3 秒后自动关闭。
- UI样式简洁,位于页面某一角落(常见:右上角 / 居中)。
js
useToast().success('保存成功!')
useToast().error('请求失败,请稍后再试')
-
适合场景:
- 表单提交后提示
- 网络请求反馈
- 成功/失败信息
2. Dialog:确认/取消类弹窗
-
用途:用于需要用户明确决策的场景,如"是否删除""确认退出"等。
-
特点:
- 阻断式操作:显示时通常会遮罩背景,阻止其他交互。
- 通常带有"确认"和"取消"两个按钮。
- 回调机制支持 Promise 或事件绑定。
js
const confirmed = await useDialog().confirm('确定删除这条记录?')
if (confirmed) {
deleteItem()
}
-
适合场景:
- 删除确认
- 危险操作提示
- 用户权限类操作前的确认
3. Modal:具备内容区域的弹窗(增强版 Dialog)
-
用途:用于展示较复杂内容或多步骤交互,如表单、说明、图片预览等。
-
特点:
- 内容灵活:支持插槽、自定义 header/footer。
- 通常具有关闭按钮,可遮罩。
- 可嵌套组件或异步逻辑。
js
useModal().open({
title: '编辑用户信息',
content: UserForm,
onConfirm: async () => { await saveUser() }
})
-
适合场景:
- 编辑表单弹窗
- 多步操作提示
- 显示富文本或图表数据
对比总结表:
类型 | 是否阻断 | 自动关闭 | 用途 | 是否支持插槽/内容 |
---|---|---|---|---|
Toast | 否 | 是 | 简单反馈(成功/失败) | 否 |
Dialog | 是 | 否 | 确认/取消类操作 | 一般不需要 |
Modal | 是 | 否 | 展示复杂信息或交互 | 是 |
实际封装建议:
- 将三种组件分别封装为:
useToast()
、useDialog()
、useModal()
。 - 提供统一的语义调用方式。
- 用
Teleport
+动态挂载
保证它们能在任意页面调用且不影响布局。 - 支持组件内部使用,也支持
setup
中直接调用。
三、封装思路:从 API 设计出发
一个优秀的全局提示系统,不只是 UI 层的组件封装,更重要的是提供一个开发体验友好、灵活可扩展的调用 API。
我们从开发者视角出发,先设计使用方式(使用场景),再倒推实现逻辑。
1. 使用者视角:希望如何调用?
Toast 示例
js
// 提示成功信息
useToast().success('提交成功')
// 提示失败信息
useToast().error('操作失败,请稍后重试')
// 提示加载中(可选)
const loading = useToast().loading('提交中...')
loading.close()
目标:链式调用、无需在模板中引入组件、支持自动关闭或手动控制。
Dialog 示例
js
// 确认对话框
const confirmed = await useDialog().confirm('确定要删除该用户吗?')
if (confirmed) {
deleteUser()
}
目标:基于 Promise 的异步操作,像 JavaScript 原生
confirm
一样好用。
Modal 示例
js
// 打开自定义组件弹窗
useModal().open({
title: '编辑用户',
content: EditUserForm,
props: { userId: 123 },
onConfirm: async () => {
await saveUser()
useToast().success('保存成功')
}
})
目标:灵活渲染任意内容组件,带 confirm/cancel 回调。
2. 实现核心:基于组合式 API + Teleport 动态挂载
封装这套系统的关键在于:
- 使用
defineComponent
+Teleport
实现组件层。 - 利用
createVNode
+render
动态挂载组件实例。 - 在组合式 API 中暴露
useXXX()
方法,管理状态、弹窗堆栈等。
3. 设计通用 API 结构
我们统一抽象一个结构用于调用:
js
// toast.ts
type ToastLevel = 'success' | 'error' | 'info' | 'loading'
interface ToastApi {
success: (msg: string) => void
error: (msg: string) => void
loading: (msg: string) => { close: () => void }
}
export function useToast(): ToastApi {
// ...实现略
}
js
// dialog.ts
interface DialogApi {
confirm: (msg: string, options?: ConfirmOptions) => Promise<boolean>
}
export function useDialog(): DialogApi {
// ...实现略
}
js
// modal.ts
interface ModalOptions {
title: string
content: Component
props?: Record<string, any>
onConfirm?: () => Promise<void> | void
onCancel?: () => void
}
interface ModalApi {
open: (options: ModalOptions) => void
}
export function useModal(): ModalApi {
// ...实现略
}
4. UI 风格灵活适配
建议将提示组件风格交由配置或样式变量控制,例如:
js
// theme.ts
export const toastDefaultDuration = 3000
export const toastPosition = 'top-right'
export const modalZIndex = 9999
这样便于后期适配 UI 框架(Element Plus、Naive UI、Ant Design Vue)或切换暗黑模式。
5. 生命周期管理 + 垃圾清理
对于 Toast
和 Modal
,还需注意以下实现细节:
- 动态创建后自动销毁(
unmount()
)。 - 异步组件加载错误处理。
- 防止重复弹窗或内存泄漏。
四、实战封装:useToast
的完整实现
完整实现一个基于 Vue 3 的全局消息提示组件系统,包括以下目标:
- 支持多类型消息(success / error / info / loading)
- 支持自动关闭或手动关闭
- 可动态创建多个提示,叠加显示
- 使用
Teleport
动态挂载到body
- 提供组合式 API:
useToast().success(...)
1. 创建 Toast.vue
组件
js
<!-- components/Toast.vue -->
<template>
<div class="toast" :class="type" :style="style">
<span>{{ message }}</span>
<button v-if="closable" @click="close">×</button>
</div>
</template>
<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref } from 'vue'
defineProps<{
id: string
message: string
type: 'success' | 'error' | 'info' | 'loading'
duration: number
onClose: (id: string) => void
}>()
const closable = ref(false)
onMounted(() => {
if (duration > 0) {
setTimeout(() => {
close()
}, duration)
} else {
closable.value = true
}
})
function close() {
onClose(id)
}
</script>
<style scoped>
.toast {
padding: 10px 16px;
margin: 8px;
border-radius: 6px;
color: #fff;
background: #333;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
position: relative;
}
.toast.success { background: #52c41a; }
.toast.error { background: #f5222d; }
.toast.info { background: #1890ff; }
.toast.loading { background: #faad14; }
.toast button {
position: absolute;
top: 6px;
right: 8px;
background: none;
border: none;
color: #fff;
cursor: pointer;
}
</style>
2. 编写 useToast.ts
js
// composables/useToast.ts
import { createVNode, render } from 'vue'
import Toast from '@/components/Toast.vue'
let seed = 0
const container = document.createElement('div')
container.style.position = 'fixed'
container.style.top = '20px'
container.style.right = '20px'
container.style.zIndex = '9999'
document.body.appendChild(container)
const instances = new Map<string, HTMLElement>()
function createToast(
message: string,
type: 'success' | 'error' | 'info' | 'loading',
duration: number = 3000
) {
const id = `toast_${seed++}`
const vnode = createVNode(Toast, {
id,
message,
type,
duration,
onClose: (id: string) => {
const el = instances.get(id)
if (el) {
render(null, el)
container.removeChild(el)
instances.delete(id)
}
}
})
const mountNode = document.createElement('div')
container.appendChild(mountNode)
render(vnode, mountNode)
instances.set(id, mountNode)
if (type === 'loading') {
return {
close: () => {
const el = instances.get(id)
if (el) {
render(null, el)
container.removeChild(el)
instances.delete(id)
}
}
}
}
}
export function useToast() {
return {
success: (msg: string) => createToast(msg, 'success'),
error: (msg: string) => createToast(msg, 'error'),
info: (msg: string) => createToast(msg, 'info'),
loading: (msg: string) => createToast(msg, 'loading', 0)
}
}
3. 使用示例(页面中调用)
js
import { useToast } from '@/composables/useToast'
const toast = useToast()
toast.success('保存成功')
toast.error('删除失败')
const loading = toast.loading('上传中...')
setTimeout(() => loading.close(), 3000)
五、扩展封装:实现 useDialog
和 useModal
的方案
在实际项目中,除了轻量级的 Toast,我们还经常需要更复杂的 模态交互组件,比如:
- 确认弹窗(Confirm Dialog)
- 表单弹窗(Modal Form)
- 自定义内容弹窗(嵌套组件)
封装一个 可组合式调用、支持组件参数传入的全局对话框系统 ,并提供 useDialog()
与 useModal()
两种 API 接口。
1. 设计目标
我们希望最终支持以下调用方式:
js
const dialog = useDialog()
dialog.confirm({
title: '确认删除',
content: '你确定要删除这条数据吗?',
onConfirm: () => { /* 删除逻辑 */ }
})
或者:
js
const modal = useModal()
modal.open({
title: '编辑资料',
component: EditFormComponent,
props: { id: 123 },
onClose: (result) => { console.log(result) }
})
2. 创建 Dialog 容器组件
js
<!-- components/Dialog.vue -->
<template>
<Teleport to="body">
<div v-if="visible" class="dialog-overlay">
<div class="dialog-box">
<h3>{{ title }}</h3>
<div class="dialog-content">
<slot>{{ content }}</slot>
</div>
<div class="dialog-actions">
<button @click="onCancel">取消</button>
<button @click="onConfirm">确定</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, watch, onUnmounted } from 'vue'
const props = defineProps<{
title: string
content?: string
onConfirm: () => void
onCancel: () => void
}>()
const visible = ref(true)
function onConfirm() {
props.onConfirm()
visible.value = false
}
function onCancel() {
props.onCancel()
visible.value = false
}
</script>
<style scoped>
.dialog-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
}
.dialog-box {
background: white;
border-radius: 8px;
padding: 20px;
min-width: 300px;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.dialog-actions button {
margin-left: 12px;
}
</style>
3. 编写 useDialog.ts
调用逻辑
js
// composables/useDialog.ts
import { createVNode, render } from 'vue'
import Dialog from '@/components/Dialog.vue'
export function useDialog() {
return {
confirm({ title, content, onConfirm }: {
title: string,
content: string,
onConfirm: () => void
}) {
const container = document.createElement('div')
document.body.appendChild(container)
const vnode = createVNode(Dialog, {
title,
content,
onConfirm: () => {
onConfirm()
close()
},
onCancel: close
})
function close() {
render(null, container)
document.body.removeChild(container)
}
render(vnode, container)
}
}
}
4. 使用方式示例
js
import { useDialog } from '@/composables/useDialog'
const dialog = useDialog()
dialog.confirm({
title: '删除确认',
content: '确认删除当前项吗?',
onConfirm: () => {
// 执行删除逻辑
}
})
5. Bonus:封装 useModal(支持嵌套组件)
类似上面的对话框,我们也可以封装 useModal
,传入任意组件作为弹窗内容,实现"组件级"弹窗通信。其核心思路与上面一致,只需将组件作为参数传入 createVNode()
中。
六、总结与实践建议:让 Toast/Dialog/Modal 形成统一通信体系
在前面的内容中,我们分别介绍了如何封装 Toast
、Dialog
、Modal
三种全局消息提示组件,并通过 Composition API 提供了 useToast
、useDialog
、useModal
等调用方式。 这一章,我们将站在更高的维度,整合思路,总结在 Vue 3 项目中构建 统一的全局消息提示体系 的最佳实践。
三种提示工具的组合式 API 统一封装建议
可以创建一个统一的 messageCenter.ts
模块,将所有提示类接口集中管理,方便全局调用与维护
js
// composables/messageCenter.ts
import { useToast } from './useToast'
import { useDialog } from './useDialog'
import { useModal } from './useModal'
export const messageCenter = {
toast: useToast(),
dialog: useDialog(),
modal: useModal()
}
使用时这样调用:
js
import { messageCenter } from '@/composables/messageCenter'
messageCenter.toast.success('操作成功!')
messageCenter.dialog.confirm({
title: '提示',
content: '确定执行此操作?',
onConfirm: () => {
// do something
}
})
messageCenter.modal.open({
title: '编辑资料',
component: EditForm,
props: { id: 123 }
})
这种方式既提升了使用一致性,又使代码结构更加清晰。
全局注册组件建议(可选)
为了避免每次弹窗都手动挂载组件,也可以选择将 ToastContainer.vue、DialogContainer.vue、ModalHost.vue 之类的组件在 App.vue 或布局组件中全局注册,只做一次挂载,并暴露统一的控制接口(例如通过 Pinia 或 EventEmitter 控制展示状态)。
但这种方式属于 "驻留型组件" 管理,适合提示频繁、需要缓存状态的场景,开发者可根据实际项目需求进行权衡。