Vue3项目里怎样快速封装一个好用又通用的Toast组件?不用第三方库也行?
这个问题最近半年在前端社群和问答平台出现的频率特别高,尤其是刚从Vue2转过来的新手开发者,或者是对第三方UI库体积、定制化程度不满意的团队,都在找自己搞的方案,其实不用太纠结,Vue3的Composition API + Teleport + Transition组件,已经把封装Toast的核心难点都解决得差不多了,整个过程大概10分钟就能写好基础版,再花点时间加些细节就能覆盖90%的场景。
先搞懂:为什么推荐用Vue3原生API封装,不直接用第三方?
可能有人会说,直接搬Element Plus、Ant Design Vue的Toast不香吗?体积小的比如vue-toastification也有啊,何必自己折腾?如果你的项目已经在用这些大厂UI库,直接用当然没问题;但如果是小项目、个人站,或者是对UI风格、交互细节有强定制要求,甚至是第三方库的版本兼容性踩过坑的,自己封装就很有必要了。
小项目/个人站的核心诉求:体积可控
大厂UI库的Toast虽然好用,但很多都是按需引入的前提下,还得带个核心CSS、JS基础模块;比如vue-toastification,最小化的ES模块引入大概是10KB左右,但如果你只需要“显示成功/失败提示、自动消失、点击不重叠”这几个最基本的功能,自己写的代码连1KB都不到(压缩后),完全没必要多占资源。
团队项目的核心痛点:定制化无限制
第三方UI库的Toast,样式改起来有时候还挺麻烦,得用深度选择器、覆盖变量,甚至有些交互逻辑(比如提示文字自动换行但不能超过2行、加载动画是自定义SVG、消失前有个渐隐同时缩小的复合动画)根本改不了;自己封装的话,CSS、JS、模板全是自己写的,想怎么改就怎么改,没有任何约束。
还有个小细节:代码洁癖和学习需求
自己封装一个完整的Vue3通用组件,能更深入地理解Teleport、Transition、provide/inject、ref数组管理多个实例这些核心API的用法,而且自己写的代码维护起来也顺手,出了问题不用去翻第三方库的issues。
基础准备:Vue3封装通用Toast的核心原生API有哪些?
在正式动手之前,先理清楚要用的几个核心东西,不然写起来容易卡壳:
Teleport组件:把Toast挂载到body上
这是最关键的API之一!如果不使用Teleport,Toast就会被挂载在当前组件的DOM树里,一旦当前组件销毁,Toast也会跟着消失,而且还可能受到当前组件的CSS overflow、z-index等样式的影响,导致显示不全或者被其他元素挡住,用Teleport的话,不管你在哪里触发Toast,它都会被直接挂载到<body>标签下的某个指定容器里,完全独立于当前组件的DOM树。
Composition API的ref、reactive、computed、onMounted、onUnmounted:管理Toast的状态和生命周期
ref数组用来管理多个同时显示的Toast实例(比如连续点击提交按钮,显示多个成功/失败提示,而不是只显示最新的一个);reactive用来定义单个Toast的属性(比如内容、类型、持续时间、位置);computed用来根据类型动态切换图标、背景色;onMounted可以用来在挂载时自动开始倒计时,onUnmounted可以用来清理倒计时定时器,防止内存泄漏。
Transition组件或者TransitionGroup组件:给Toast加进入/离开动画
如果只需要显示一个Toast,用Transition就行;如果要显示多个不重叠的Toast,最好用TransitionGroup,因为它能自动给每个进入/离开的元素分配动画类名,还能处理列表的移动动画(比如中间的某个Toast消失了,下面的Toast会自动往上滑填补空缺)。
provide/inject或者Pinia/Vuex:让Toast在任何组件、任何地方都能调用
如果只是在单个组件里用Toast,直接写个方法触发就行;但通用的Toast肯定要在整个项目里用,provide/inject是最轻量的方案(适合小项目),Pinia/Vuex是更规范的方案(适合大项目),这里我们先用provide/inject,因为简单,后面再讲怎么改成Pinia版本。
正式动手:一步步写一个90%场景都能用的Toast组件
好了,准备工作做完了,现在开始写代码,我会把每个步骤都拆得很细,保证新手也能看懂。
第一步:创建Toast相关的文件
为了代码结构清晰,不要把所有代码都塞在一个.vue文件里,我们可以新建一个src/components/Toast文件夹,里面放三个文件:
index.vue:Toast的模板、样式、单个Toast的逻辑useToast.js:管理多个Toast实例、provide/inject的逻辑index.js:入口文件,用来在main.js里全局注册或者导出供局部使用
第二步:写单个Toast的模板和样式(index.vue)
单个Toast的UI其实很简单,就是一个圆角的小盒子,里面有图标和文字;样式要注意z-index要足够大(比如9999),防止被其他元素挡住,还要有半透明的背景、阴影,让它看起来更美观。
这里我写的是一个居中顶部显示的基础样式,位置、颜色这些后面会改成可配置的:
<!-- src/components/Toast/index.vue -->
<template>
<Transition name="toast">
<!-- 单个Toast的容器 -->
<div v-if="visible" class="toast-item" :class="typeClass">
<!-- 图标部分 -->
<span v-if="icon" class="toast-icon" v-html="icon"></span>
<!-- 文字部分 -->
<span class="toast-text">{{ message }}</span>
</div>
</Transition>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
// 父组件传来的props
const props = defineProps({
// Toast的唯一标识,用来删除
id: {
type: String,
required: true
},
// 显示的文字内容
message: {
type: String,
default: '提示内容'
},
// Toast的类型:success成功、error失败、warning警告、info普通、loading加载
type: {
type: String,
default: 'info',
validator: (val) => ['success', 'error', 'warning', 'info', 'loading'].includes(val)
},
// 持续时间,单位毫秒,loading类型默认不自动消失
duration: {
type: Number,
default: 2500
},
// 自定义图标,SVG字符串或者HTML字符串
icon: {
type: String,
default: ''
},
// 关闭Toast的回调函数,父组件(useToast.js)传来的
onClose: {
type: Function,
required: true
}
});
// 控制单个Toast显示/隐藏的ref
const visible = ref(false);
// 根据type动态计算的类名,用来切换背景色和图标
const typeClass = computed(() => `toast-${props.type}`);
// 倒计时定时器,用来自动关闭Toast
let timer = null;
// 组件挂载后延迟50ms显示,这样Transition的进入动画能正常触发
onMounted(() => {
setTimeout(() => {
visible.value = true;
}, 50);
// 如果不是loading类型,才设置自动关闭
if (props.type !== 'loading') {
timer = setTimeout(() => {
closeToast();
}, props.duration);
}
});
// 组件卸载前清理定时器,防止内存泄漏
onUnmounted(() => {
if (timer) clearTimeout(timer);
});
// 关闭Toast的方法:先触发离开动画,然后调用父组件的onClose回调删除这个实例
const closeToast = () => {
visible.value = false;
// 等离开动画结束(300ms,和CSS里的transition-duration一致)再删除实例
setTimeout(() => {
props.onClose(props.id);
}, 300);
};
// 暴露closeToast方法,供父组件或者外部调用(比如手动关闭loading类型的Toast)
defineExpose({
closeToast
});
</script>
<style scoped>
/* 单个Toast的基础样式 */
.toast-item {
position: fixed;
left: 50%;
transform: translateX(-50%);
top: 20px;
padding: 12px 24px;
border-radius: 8px;
background-color: rgba(0, 0, 0, 0.75);
color: #fff;
font-size: 14px;
line-height: 1.5;
display: flex;
align-items: center;
gap: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 9999;
max-width: 80vw;
word-break: break-all;
/* 限制最多显示2行文字,超出显示省略号 */
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 根据type切换背景色 */
.toast-success {
background-color: rgba(82, 196, 26, 0.9);
}
.toast-error {
background-color: rgba(245, 34, 45, 0.9);
}
.toast-warning {
background-color: rgba(250, 173, 20, 0.9);
}
.toast-info {
background-color: rgba(0, 0, 0, 0.75);
}
.toast-loading {
background-color: rgba(0, 0, 0, 0.75);
/* loading类型不限制行数,因为可能需要显示更长的加载提示 */
display: flex;
-webkit-line-clamp: unset;
-webkit-box-orient: unset;
}
/* 进入/离开动画 */
.toast-enter-active,
.toast-leave-active {
transition: all 0.3s ease;
}
.toast-enter-from {
opacity: 0;
transform: translateX(-50%) translateY(-20px);
}
.toast-leave-to {
opacity: 0;
transform: translateX(-50%) translateY(-20px) scale(0.9);
}
/* 基础图标样式,这里用的是简单的SVG,你可以换成自己喜欢的图标库或者自定义SVG */
.toast-icon {
display: inline-flex;
width: 16px;
height: 16px;
flex-shrink: 0;
}
</style>
第三步:写管理多个Toast实例的逻辑(useToast.js)
这一步的核心是用ref数组来存储所有的Toast实例,每个实例都有唯一的id,然后提供show、success、error、warning、info、loading、close、closeAll这些方法,最后通过provide把这些方法暴露给整个项目的子组件。
这里还要注意生成唯一id的方法,最简单的就是用时间戳 + 随机数:
// src/components/Toast/useToast.js
import { ref, provide, inject } from 'vue';
import ToastComponent from './index.vue';
// 定义inject的key,防止和其他provide/inject的key冲突
const TOAST_KEY = Symbol('toast');
// 预设的图标,都是简单的SVG字符串,你可以换成自己的
const DEFAULT_ICONS = {
success: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>',
error: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="15" y1="9" x2="9" y2="15"></line><line x1="9" y1="9" x2="15" y2="15"></line></svg>',
warning: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>',
info: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>',
loading: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="animate-spin"><line x1="12" y1="2" x2="12" y2="6"></line><line x1="12" y1="18" x2="12" y2="22"></line><line x1="4.93" y1="4.93" x2="7.76" y2="7.76"></line><line x1="16.24" y1="16.24" x2="19.07" y2="19.07"></line><line x1="2" y1="12" x2="6" y2="12"></line><line x1="18" y1="12" x2="22" y2="12"></line><line x1="4.93" y1="19.07" x2="7.76" y2="16.24"></line><line x1="16.24" y1="7.76" x2="19.07" y2="4.93"></line></svg>'
};
// 加载动画的CSS,因为是给SVG加的,所以放在这里用style标签动态插入到head里
const insertLoadingCSS = () => {
if (document.getElementById('toast-loading-css')) return;
const style = document.createElement('style');
style.id = 'toast-loading-css';
style.textContent = `
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.animate-spin {
animation: spin 1s linear infinite;
}
`;
document.head.appendChild(style);
};
// 生成唯一id的方法
const generateId = () => {
return `toast-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
};
// 创建useToast组合式函数,供provide和局部使用
export const createToast = () => {
// 先插入加载动画的CSS
insertLoadingCSS();
// 存储所有Toast实例的ref数组
const toasts = ref([]);
// 显示Toast的通用方法
const show = (options = {}) => {
// 处理参数:如果传入的是字符串,就当成message
if (typeof options === 'string') {
options = { message: options };
}
// 合并默认参数
const mergedOptions = {
id: generateId(),
message: '提示内容',
type: 'info',
duration: 2500,
icon: '',
position: 'top-center', // 暂时写死,后面讲怎么改成可配置的
...options
};
// 如果没有传自定义图标,就用预设的
if (!mergedOptions.icon) {
mergedOptions.icon = DEFAULT_ICONS[mergedOptions.type];
}
// 把新的Toast实例加到数组的开头,这样最新的Toast会显示在最上面
toasts.value.unshift(mergedOptions);
// 返回当前Toast的id,方便后续手动关闭
return mergedOptions.id;
};
// 快捷方法
const success = (message, options = {}) => show({ message, type: 'success', ...options });
const error = (message, options = {}) => show({ message, type: 'error', ...options });
const warning = (message, options = {}) => show({ message, type: 'warning', ...options });
const info = (message, options = {}) => show({ message, type: 'info', ...options });
const loading = (message = '加载中...', options = {}) => show({ message, type: 'loading', duration: 0, ...options });
// 关闭指定id的Toast
const close = (id) => {
const index = toasts.value.findIndex(toast => toast.id === id);
if (index !== -1) {
toasts.value.splice(index, 1);
}
};
// 关闭所有Toast
const closeAll = () => {
toasts.value = [];
};
// 返回所有方法和toasts数组
return {
toasts,
show,
success,
error,
warning,
info,
loading,
close,
closeAll
};
};
// 全局注册Toast的方法,在main.js里调用
export const provideToast = (app) => {
const toast = createToast();
app.provide(TOAST_KEY, toast);
// 还可以把toast挂载到app.config.globalProperties上,供选项式API或者this.$toast使用
app.config.globalProperties.$toast = toast;
};
// 供组合式API使用的inject方法
export const useToast = () => {
const toast = inject(TOAST_KEY);
if (!toast) {
throw new Error('请先在main.js里调用provideToast(app)注册Toast!');
}
return toast;
};
第四步:写入口文件和Teleport容器(index.js + 全局Teleport容器)
入口文件index.js很简单,就是把所有东西导出:
// src/components/Toast/index.js
export { createToast, provideToast, useToast } from './useToast';
export { default as ToastContainer } from './ToastContainer.vue';
等等,刚才漏了一个ToastContainer.vue?对,刚才的useToast.js里只管理了toasts数组,但是没有把这些数组渲染成DOM,所以我们需要一个全局的ToastContainer.vue组件,里面用Teleport把所有的Toast实例挂载到body上,还要用TransitionGroup来处理多个实例的动画:
<!-- src/components/Toast/ToastContainer.vue -->
<template>
<!-- Teleport:把所有Toast挂载到body上的toast-container容器里 -->
<Teleport to="body">
<div class="toast-container" :class="positionClass">
<!-- TransitionGroup:处理多个Toast实例的进入/离开/移动动画 -->
<TransitionGroup name="toast-list">
<!-- 遍历toasts数组,渲染每个Toast实例 -->
<ToastComponent
v-for="toast in toasts"
:key="toast.id"
:id="toast.id"
:message="toast.message"
:type="toast.type"
:duration="toast.duration"
:icon="toast.icon"
:onClose="close"
/>
</TransitionGroup>
</div>
</Teleport>
</template>
<script setup>
import { computed } from 'vue';
import { useToast } from './useToast';
import ToastComponent from './index.vue';
// 获取toasts数组和close方法
const { toasts, close } = useToast();
// 暂时写死positionClass,后面讲怎么改
const positionClass = computed(() => 'toast-top-center');
</script>
<style scoped>
/* Toast容器的基础样式,暂时写死为居中顶部,后面讲怎么改成可配置的 */
.toast-container {
position: fixed;
z-index: 9998;
pointer-events: none; /* 让Toast容器不阻挡下面的元素的点击事件 */
}
.toast-top-center {
top: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
gap: 10px;
align-items: center;
}
/* TransitionGroup的移动动画,让中间的Toast消失后,下面的Toast往上滑填补空缺 */
.toast-list-move {
transition: all 0.3s ease;
}
</style>
第五步:在main.js里全局注册Toast
现在所有的代码都写好了,接下来在main.js里注册一下,就可以在整个项目里用了:
// src/main.js
import { createApp } from 'vue';
import App from './App.vue';
// 导入我们的ToastContainer和provideToast
import { provideToast } from './components/Toast';
import ToastContainer from './components/Toast/ToastContainer.vue';
const app = createApp(App);
// 全局注册Toast的provide和globalProperties
provideToast(app);
// 全局注册ToastContainer组件
app.component('ToastContainer', ToastContainer);
app.mount('#app');
在App.vue的最底部(或者任何地方,因为Teleport会把它移到body上)加上<ToastContainer />:
<!-- src/App.vue -->
<template>
<div class="app">
<!-- 你的其他组件 -->
<h1>Vue3 Toast 测试页面</h1>
<button @click="showSuccess">显示成功提示</button>
<button @click="showError">显示失败提示</button>
<button @click="showWarning">显示警告提示</button>
<button @click="showInfo">显示普通提示</button>
<button @click="showLoading">显示加载提示(3秒后自动关闭)</button>
<button @click="closeAll">关闭所有提示</button>
<!-- Toast容器,放在这里就行 -->
<ToastContainer />
</div>
</template>
<script setup>
import { useToast } from './components/Toast';
// 获取toast的所有方法
const { success, error, warning, info, loading, close, closeAll } = useToast();
// 测试方法
const showSuccess = () => success('操作成功!');
const showError = () => error('操作失败,请稍后重试!');
const showWarning = () => warning('这是一条警告信息!');
const showInfo = () => info('这是一条普通提示信息!');
const showLoading = () => {
const id = loading('数据正在加载中,请稍候...');
// 3秒后手动关闭加载提示
setTimeout(() => {
close(id);
success('数据加载成功!');
}, 3000);
};
</script>
<style scoped>
.app {
padding: 40px;
display: flex;
flex-direction: column;
gap: 20px;
align-items: center;
}
button {
padding: 10px 20px;
border: none;
border-radius: 4px;
background-color: #409eff;
color: #fff;
font-size: 14px;
cursor: pointer;
}
button:hover {
background-color: #66b1ff;
}
</style>
现在运行你的Vue3项目,点击这些按钮,应该就能看到效果了!
进阶优化:让Toast更通用、更好用
刚才写的是基础版,现在我们可以加一些进阶功能,让它覆盖更多的场景:
支持多个显示位置
刚才的位置是写死的top-center,现在我们可以改成支持top-left、top-center、top-right、bottom-left、bottom-center、bottom-right、center这些位置。
修改useToast.js里的show方法和mergedOptions,把position设为可配置的,默认还是top-center;修改ToastContainer.vue里的positionClass,根据toast.position来计算;不对,等一下,刚才的positionClass是写在ToastContainer上的,但如果我们支持同时在不同位置显示Toast呢?比如同时在top-center显示成功提示,在bottom-right显示错误提示?
哦,对哦,刚才的实现有个问题:所有的Toast都放在同一个容器里,只能显示在同一个位置,要支持同时在不同位置显示Toast,我们需要给每个位置创建一个单独的容器,然后在useToast.js里按位置分组存储Toast实例。
这个修改稍微有点复杂,但更合理:
- 修改
useToast.js里的toastsref,从数组改成对象,键是位置,值是该位置的Toast实例数组 - 修改
show方法,根据mergedOptions.position把新实例加到对应的数组开头 - 修改
ToastContainer.vue,遍历所有的位置,为每个位置创建一个单独的Teleport容器和TransitionGroup - 修改
ToastComponent.vue里的position相关的样式,或者直接把positionClass从ToastContainer移到ToastComponent?不,还是把不同位置的容器分开,这样TransitionGroup的移动动画只会在同一个位置的Toast之间生效,更合理。
具体的代码修改这里就不全部贴出来了,大家可以自己试试,核心思路就是按位置分组存储和渲染。
支持点击Toast关闭
刚才的Toast只有自动关闭和手动调用close(id)关闭,现在我们可以加个clickable参数,让用户点击Toast的时候也能关闭:
- 在
useToast.js里的mergedOptions里加个clickable参数,默认true - 在
ToastComponent.vue里的props里加个clickable参数 - 在
ToastComponent.vue的.toast-item上加个@click="clickable && closeToast()"的点击事件 - 还要给
.toast-item加个cursor: pointer的样式,当clickable为true的时候显示
支持自定义内容(比如插槽)
刚才的Toast只能显示图标和文字,现在我们可以加个插槽,让用户可以自定义内容(比如显示图片、按钮、输入框等):
- 在
ToastComponent.vue的模板里加个<slot></slot>,如果有插槽内容就显示插槽,否则显示图标和文字 - 在
useToast.js里的mergedOptions里加个render参数,支持传入一个函数,返回VNode,这样在组合式API里也能自定义内容 - 这里要注意,Vue3的Teleport和TransitionGroup对插槽和VNode的支持是没问题的,但要注意VNode的key和id的处理。
支持防止重复点击
刚才的Toast如果连续点击提交按钮,会显示多个相同的提示,现在我们可以加个preventDuplicate参数,或者duplicateKey参数,防止重复显示相同的提示:
- 在
useToast.js里的mergedOptions里加个duplicateKey参数,默认是message - 在
show方法里,先检查该位置的Toast实例数组里有没有duplicateKey相同的实例,如果有就不添加,或者替换掉 - 还可以加个
preventDuplicate参数,默认false,控制是否开启防止重复点击。
改成Pinia版本(适合大项目)
如果你的项目已经在用Pinia,把Toast的状态和逻辑放到Pinia store里会更规范,方便调试和维护:
- 创建一个
src/stores/toast.js的Pinia store - 把
useToast.js里的toasts、show、success等方法移到这个store里 - 在
ToastContainer.vue里用useToastStore()代替useToast() - 在其他组件里也用
useToastStore()代替useToast() - 这样就不需要provide/inject了,而且Pinia的devtools还能看到Toast的状态变化,调试起来更方便。
自己封装Vue3 Toast的优缺点
刚才我们一步步写了一个基础版的Vue3 Toast,还讲了一些进阶优化的思路,现在总结一下自己封装的优缺点,方便大家根据自己的项目情况选择:
优点
- 体积极小,完全可控,没有多余的依赖
- 定制化无限制,样式、交互、逻辑全是自己写的
- 能更深入地理解Vue3的核心API
- 维护起来顺手,出了问题不用翻第三方库的issues
缺点
- 需要自己写测试用例,保证兼容性和稳定性
- 如果要支持很多高级功能(比如国际化、自定义主题、响应式布局适配等),需要花更多的时间
- 没有大厂UI库那么完善的文档和社区支持
对于大部分小项目、个人站,或者是对第三方库有强定制要求的团队,自己封装一个Toast组件是完全值得的,而且整个过程也很简单,大概10分钟就能写好基础版,再花点时间加些细节就能用了。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网

