Code前端首页关于Code前端联系我们

Vue3怎么做一个好用又通用的Notification通知组件?新手能看懂的完整教程?

terry 48分钟前 阅读数 4 #Vue
文章标签 Vue3Notification组件

很多刚接触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: 4000position: '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文件夹,放三个文件:

  1. NotificationItem.vue:单个通知的组件
  2. NotificationContainer.vue:通知容器的组件,用来管理所有的通知
  3. 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组件已经满足了基础功能和大部分加分项,但还有一些可以优化的地方,

  1. 支持传入HTML内容:可以用v-html来渲染,但要注意XSS攻击的风险。
  2. 支持拖动:可以用一些原生的JS事件或者第三方库(比如SortableJS,但拖动对通知来说可能不是必须的)。
  3. 支持自定义样式:可以通过props传class或者style对象,覆盖默认样式。
  4. 支持SSR(服务端渲染):Teleport在SSR里的处理有点特殊,需要做一些调整。
  5. 支持响应式宽度:比如在移动端把通知的宽度设为100%,减去一些padding。

不过作为一个新手能看懂的通用组件,刚才的代码已经足够用了,大家可以根据自己的项目需求在这个基础上修改。

自己写一个Vue3 Notification组件其实并不难,核心就是掌握Teleport传送门、组合式API、动态组件挂载、TransitionGroup过渡列表这几个知识点,而且自己写的组件更灵活,可以完全符合项目的UI风格,不用硬套第三方组件库的样式,希望这篇教程能帮到刚接触Vue3的小伙伴!

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门