最近在做项目的时候,发现我们团队在不同页面重复实现类似的提示功能,代码复用率低,而且样式也不统一。于是决定抽离出一个通用的Toast组件,这样大家都可以直接调用,不用重复造轮子了。今天就来分享一下这个组件的开发过程和使用方法。
需求分析
首先,我们需要明确一下Toast组件的基本需求:
- 支持不同类型的提示(成功、错误、警告、信息)
- 可自定义显示时长
- 可以手动关闭
- 支持多个Toast同时显示
- 良好的动画效果
- 响应式设计,在移动端也能正常使用
组件实现
1. 目录结构
toast/
├── index.js # 入口文件,用于注册插件
├── Toast.vue # 组件主文件
└── toast.css # 样式文件
2. Toast.vue 组件实现
<template><transition name="toast-fade"><div v-show="visible" :class="['vue-toast', `vue-toast-${type}`]":style="positionStyle"@mouseenter="clearTimer"@mouseleave="startTimer"><div class="vue-toast-content"><i :class="iconClass" v-if="showIcon"></i><span class="vue-toast-message">{{ message }}</span></div><div class="vue-toast-close" v-if="closable" @click="close"><i class="vue-toast-close-icon">×</i></div></div></transition>
</template><script>
export default {name: 'VueToast',props: {message: {type: String,default: ''},type: {type: String,default: 'info',validator: value => ['success', 'error', 'warning', 'info'].includes(value)},duration: {type: Number,default: 3000},position: {type: String,default: 'top',validator: value => ['top', 'bottom', 'top-left', 'top-right', 'bottom-left', 'bottom-right'].includes(value)},showIcon: {type: Boolean,default: true},closable: {type: Boolean,default: false},offset: {type: Number,default: 20}},data() {return {visible: false,timer: null,height: 0}},computed: {iconClass() {const iconMap = {success: 'vue-toast-icon-success',error: 'vue-toast-icon-error',warning: 'vue-toast-icon-warning',info: 'vue-toast-icon-info'}return iconMap[this.type] || ''},positionStyle() {const positions = {'top': { top: `${this.offset}px`, left: '50%', transform: 'translateX(-50%)' },'bottom': { bottom: `${this.offset}px`, left: '50%', transform: 'translateX(-50%)' },'top-left': { top: `${this.offset}px`, left: `${this.offset}px` },'top-right': { top: `${this.offset}px`, right: `${this.offset}px` },'bottom-left': { bottom: `${this.offset}px`, left: `${this.offset}px` },'bottom-right': { bottom: `${this.offset}px`, right: `${this.offset}px` }}return positions[this.position] || positions.top}},mounted() {this.visible = truethis.startTimer()},beforeDestroy() {this.clearTimer()},methods: {close() {this.visible = falsethis.$emit('close')setTimeout(() => {this.$el.parentNode && this.$el.parentNode.removeChild(this.$el)this.$destroy()}, 300) // 等待过渡动画完成},startTimer() {if (this.duration > 0) {this.clearTimer()this.timer = setTimeout(() => {this.close()}, this.duration)}},clearTimer() {if (this.timer) {clearTimeout(this.timer)this.timer = null}}}
}
</script>
3. 样式文件 toast.css
.vue-toast {position: fixed;display: flex;align-items: center;padding: 12px 20px;border-radius: 4px;box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);background: #fff;z-index: 9999;transition: all 0.3s;max-width: 80%;min-width: 180px;
}.vue-toast-content {display: flex;align-items: center;flex: 1;
}.vue-toast-message {font-size: 14px;line-height: 1.5;word-break: break-word;
}.vue-toast i {margin-right: 10px;font-size: 16px;
}.vue-toast-success {background-color: #f0f9eb;border: 1px solid #e1f3d8;color: #67c23a;
}.vue-toast-error {background-color: #fef0f0;border: 1px solid #fde2e2;color: #f56c6c;
}.vue-toast-warning {background-color: #fdf6ec;border: 1px solid #faecd8;color: #e6a23c;
}.vue-toast-info {background-color: #edf2fc;border: 1px solid #ebeef5;color: #909399;
}.vue-toast-close {margin-left: 10px;cursor: pointer;font-size: 16px;opacity: 0.6;
}.vue-toast-close:hover {opacity: 1;
}/* 图标样式 - 实际项目中可以使用字体图标或SVG */
.vue-toast-icon-success:before { content: "✓"; }
.vue-toast-icon-error:before { content: "✗"; }
.vue-toast-icon-warning:before { content: "!"; }
.vue-toast-icon-info:before { content: "i"; }/* 过渡动画 */
.toast-fade-enter-active, .toast-fade-leave-active {transition: opacity 0.3s, transform 0.3s;
}
.toast-fade-enter, .toast-fade-leave-to {opacity: 0;transform: translateY(-20px);
}
4. 入口文件 index.js
import Vue from 'vue'
import ToastComponent from './Toast.vue'
import './toast.css'// 创建构造器
const ToastConstructor = Vue.extend(ToastComponent)// 存储toast实例的数组
const instances = []
let seed = 1// 关闭单个toast
const removeInstance = (instance) => {if (!instance) returnconst index = instances.findIndex(item => item.id === instance.id)if (index === -1) returninstances.splice(index, 1)// 重新计算剩余toast的位置const len = instances.lengthif (len < 1) returnconst position = instance.positionconst removedHeight = instance.heightfor (let i = index; i < len; i++) {if (instances[i].position === position) {const verticalPos = position.indexOf('top') !== -1 ? 'top' : 'bottom'instances[i].$el.style[verticalPos] = parseInt(instances[i].$el.style[verticalPos], 10) - removedHeight - 16 + 'px'}}
}// 创建并显示toast
const showToast = (options = {}) => {if (Vue.prototype.$isServer) return// 处理optionsif (typeof options === 'string') {options = { message: options }}// 创建实例const userOnClose = options.onCloseconst id = `toast_${seed++}`options.onClose = function() {Toast.close(id, userOnClose)}const instance = new ToastConstructor({propsData: options})instance.id = id// 挂载到bodyconst vm = instance.$mount()document.body.appendChild(vm.$el)// 计算位置const verticalOffset = options.offset || 20let verticalPos = options.position || 'top'if (verticalPos.indexOf('top') !== -1) {verticalPos = 'top'} else {verticalPos = 'bottom'}// 计算同方向上的偏移量let offset = verticalOffsetinstances.filter(item => item.position.indexOf(verticalPos) !== -1).forEach(item => {offset += item.$el.offsetHeight + 16 // 16px为间距})// 设置位置vm.$el.style[verticalPos] = `${offset}px`// 显示vm.visible = true// 记录高度,用于后续关闭时重新计算位置vm.$nextTick(() => {vm.height = vm.$el.offsetHeight})// 添加到实例数组instances.push(vm)return vm
}// Toast对象
const Toast = {// 显示toastshow(options) {return showToast(options)},// 成功提示success(message, options = {}) {return showToast({type: 'success',message,...options})},// 错误提示error(message, options = {}) {return showToast({type: 'error',message,...options})},// 警告提示warning(message, options = {}) {return showToast({type: 'warning',message,...options})},// 信息提示info(message, options = {}) {return showToast({type: 'info',message,...options})},// 关闭指定toastclose(id, userOnClose) {const instance = instances.find(inst => inst.id === id)if (instance) {if (typeof userOnClose === 'function') {userOnClose(instance)}removeInstance(instance)}},// 关闭所有toastcloseAll() {for (let i = instances.length - 1; i >= 0; i--) {instances[i].close()}},// 安装插件install(Vue) {Vue.prototype.$toast = Toast}
}export default Toast
使用方法
1. 全局注册
在 main.js 中注册插件:
import Vue from 'vue'
import Toast from './components/toast'Vue.use(Toast)
2. 基本使用
// 显示普通提示
this.$toast.show('这是一条提示信息')// 显示成功提示
this.$toast.success('操作成功!')// 显示错误提示
this.$toast.error('操作失败,请重试!')// 显示警告提示
this.$toast.warning('警告:系统即将更新')// 显示信息提示
this.$toast.info('这是一条信息提示')
3. 高级配置
this.$toast.show({message: '这是一条自定义提示',type: 'success',duration: 5000, // 显示5秒position: 'top-right', // 右上角显示showIcon: true, // 显示图标closable: true, // 可手动关闭offset: 30, // 距离顶部30pxonClose: () => { // 关闭回调console.log('Toast已关闭')}
})
4. 手动关闭
const toast = this.$toast.show({message: '这条提示不会自动关闭',duration: 0, // 设置为0表示不自动关闭closable: true
})// 在需要的时候手动关闭
setTimeout(() => {toast.close()
}, 10000)
5. 关闭所有提示
this.$toast.closeAll()
错误处理
在组件中,我们实现了几种错误处理机制:
- 参数校验:使用Vue的prop验证器确保传入的类型和值是有效的
- 服务端渲染检测:通过
Vue.prototype.$isServer
检查是否在服务端运行 - DOM操作保护:在操作DOM前检查元素是否存在
- 动画完成后销毁:等待过渡动画完成后再销毁组件
优化点
- 性能优化:使用计算属性缓存样式计算结果
- 内存管理:组件关闭后及时清理定时器和DOM元素
- 交互优化:鼠标悬停时暂停自动关闭计时器
- 响应式设计:通过百分比和最大宽度限制确保在各种设备上的良好显示
总结
这个Toast组件虽然看起来简单,但其实包含了很多实用的设计思想:
- 单一职责:组件只负责显示提示信息
- 可配置性:通过props提供丰富的配置选项
- 良好的封装:内部实现对外透明,提供简洁的API
- 错误处理:各种边界情况的处理
- 动画效果:提升用户体验
在实际开发中,这个组件帮我们节省了很多重复工作,而且保证了UI的一致性。当然,这只是一个基础版本,你可以根据自己的需求进一步扩展,比如添加更多的动画效果、支持HTML内容、添加进度条等功能。
希望这篇文章对你有所帮助,如果有任何问题或建议,欢迎在评论区留言交流!
参考资料
- Vue.js官方文档
- Element UI Toast组件
- Ant Design Vue消息提示