Vue3怎么做一个好用又通用的Notification通知组件?新手能看懂的完整教程?
很多刚接触Vue3的小伙伴,在做后台管理系统、电商平台或者APP H5版的时候,都会遇到一个高频场景:给用户展示操作反馈——比如表单提交成功啦、网络超时啦、密码输入格式不对啦,这些都叫通知,或者Notification,自己写一个的话,既能符合项目UI风格,不用硬套第三方组件库(比如Element Plus的默认样式有时候不够灵活),还能练手Vue3的组合式API、Teleport传送门、动态组件挂载这些核心知识点。
先理清楚,一个“好用通用”的Vue3 Notification得满足哪些点?
在动手敲代码之前,得先想明白我们要做的东西到底有什么功能,不能想到哪写到哪,结合常用的第三方组件和实际开发需求,我整理了几个必选项和加分项:
必选项:基础功能全适配
位置可配置
通知不能总弹在同一个地方吧?比如有的项目喜欢右上角,有的喜欢右下角、顶部中间,甚至全屏居中(不过全屏居中更像Dialog),至少要支持上下左右加中间的8个常见位置,每个位置的通知还要能堆叠,新来的在最上面或者按顺序往下排?对,一般都是按顺序,比如右上角的通知,新来的在最上面,旧的往下挪。
类型和图标自定义/内置
常用的通知类型肯定要有:成功、失败、警告、信息这四种,每种配个默认图标,比如对勾、叉号、感叹号、灯泡,显得专业,另外还要允许用户传自定义图标,不管是SVG组件、img标签还是emoji都行。
显示时长可控制
成功、信息这种不重要的反馈,一般3-5秒自动消失;失败、警告这种需要用户重点关注的,可以设长一点,甚至设为0,让用户手动点叉号关闭,还要支持鼠标悬停的时候暂停倒计时,移开之后继续——这个细节很重要,比如用户正在看通知内容,结果倒计时到了自动关了,体验会很差。
支持单行/多行文本,还能加操作按钮
有的时候通知内容只有一句话“提交成功!”,有的时候需要写两行“您的订单已支付完成,预计3-5天内发货,请关注物流信息”,甚至还要加个“查看订单”的按钮,点了直接跳转到订单详情页。
组件化调用简单
不能每次用都要在template里写一堆Notification.success('提交成功!'),或者Notification.warning({ title: '注意', content: '剩余库存不足', duration: 0 }),这种调用方式比组件式灵活太多了,想在哪调就在哪调,不用考虑组件的挂载位置。
加分项:细节打磨显专业
入场/离场动画
通知弹出来的时候要有个淡入加位移的效果,消失的时候反过来,这样界面不会太生硬,可以用Vue3自带的TransitionGroup组件,因为多个通知要堆叠动画,单个Transition不管用。
关闭按钮可隐藏
有些极简风格的项目,可能不希望显示关闭按钮,只靠自动消失,所以得加个showClose的配置项。
可以设置通知的唯一ID
万一用户重复点击按钮,弹了好几个一模一样的通知怎么办?可以通过唯一ID来限制,比如第二次点击的时候,如果ID一样,就不弹新的,或者替换掉旧的。
支持全局配置默认值
比如项目里所有的成功通知都要4秒自动消失,都要显示在右下角,那就不用每次调用都传duration: 4000和position: 'bottom-right',直接在项目入口文件(main.js/main.ts)里全局配置一下就行。
核心技术点提前预习,新手别慌
既然是练手,肯定要用到Vue3的一些核心API,我先简单提一下它们的作用,后面写代码的时候会详细讲:
Teleport传送门
这个是Vue3新增的超级实用的API!作用就是把一个组件的DOM元素“传送”到指定的父元素下面,而不是跟着当前组件的层级走,为什么要用在Notification里?因为如果把Notification放在当前组件的template里,可能会被父元素的overflow: hidden给挡住,或者层级不够高,被其他弹窗盖住,用Teleport的话,直接传送到<body>标签下面,就不会有这些问题了。
组合式API(ref, reactive, computed, onMounted, onUnmounted, watch, nextTick这些)
ref用来定义响应式的基础数据类型(比如布尔值、数字、字符串),reactive用来定义响应式的对象或者数组(比如我们的通知列表),computed用来根据通知的位置计算容器的样式,onMounted和onUnmounted用来监听鼠标的悬停事件,watch用来监听通知列表的变化,动态挂载/卸载容器,nextTick用来确保DOM更新之后再做动画相关的操作。
动态组件挂载(createApp, mount, unmount)
函数式调用的核心就是这个!我们可以用createApp创建一个临时的Vue应用实例,然后把我们写好的Notification容器组件挂载到一个临时的div上,再把这个div放到body里,这样就不用在template里写任何东西了,调用结束之后,再把这个临时的div和Vue应用实例卸载掉,避免内存泄漏。
TransitionGroup过渡列表组件
刚才说了,多个通知堆叠需要动画,TransitionGroup就是专门干这个的,它会给每个子元素自动添加进入、离开的类名,我们只要写好对应的CSS就行。
开始动手!完整代码一步步写
我会用原生Vue3 + CSS来写,不依赖任何第三方库,新手可以直接复制到自己的项目里用,也可以根据需求修改。
第一步:创建文件结构
先在项目的src目录下创建一个components文件夹,然后在里面创建一个Notification文件夹,放三个文件:
NotificationItem.vue:单个通知的组件NotificationContainer.vue:通知容器的组件,用来管理所有的通知index.js:导出函数式调用的接口
第二步:写单个通知组件NotificationItem.vue
这个组件负责渲染单个通知的内容、图标、关闭按钮、操作按钮,还有处理鼠标悬停暂停倒计时、点击关闭等逻辑。
先写template部分
Teleport暂时不用在这里写,后面放在容器里,单个通知的结构很简单:一个外层div(notification-item),里面左边是图标,中间是标题和内容,右边是关闭按钮,底部可以放操作按钮。
<template>
<div
class="notification-item"
:class="`notification-${type}`"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<!-- 图标区域 -->
<div class="notification-icon" v-if="showIcon">
<!-- 默认图标 -->
<component :is="defaultIcon" v-if="!customIcon" />
<!-- 自定义图标 -->
<component :is="customIcon" v-else />
</div>
<!-- 内容区域 -->
<div class="notification-content">
<!-- 标题 -->
<div class="notification-title" v-if="title">{{ title }}</div>
<!-- 内容 -->
<div class="notification-message">{{ message }}</div>
<!-- 操作按钮 -->
<div class="notification-actions" v-if="actions && actions.length">
<button
v-for="(action, index) in actions"
:key="index"
class="notification-action"
@click="handleActionClick(action)"
>
{{ action.text }}
</button>
</div>
</div>
<!-- 关闭按钮 -->
<div class="notification-close" v-if="showClose" @click="handleClose">
×
</div>
</div>
</template>
再写script部分(组合式API,setup语法糖)
这里要接收父组件传过来的props,比如通知的类型、标题、内容、显示时长、是否显示关闭按钮、是否显示图标、自定义图标、操作按钮、唯一ID、关闭回调函数等,还要处理倒计时逻辑、鼠标悬停逻辑、关闭逻辑。
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
import SuccessIcon from './icons/SuccessIcon.vue';
import ErrorIcon from './icons/ErrorIcon.vue';
import WarningIcon from './icons/WarningIcon.vue';
import InfoIcon from './icons/InfoIcon.vue';
// 定义props
const props = defineProps({
id: {
type: [String, Number],
required: true,
},
type: {
type: String,
default: 'info',
validator: (value) => ['success', 'error', 'warning', 'info'].includes(value),
}, {
type: String,
default: '',
},
message: {
type: String,
default: '',
required: true,
},
duration: {
type: Number,
default: 3000,
},
showClose: {
type: Boolean,
default: true,
},
showIcon: {
type: Boolean,
default: true,
},
customIcon: {
type: [String, Object, Function],
default: null,
},
actions: {
type: Array,
default: () => [],
},
});
// 定义emit
const emit = defineEmits(['close']);
// 响应式数据
const timer = ref(null);
const isPaused = ref(false);
// 根据类型计算默认图标
const defaultIcon = computed(() => {
const iconMap = {
success: SuccessIcon,
error: ErrorIcon,
warning: WarningIcon,
info: InfoIcon,
};
return iconMap[props.type];
});
// 开始倒计时
const startTimer = () => {
if (props.duration <= 0) return;
timer.value = setTimeout(() => {
if (!isPaused.value) {
handleClose();
}
}, props.duration);
};
// 清除倒计时
const clearTimer = () => {
if (timer.value) {
clearTimeout(timer.value);
timer.value = null;
}
};
// 鼠标进入:暂停倒计时
const handleMouseEnter = () => {
isPaused.value = true;
clearTimer();
};
// 鼠标离开:继续倒计时
const handleMouseLeave = () => {
isPaused.value = false;
startTimer();
};
// 关闭通知
const handleClose = () => {
clearTimer();
emit('close', props.id);
};
// 点击操作按钮
const handleActionClick = (action) => {
if (action.onClick) {
action.onClick();
}
if (action.closeAfterClick !== false) {
handleClose();
}
};
// 组件挂载时开始倒计时
onMounted(() => {
startTimer();
});
// 组件卸载时清除倒计时
onUnmounted(() => {
clearTimer();
});
</script>
然后写默认图标组件
刚才的script里引入了四个默认图标,我们也要创建它们,放在Notification/icons文件夹里,这里用SVG来写,简单又好看,而且可以通过CSS改颜色,比如SuccessIcon.vue:
<template>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 6L9 17L4 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</template>
其他三个图标(ErrorIcon、WarningIcon、InfoIcon)可以自己去网上找简单的SVG代码改一下,或者我给个大概的:
- ErrorIcon:一个叉号
- WarningIcon:一个三角形里面加个感叹号
- InfoIcon:一个圆圈里面加个i
最后写CSS部分
CSS要写得通用一点,支持通过类名修改不同类型通知的背景色、文字色、图标色,还要写好堆叠的间距,入场/离场的动画类名(TransitionGroup会自动用的)。
<style scoped>
.notification-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 16px 20px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
min-width: 320px;
max-width: 400px;
position: relative;
margin-bottom: 12px;
background-color: #fff;
overflow: hidden;
transition: all 0.3s ease;
}
/* 不同类型的通知样式 */
.notification-success {
border-left: 4px solid #52c41a;
}
.notification-success .notification-icon {
color: #52c41a;
}
.notification-error {
border-left: 4px solid #ff4d4f;
}
.notification-error .notification-icon {
color: #ff4d4f;
}
.notification-warning {
border-left: 4px solid #faad14;
}
.notification-warning .notification-icon {
color: #faad14;
}
.notification-info {
border-left: 4px solid #1890ff;
}
.notification-info .notification-icon {
color: #1890ff;
}
/* 图标样式 */
.notification-icon {
flex-shrink: 0;
width: 24px;
height: 24px;
margin-top: 2px;
}
样式 */
.notification-content {
flex: 1;
line-height: 1.6;
font-size: 14px;
}
.notification-title {
font-weight: 600;
margin-bottom: 4px;
font-size: 15px;
}
.notification-message {
color: #333;
}
/* 操作按钮样式 */
.notification-actions {
display: flex;
gap: 12px;
margin-top: 12px;
}
.notification-action {
border: none;
background: none;
color: #1890ff;
font-size: 13px;
cursor: pointer;
padding: 0;
transition: color 0.2s ease;
}
.notification-action:hover {
color: #40a9ff;
}
/* 关闭按钮样式 */
.notification-close {
flex-shrink: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: #999;
cursor: pointer;
transition: color 0.2s ease;
margin-top: -2px;
}
.notification-close:hover {
color: #333;
}
/* TransitionGroup的动画类名 */
.notification-enter-active,
.notification-leave-active {
transition: all 0.3s ease;
}
.notification-enter-from {
opacity: 0;
transform: translateX(100%);
}
.notification-leave-to {
opacity: 0;
transform: translateX(100%);
height: 0;
padding-top: 0;
padding-bottom: 0;
margin-bottom: 0;
}
</style>
等等,刚才的入场/离场动画只考虑了从右边进来的情况,比如top-right、bottom-right,如果是从左边进来的(top-left、bottom-left),应该是translateX(-100%),如果是从上面进来的(top-center),应该是translateY(-100%),从下面进来的(bottom-center)应该是translateY(100%),这个问题后面在容器组件里解决,通过props传进来的位置来动态设置动画类名或者样式。
第三步:写通知容器组件NotificationContainer.vue
这个组件负责管理所有的通知,比如添加通知、删除通知、根据位置计算容器的样式、给TransitionGroup设置正确的动画、Teleport到body下面。
先写template部分
这里用Teleport把整个容器传送到body下面,然后用TransitionGroup包裹所有的NotificationItem,最后根据位置动态绑定容器的class。
<template>
<Teleport to="body">
<div class="notification-container" :class="`notification-${position}`">
<TransitionGroup name="notification" tag="div">
<NotificationItem
v-for="notification in notifications"
:key="notification.id"
v-bind="notification"
@close="handleRemoveNotification"
/>
</TransitionGroup>
</div>
</Teleport>
</template>
再写script部分
这里要接收父组件(或者说动态挂载时传的props)传过来的全局配置,比如默认位置、默认显示时长、默认是否显示关闭按钮等,还要有添加通知、删除通知的方法,暴露给外部函数调用,刚才的动画类名问题,这里可以通过computed动态修改TransitionGroup的name属性,或者修改NotificationItem的enter-from/leave-to样式,为了简单,我这里通过computed给容器加个位置类名,然后在CSS里针对不同的位置设置不同的动画。
<script setup>
import { ref, reactive, computed } from 'vue';
import NotificationItem from './NotificationItem.vue';
// 定义全局配置的默认值
const defaultConfig = reactive({
position: 'top-right',
duration: 3000,
showClose: true,
showIcon: true,
maxCount: 10, // 最多显示多少个通知,超过的话删除最早的
});
// 定义通知列表
const notifications = ref([]);
// 生成唯一ID
let idCounter = 0;
const generateId = () => {
return `notification-${++idCounter}`;
};
// 暴露给外部的添加通知方法
const addNotification = (options) => {
// 合并全局配置和用户传的配置
const config = {
id: options.id || generateId(),
...defaultConfig,
...options,
};
// 检查是否有重复ID
const existingIndex = notifications.value.findIndex((n) => n.id === config.id);
if (existingIndex !== -1) {
// 如果有重复ID,替换掉旧的
notifications.value.splice(existingIndex, 1, config);
return;
}
// 检查是否超过最大数量
if (notifications.value.length >= defaultConfig.maxCount) {
// 删除最早的通知
notifications.value.shift();
}
// 添加新通知
notifications.value.push(config);
};
// 暴露给外部的删除通知方法
const removeNotification = (id) => {
const index = notifications.value.findIndex((n) => n.id === id);
if (index !== -1) {
notifications.value.splice(index, 1);
}
};
// 暴露给外部的清空所有通知方法
const clearAllNotifications = () => {
notifications.value = [];
};
// 暴露给外部的修改全局配置方法
const setDefaultConfig = (config) => {
Object.assign(defaultConfig, config);
};
// 接收外部传的位置(或者用全局配置的位置)
const props = defineProps({
position: {
type: String,
default: defaultConfig.position,
},
});
// 子组件触发的关闭事件
const handleRemoveNotification = (id) => {
removeNotification(id);
};
// 暴露方法给外部
defineExpose({
addNotification,
removeNotification,
clearAllNotifications,
setDefaultConfig,
});
</script>
最后写CSS部分
这里要给容器设置不同位置的样式,比如top-right就是固定在右上角,bottom-left就是固定在左下角,还要针对不同的位置设置不同的NotificationItem动画。
<style scoped>
.notification-container {
position: fixed;
z-index: 9999;
padding: 20px;
pointer-events: none; /* 让容器不阻挡下面的元素的点击事件 */
}
.notification-container > div {
pointer-events: auto; /* 让通知项可以点击 */
}
/* 不同位置的容器样式 */
.notification-top-left {
top: 0;
left: 0;
}
.notification-top-center {
top: 0;
left: 50%;
transform: translateX(-50%);
}
.notification-top-right {
top: 0;
right: 0;
}
.notification-center-left {
top: 50%;
left: 0;
transform: translateY(-50%);
}
.notification-center-right {
top: 50%;
right: 0;
transform: translateY(-50%);
}
.notification-bottom-left {
bottom: 0;
left: 0;
}
.notification-bottom-center {
bottom: 0;
left: 50%;
transform: translateX(-50%);
}
.notification-bottom-right {
bottom: 0;
right: 0;
}
/* 不同位置的通知动画 */
.notification-top-left .notification-enter-from,
.notification-top-left .notification-leave-to,
.notification-center-left .notification-enter-from,
.notification-center-left .notification-leave-to {
transform: translateX(-100%);
}
.notification-top-center .notification-enter-from,
.notification-top-center .notification-leave-to {
transform: translateY(-100%);
}
.notification-bottom-center .notification-enter-from,
.notification-bottom-center .notification-leave-to {
transform: translateY(100%);
}
/* 其他位置(top-right、center-right、bottom-left、bottom-right)的动画刚才已经在NotificationItem里写了,是translateX(100%),如果有问题可以再调整 */
</style>
第四步:写函数式调用的接口index.js
这是最关键的一步,要实现动态挂载NotificationContainer组件,然后暴露四个常用的函数(success、error、warning、info)和其他通用方法(setDefaultConfig、clearAll)。
import { createApp, nextTick } from 'vue';
import NotificationContainer from './NotificationContainer.vue';
// 临时的Vue应用实例和DOM元素
let appInstance = null;
let containerEl = null;
let containerVm = null;
// 初始化容器
const initContainer = () => {
if (containerVm) return; // 已经初始化过了,不用再初始化
// 创建临时的div
containerEl = document.createElement('div');
document.body.appendChild(containerEl);
// 创建临时的Vue应用实例
appInstance = createApp(NotificationContainer);
containerVm = appInstance.mount(containerEl);
};
// 通用的通知函数
const notify = (options) => {
initContainer();
nextTick(() => {
containerVm.addNotification(options);
});
};
// 暴露四个常用的函数
const success = (options) => {
if (typeof options === 'string') {
options = { message: options };
}
return notify({ ...options, type: 'success' });
};
const error = (options) => {
if (typeof options === 'string') {
options = { message: options };
}
return notify({ ...options, type: 'error' });
};
const warning = (options) => {
if (typeof options === 'string') {
options = { message: options };
}
return notify({ ...options, type: 'warning' });
};
const info = (options) => {
if (typeof options === 'string') {
options = { message: options };
}
return notify({ ...options, type: 'info' });
};
// 暴露修改全局配置的函数
const setDefaultConfig = (config) => {
initContainer();
nextTick(() => {
containerVm.setDefaultConfig(config);
});
};
// 暴露清空所有通知的函数
const clearAll = () => {
if (containerVm) {
containerVm.clearAllNotifications();
}
};
// 导出所有函数
export default {
notify,
success,
error,
warning,
info,
setDefaultConfig,
clearAll,
};
怎么在项目里用?
写好之后,我们可以在项目的入口文件(main.js/main.ts)里先全局配置一下默认值,然后在其他组件里直接导入使用。
第一步:全局配置(可选但推荐)
比如在main.js里:
import { createApp } from 'vue';
import App from './App.vue';
import Notification from './components/Notification/index.js';
// 全局配置默认值
Notification.setDefaultConfig({
position: 'bottom-right',
duration: 4000,
maxCount: 5,
});
const app = createApp(App);
// 也可以挂载到app.config.globalProperties上,方便在模板里用(不过组合式API里用import更规范)
app.config.globalProperties.$notification = Notification;
app.mount('#app');
第二步:在其他组件里使用
比如在一个登录组件里:
<template>
<div class="login-container">
<form @submit.prevent="handleLogin">
<input type="text" v-model="username" placeholder="请输入用户名" />
<input type="password" v-model="password" placeholder="请输入密码" />
<button type="submit">登录</button>
</form>
</div>
</template>
<script setup>
import { ref } from 'vue';
import Notification from '../components/Notification/index.js';
import { useRouter } from 'vue-router';
const router = useRouter();
const username = ref('');
const password = ref('');
const handleLogin = () => {
// 模拟登录请求
if (!username.value || !password.value) {
Notification.warning({
title: '登录失败',
message: '请输入用户名和密码',
duration: 0,
});
return;
}
// 模拟请求成功
Notification.success({ '登录成功',
message: `欢迎回来,${username.value}!`,
actions: [
{
text: '查看个人中心',
onClick: () => {
router.push('/user');
},
closeAfterClick: true,
},
],
});
};
</script>
第三步:在模板里使用(如果挂载到了globalProperties上)
不过组合式API里用import更规范,也更利于Tree Shaking,所以不推荐在模板里用,但还是提一下:
<template>
<button @click="showInfo">显示信息</button>
</template>
<script setup>
import { getCurrentInstance } from 'vue';
const { proxy } = getCurrentInstance();
const showInfo = () => {
proxy.$notification.info('这是一条信息通知');
};
</script>
可以优化的地方
刚才写的这个Notification组件已经满足了基础功能和大部分加分项,但还有一些可以优化的地方,
- 支持传入HTML内容:可以用
v-html来渲染,但要注意XSS攻击的风险。 - 支持拖动:可以用一些原生的JS事件或者第三方库(比如SortableJS,但拖动对通知来说可能不是必须的)。
- 支持自定义样式:可以通过props传
class或者style对象,覆盖默认样式。 - 支持SSR(服务端渲染):Teleport在SSR里的处理有点特殊,需要做一些调整。
- 支持响应式宽度:比如在移动端把通知的宽度设为100%,减去一些padding。
不过作为一个新手能看懂的通用组件,刚才的代码已经足够用了,大家可以根据自己的项目需求在这个基础上修改。
自己写一个Vue3 Notification组件其实并不难,核心就是掌握Teleport传送门、组合式API、动态组件挂载、TransitionGroup过渡列表这几个知识点,而且自己写的组件更灵活,可以完全符合项目的UI风格,不用硬套第三方组件库的样式,希望这篇教程能帮到刚接触Vue3的小伙伴!
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网


