6.1 简单轮子:VueToast 信息弹出组件
大纲链接 §
[toc]
需求分析
用例图
- 用户触发
- 弹出信息位置
- 置顶
- 底部
- 中心
- 弹出信息
- 自动关闭
- 可设置 n 秒后关闭
- 点击后关闭
- 关闭按钮
- 多行文字关闭按钮
- 关闭后执行回调
- 用户连续触发两次以上
- 弹出一个信息
- 关闭上个信息
- 再弹出一个
- 期间不能再次点击
- 可设置 不会同时出现两个 toast 提示信息
- 可设置允许多个 Toast 出现
- 在相同的位置,依次出现
- 限制出现次数,超过次数,清楚最前一次的
- 弹出动画
API设计
初步实现 UI
VueToast.vue
|
|
- 初步实现,只包含一个插槽来传递信息
如何使用 VueToast组件
- 添加方法:
showToast() {this.$toast('提示信息提示信息提示信息')} - 实现
this.$toast方法- 使用
installAPI,开发插件的方式实现 - 开发者使用
Vue.use( MyPlugin )
- 使用
实现
Vue.use( MyPlugin )的原理
import Vue from 'vue'- 在实例上挂载方法
Vue.prototype.$toast = ()=> {...} const vm = new Vue({...})后,使用vm.$toast()- 全局引入组件,在实例中调用钩子
created() {this.$toast(...)}
直接修改
Vue.prototype.*不符合工程实践,带来工程问题
- 组件开发者并不知道组件使用者是否已经使用了相同的命名,导致同名覆盖
- 直接修改
Vue.prototype.*,侵入性太强 - 墨菲定理:小概率事件在足够多的实践后一定会发生
import Vue from 'vue*'不能确定用户使用的版本
解决方法:写一个插件,让项目开发者主动使用插件
在
toastPlugin.ts中定义一个install方法1 2 3 4 5 6 7export default { install(Vue: any, options: {} | string = {}) { Vue.prototype.$toast = (message: string) => { alert(message); }; } };
使用时或者在入口文件中
- 引入
import toastPlugin from '.../toastPlugin' - 写
Vue.use(toastPlugin)去自动执行install方法 - 执行
created() {this.$toast('Hi')}
将选择留给UI使用用户
- 用户自己决定是否
Vue.use(plugin) toastPlugin.ts中没有引入具体版本的Vue
让 VueToast组件 出现在页面中
toastPlugin.ts
|
|
使用JS原生 API ,背离了使用 Vue 的初衷,改为 Vue 用法:
1 2 3 4 5 6 7 8 9 10 11 12 13import Toast from './VueToast.vue'; export default { install(Vue: any) { Vue.prototype.$toast = (message: string) => { const Constructor = Vue.extend(Toast); const toast = new Constructor(); toast.$slots.default = [message]; toast.$mount(); document.body.appendChild(toast.$el); }; } };为组件实例添加
$toast方法动态地创建一个
VueToast.vue组件const Constructor = Vue.extend(Toast);const toast = new Constructor();- 将传参放入插槽
toast.$slots.default = [message];,注意必须放在$mount()之前 - 挂载到内存中
toast.$mount();,使得组件的所有生命周期执行,但页面中还未出现 - 将节点添加到页面
document.body.appendChild(toast.$el);
ToastsTips.vue
|
|
VueToast组件不直接出现在页面中- 通过监听按钮点击事件,回调中动态创建组件
- 调用
this.$toast('我是一个VueToast组件');
- 调用
参考
加样式使得VueToast组件出现在页面顶部
VueToast.vue
|
|
添加toastPlugin.d.ts声明
|
|
- 添加后续重启服务,看是否仍提示 type error
参考stackoverflow
- Vue typescript plugin
- Why doesn’t TypeScript recognize module augmentation for a Vue plugin?
- Vue.js-Plugins with TypeScript
- Error creating custom plugin Vuejs + Typescript
对比 Vue 3 的内置标签<Teleport to="xxx"/>
Vue 3 中官方已经封装好了 teleport组件 用于修改节点的挂载位置
- 通过 teleport 把模板内容移动到当前组件之外的DOM,防止由于层叠上下文等原因导致的弹出框或模态框被文档中的元素遮盖
- 模态框(子组件),里面的模态宽度、标题、X按钮显示等都可以通过属性接受定制
子组件
Modal.vue
|
|
父组件
|
|
完善功能
为 VueToast.vue 添加过渡动画
使用
<transition></transition>包裹组件根标签
|
|
- 钩子回调
@after-leave="handleAfterLeave"
过渡样式与逻辑
|
|
添加自动关闭 与 定时清除功能
setTimeout@Prop({type: Boolean, default: true}) autoClose!: boolean;@Prop({type: Number, default: 1800}) autoCloseDelay!: number;1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78<template> <transition name="eat-toast-fade" @after-leave="handleAfterLeave"> <div :class="['toast', 'customClass', ]" :style="positionStyle" v-show="visible" > <slot></slot> </div> </transition> </template> <script lang="ts"> import {Component, Prop, Vue, Watch} from 'vue-property-decorator'; @Component export default class VueToast extends Vue { name = 'VueToast'; visible = false; message = ''; timer: number | null = null; verticalOffset = 8; isClosed = false; onClose = null; @Prop({type: Boolean, default: true}) autoClose!: boolean; @Prop({type: Number, default: 1800}) autoCloseDelay!: number; get positionStyle() { return { 'top': `${this.verticalOffset}px` }; } // 监听 closed 的状态 @Watch('isClosed') onClosedChange(newVal: boolean) { if (newVal) { this.visible = false; } } // 元素离开后执行钩子 handleAfterLeave() { this.$el.remove(); this.$destroy(); if (this.$el.parentNode) { this.$el.parentNode.removeChild(this.$el); } } popUpToast() { this.visible = true; } close() { this.isClosed = true; } startTimer() { if (this.autoClose && this.autoCloseDelay > 0) { this.timer = setTimeout(() => { if (!this.isClosed) { this.close(); } }, this.autoCloseDelay); } } mounted() { this.popUpToast(); this.startTimer(); } } </script>
添加关闭按钮
使用外部数据传入参数,来判断是否出现关闭按钮
- 外部数据为对象时,默认值需要是一个返回此对象的工厂函数
@Prop({ type: Object, default: ()=>(...); 组件的复用性
- 组件选项为
Vue.component('xxx', {...})的第二个参数 - 每次初始化一个组件的时候,需要保证在内存中产生一个新的选项(不复用内存中同一个选项)
保证了同类的组件的状态改变不会互相影响
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92<template> <transition name="eat-toast-fade" @after-leave="handleAfterLeave"> <div :class="['toast', 'customClass', ]" :style="positionStyle" v-show="visible"> <slot></slot> <span v-if="closeButton">{{ closeButton.text }}</span> </div> </transition> </template> <script lang="ts"> import {Component, Prop, Vue, Watch} from 'vue-property-decorator'; @Component export default class VueToast extends Vue { name = 'VueToast'; visible = false; message = ''; timer: number | null = null; verticalOffset = 8; isClosed = false; onClose = null; @Prop({type: Boolean, default: true}) autoClose!: boolean; @Prop({type: Number, default: 1800}) autoCloseDelay!: number; @Prop({ type: Object, default() { return { text: '关闭', callback: (toast: VueToast) => { toast.close(); } }; } }) closeButton!: number; get positionStyle() { return { 'top': `${this.verticalOffset}px` }; } // 监听 closed 的状态 @Watch('isClosed') onClosedChange(newVal: boolean) { if (newVal) { this.visible = false; } } // 元素离开后执行钩子 handleAfterLeave() { this.$el.remove(); this.$destroy(); if (this.$el.parentNode) { this.$el.parentNode.removeChild(this.$el); } } popUpToast() { this.visible = true; } close() { this.isClosed = true; } clearTimer() { clearTimeout(this.timer || undefined); } startTimer() { if (this.autoClose && this.autoCloseDelay > 0) { this.timer = setTimeout(() => { if (!this.isClosed) { this.close(); } }, this.autoCloseDelay); } } mounted() { this.popUpToast(); this.startTimer(); } } </script>
- 组件选项为
显示在提示信息里
<span v-if="closeButton">{{ closeButton.text }}</span>如果用户传了
closeButton,就把closeButton.text显示在一个<span>标签中Debug时可以使用created() {console.log(this.closeButton)}调试只有
this.$destroy();而未this.$el.remove();,并不会删除节点
添加关闭按钮样式
|
|
添加类型文件
VueToast.d.ts定义方法类型
|
|
- 在
toastPlugin.ts中的const toast = new Constructor({...});时传递参数{propsData: {closeButton: {test: '手动关闭', callback() { ...;}}}}
toastPlugin.ts
|
|
实现功能
src/components/ToastsTips.vue
|
|
toastPlugin.ts中定义选项toastOptionsconst toast = new Constructor({ propsData: { closeButton: toastOptions.propsData.closeButton } });
- 用户使用时在
ToastsTips.vue中提供选项,传参this.$toast('我是一个VueToast组件', { propsData: { closeButton: { text: '手动关闭', callback() { console.log('执行用户的回调'); } } } });
- 组件拿到
closeButton用在三处:<span class="closeButton" v-if="closeButton" @click="onClickCloseButton">...{{ closeButton.text }}onClickCloseButton() { this.isClosed = true; this.closeButton?.callback(); }
- 需要考虑到用户不传选项中的
closeButton时,就不执行回调函数- 防御性编程
onClickCloseButton() { this.isClosed = true; if (typeof (this?.closeButton?.callback) === 'function') { this.closeButton.callback(); }
使用TS重载实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40import Vue from 'vue'; /** Message Component */ export declare class VueToast extends Vue { /** Close the Loading instance */ close(): void; popUpToast(): void; } /** Options used in Message */ export interface VueToastOptions { /** Callback function when closed with the message instance as the parameter */ onClose?: CloseEventHandler; propsData: { closeButton: { text: string; callback: ((toast: VueToast) => void) | undefined; }; }; } ... export interface ToastCallBack { fn: (text: string) => void; fn: (text: string, options: VueToastOptions) => void; } declare module 'vue/types/vue' { interface Vue { /** * Used to show feedback after an activity. * The difference with Notification * is that the latter is often * used to show a system level passive notification. * */ $toast: ToastCallBack.fn; } }
在callback中传递this,即实例本身,从而使用实例上的方法
- 让
callback接受组件回传的方法
VueToast.vue
|
|
- 测试方法
testLog() { console.log('测试'); } - 传递
this实例onClickCloseButton() {... this.closeButton.callback(this); ...} - 默认不出现关闭按钮
- 将外部数据改为 默认为空
@Prop({ type: Object, default() { return undefined; } }) closeButton: closeButton | undefined;
- 将外部数据改为 默认为空
ToastsTips.vue
|
|
VueToast.d.ts
|
|
传递自定义HTML节点,不安全
- 由于
<slot>不能写任何其他属性比如:<slot v-html="xxx"> - 在
VueToast.vue组件中使用<div v-html="$slots.defalt[0]"></div>代替<slot>v-html="$slots.defalt[0]"读取toastPlugin.ts中传递的值toast.$slots.default = [message];
在
ToastsTips.vue的demo中1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119<template> <div> <details open> <summary>ToastsTips 无关闭按钮 自动关闭</summary> <div> <VueButton @click="showToast">点击出现提示框</VueButton> </div> </details> <br> <details open> <summary>ToastsTips 点击关闭,无回调</summary> <div> <VueButton @click="showToast2">点击出现提示框</VueButton> </div> </details> <br> <details open> <summary>ToastsTips 点击关闭,并执行回调</summary> <div> <VueButton @click="popUpToasts">点击出现提示框</VueButton> </div> </details> <br> <details open> <summary>ToastsTips 传递自定义HTML节点</summary> <div> <VueButton @click="popUpToasts2">点击出现提示框</VueButton> </div> </details> <br> <details open> <summary>ToastsTips 传递自定义HTML节点</summary> <div> <VueButton @click="popUpToasts3">点击出现提示框</VueButton> </div> </details> </div> </template> <script lang="ts"> import {Component, Vue} from 'vue-property-decorator'; import VueToast from './toast/VueToast.vue'; import VueButton from './button/VueButton.vue'; import toastPlugin from './toast/toastPlugin'; Vue.use(toastPlugin); @Component({ components: { VueToast, VueButton } }) export default class ToastsTips extends Vue { name = 'ToastsTips'; showToast() { this.$toast('我是一个VueToast组件'); } showToast2() { this.$toast('我是一个VueToast组件', { propsData: { autoClose: false, closeButton: { text: '手动关闭' } } }); } popUpToasts() { this.$toast('我是一个VueToast组件', { propsData: { autoClose: false, closeButton: { text: '手动关闭', callback(toast: VueToast) { toast.close(); } } } }); } popUpToasts2() { this.$toast(` <p> 我是由一个<i>标签</i>包裹<strong>文字</strong>的VueToast组件 </p>`, { propsData: { autoClose: false, closeButton: { text: '手动关闭', callback(toast: VueToast) { toast.close(); } } } }); } popUpToasts3() { this.$toast(` <p> <a style="color: seagreen;" href="https://cn.vuejs.org">Vue官网链接</a> </p>`, { propsData: { autoClose: false, closeButton: { text: '手动关闭', callback(toast: VueToast) { toast.close(); } } } }); } } </script>
传
HTML标签有安全风险,必须加上一个选项加以限制enableHTML
让UI开发者控制是否开启传HTML标签功能
VueToast.vue
|
|
toastPlugin.ts
|
|
ToastsTips.vue
|
|
样式 bug
注意关闭文字的样式由于文字溢出而显示竖排
- 父元素为
flex布局,子元素不收缩改变宽度.closeButton {...; min-width: 40px; flex-shrink: 0;}
注意文字溢出而高度未做自适应
- 不写死高度
- 父元素写了
min-height而没写height,会使得- 子元素即使写了高度
100%,也不生效 - 因而无法获取高度导致
<div class="line"></div>的边框高度为0,分隔线消失
- 子元素即使写了高度
使用JS获取元素的实际高度,再赋值给子元素的高度值
- 首先给元素添加引用属性
ref- 父元素
<div ... ref="toast">...</div> - 子元素
<div ref="line" class="line"></div>
- 父元素
- 获取内联样式,可读可写
- 父元素
this.$refs.toast.style.height目前为0,因为.style.*无法获取实际样式 - 子元素
this.$refs.line.style.height目前为0
- 父元素
- 使用
Element.getBoundingClientRec() API来获取元素尺寸this.$refs.toast.getBoundingClientRec()的到描述父组件尺寸与位置的对象
- 在生命周期
mounted中调用钩子时执行this.$refs.toast.getBoundingClientRec()- 得到一系列
0值 - 说明还未生成,或者经过时间差内被关闭了
- 得到一系列
Debug技巧- 眼睛观察到不为零
- JS 获取为零
- 一般来说时异步的问题
在
toastPlugin.ts中,是先$mount(),再添加节点到body中
|
|
- 生命周期
mounted的回调触发时,组件还没有添加到页面中 - 使用
this.$nextTick(() => {...}),等页面渲染完毕,立即执行回调 - 父子组件嵌套时,所有组件视图都渲染完成后再执行回调操作
VueToast.vue
|
|
- 回顾bug
- 将父元素的
height改为min-height后,子元素的height: 100%无法根据父元素计算出 - 在渲染完成后,获取父元素高度,赋给子元素
- 将父元素的
高度不定的父元素,其中的子元素不能自动撑开高度
|
|
- 父元素的
min-height不是height - 仅设置了
min-height的元素的height值为0 - 子元素设置的百分比相对高度不能撑开
或者提供可省略溢出文字的选项
重构代码 样式分离
- 增加一层
div.message包裹信息部分,即slot或v-html="$slots.default[0]"部分
VueToast.vue
|
|
更多需求
出现位置 顶部 中部 底部
VueToast.vue
|
|
只出现一个信息框
在
toastPlugin.ts里创建实例,也在其中加上限制创建实例的条件
重构第一步:创建函数
|
|
- 创建一个函数,并调用
- 和原来的代码等价
第二步:命名函数
|
|
第三部:提出参数
需要的传参
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22import Toast from './VueToast.vue'; import {VNode} from 'vue'; import {VueToastOptions} from '@/types/VueToast'; function createToast(Vue: Record<string, any>, message: string | VNode, propsData: VueToastOptions['propsData']) { const Constructor = Vue.extend(Toast); const toast = new Constructor({ propsData }); toast.$slots.default = [message]; toast.$mount(); document.body.appendChild(toast.$el); return toast; } export default { install(Vue: Record<string, any>, /* options: {} = {} */) { Vue.prototype.$toast = (message: string | VNode, toastOptions: VueToastOptions | undefined) => { createToast(Vue, message, toastOptions!.propsData); }; } };函数
createToast返回一个toast实例
使用变量
currentToast接收toast实例
判断当前是否有
currentToast,执行不同逻辑- 有,就直接关闭
无,就创建一个
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35import Toast from './VueToast.vue'; import {VNode} from 'vue'; import {VueToast, VueToastOptions} from '@/types/VueToast'; function createToast(Vue: Record<string, any>, message: string | VNode, propsData: VueToastOptions['propsData'] | undefined): VueToast { const Constructor = Vue.extend(Toast); const toast = new Constructor({ propsData }); toast.$slots.default = [message]; toast.$mount(); document.body.appendChild(toast.$el); return toast; } let currentToast: VueToast; export default { install(Vue: Record<string, any>, /* options: {} = {} */) { Vue.prototype.$toast = (message: string | VNode, toastOptions: VueToastOptions | undefined) => { if (currentToast) { currentToast.close(); } if (toastOptions) { const {propsData} = toastOptions; currentToast = createToast(Vue, message, propsData); return; } currentToast = createToast(Vue, message, undefined); return; }; } };
小 bug
- 只出现一次 VueToast ,
if (currentToast) {currentToast.close();} - 当存在一个实例时,不需要重复关闭
currentToast一直存在于内存中,没有赋空值currentToast = null;清除- 需要在
this.close()里发布emit一个回调,使得监听到这个回调时清除currentToast onClose: () => {currentToast = null;}- 注意
emit在this.$destroy();之前,否则失效
VueToast.vue
|
|
toastPlugin.ts
|
|
- 重构 方法
createToast- 增加一个参数为回调
onClose: () => void function createToast(Vue: Record<string, any>, message: string | VNode, propsData: VueToastOptions['propsData'] | undefined, onClose: () => void): VueToast {...}- 在调用时主动置空
currentToast = null; - 监听一次自定义事件
toast.$once('beforeClose', onClose); - 返回实例
return toast;
- 增加一个参数为回调
- 重构
Vue.prototype.$toast- 判断当前是否已存在实例
currentToast - 存在即 主动关闭
currentToast.close(); - 得到实例
currentToast = createToast(Vue, message, propsData, () => {currentToast = null;});
- 判断当前是否已存在实例
优化消息动画 VueToast.vue
- 注意两处的
transform属性后一个会覆盖前一个,不管transform的内容是什么,比如- 默认:
transform: tranlateX(-50%) - 进入
0%:transform: tranlateY(100%)
- 默认:
解决方法:分离定位和动画
增加一层
div.wrapper负责居中1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137<template> <transition :name="fadeAnimationName" @after-leave="handleAfterLeave"> <div class="wrapper" :class="toastPosition" v-show="visible"> <div class="toast" :style="positionOffsetStyle" ref="toast" @mouseenter="clearTimer" @mouseleave="startTimer"> <div class="message"> <slot v-if="!enableUnsafeHTML">{{ message }}</slot> <div v-else v-html="ifSlots/*$slots.default[0]*/"></div> </div> <template v-if="closeButton"> <div ref="line" class="line"></div> <span class="closeButton" @click="onClickCloseButton"> {{ closeButton.text }} </span> </template> </div> </div> </transition> </template> <script lang="ts"> ... </script> <style lang="scss" scoped> $font-size: 14px; $toast-min-height: 40px; @keyframes slide-up { 0% { opacity: 0; transform: translate(-50%, 100%); } 100% { opacity: 1; transform: translate(-50%, 0%); } } @keyframes slide-down { 0% { opacity: 0; transform: translate(-50%, -100%); } 100% { opacity: 1; transform: translate(-50%, 0%); } } .eat-toast-fade-enter-active, .eat-toast-fade-leave-active { opacity: 0; } .eat-toast-from-top-enter-active { animation: slide-down .5s; } .eat-toast-from-top-leave-active { animation: slide-down .5s reverse; } .eat-toast-from-bottom-enter-active { animation: slide-up .5s; } .eat-toast-from-bottom-leave-active { animation: slide-up .5s reverse; } .wrapper { position: fixed; left: 50%; transform: translateX(-50%); transition: opacity 0.3s ease, transform .4s ease, top 0.4s ease; &.position-top { top: 0; .toast { border-top-left-radius: 0; border-top-right-radius: 0; } } &.position-middle { transform: translate(-50%, -50%); top: 50%; } &.position-bottom { bottom: 0; .toast { border-bottom-left-radius: 0; border-bottom-right-radius: 0; } } } .toast { font-size: $font-size; min-height: $toast-min-height; max-width: 288px; background-color: rgba(0, 0, 0, .74); border-radius: 4px; box-shadow: 0 0 3px 0 rgba(0, 0, 0, .5); color: ghostwhite; padding: 0 16px; display: flex; align-items: center; .message { padding: 8px 0; } .line { height: 100%; border-left: 1px solid #666; margin-left: 16px; } .closeButton { padding-left: 16px; min-width: 40px; flex-shrink: 0; } } </style>
可以出现多个信息框
添加 按Escape键关闭 功能
- 键盘事件 KeyboardEvent() MDN
- KeyboardEvent.key
- Key Values
- KeyboardEvent 用户与键盘的交互
- 代码触发事件 EventTarget.dispatchEvent MDN
重构
重构VueToast.vue
- 使用计算属性
ifSlots将message传给$slots.default<div v-else v-html="ifSlots"></div>get ifSlots() {return (this.$slots.default ? this.$slots.default[0] : this.message);}
提出
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298<template> <transition :name="fadeAnimationName" @after-leave="handleAfterLeave"> <div class="wrapper" :class="toastPosition" v-show="visible"> <div class="toast" :style="positionOffsetStyle" ref="toast" @mouseenter="clearTimer" @mouseleave="startTimer"> <div class="message"> <slot v-if="!enableUnsafeHTML">{{ message }}</slot> <div v-else v-html="ifSlots/*$slots.default[0]*/"></div> </div> <template v-if="closeButton"> <div ref="line" class="line"></div> <span class="closeButton" @click="onClickCloseButton"> {{ closeButton.text }} </span> </template> </div> </div> </transition> </template> <script lang="ts"> import {Component, Emit, Prop, Ref, Vue, Watch} from 'vue-property-decorator'; import {closeButton} from '@/types/VueToast'; import {VNode} from 'vue/types/vnode'; // import {bus} from '../../main'; @Component export default class VueToast extends Vue { name = 'VueToast'; visible = false; message = ''; timer: number | null = null; verticalOffset = 0; isClosed = false; onClose = null; @Prop({type: Boolean, default: false}) enableUnsafeHTML!: boolean; @Prop({type: Boolean, default: true}) enableEscapeKey!: boolean; @Prop({ type: String, default: 'top', validator(value: string): boolean { return ['top', 'middle', 'bottom'].includes(value); } }) position!: 'top' | 'middle' | 'bottom'; @Prop({ type: [Number, Boolean], default: 1800, validator(value: false | number): boolean { return (value === false) || (value > 0); } }) autoCloseDelay!: false | number; @Prop({ type: Object, default() { return undefined; } }) closeButton: closeButton | undefined; onClickCloseButton() { this.isClosed = true; if (typeof (this?.closeButton?.callback) === 'function') { this.closeButton.callback(this); } } // 决定提示框出现的位置 get toastPosition(): {} { return { [`position-${this.position}`]: true }; } // 根据提示框出现的位置 应用不同类样式进入动画 get fadeAnimationName() { const map = { 'position-top': 'eat-toast-from-top', 'position-middle': 'eat-toast-fade', 'position-bottom': 'eat-toast-from-bottom', }; type mapKey = 'position-top' | 'position-middle' | 'position-bottom'; return map[Object.keys(this.toastPosition)[0] as mapKey]; } // 提供偏移量 get positionOffsetStyle() { return { [`${this.position}`]: `${this.verticalOffset}px` }; } // 监听 closed 的状态 决定visible是否变化 @Watch('isClosed') onClosedChange(newVal: boolean) { if (newVal) { this.visible = false; } } // 元素离开后执行钩子 handleAfterLeave() { this.clearVM(); } // 清除组件 clearVM() { this.$el.remove(); this.$destroy(); if (this.$el.parentNode) { this.$el.parentNode.removeChild(this.$el); } } // 显示提示框 popUpToast() { this.visible = true; } // 关闭提示框 @Emit('beforeClose') close() { this.isClosed = true; } clearTimer() { clearTimeout(this.timer || undefined); } startTimer() { if (this.autoCloseDelay) { this.timer = setTimeout(() => { if (!this.isClosed) { this.close(); } }, this.autoCloseDelay); } } // 异步 得到渲染后的父元素高度 @Ref() readonly line!: HTMLElement; @Ref() readonly toast!: HTMLElement; getRenderedHeight() { this.$nextTick(() => { if (this.line) { this.toast.style.height = `${this.toast.getBoundingClientRect().height}px`; } }); } // 按Escape键关闭消息 keydown(e: KeyboardEvent) { if (this.enableEscapeKey && e.key === `Escape`) { if (!this.isClosed) { this.close(); } } } get ifSlots() { return (this.$slots.default ? this.$slots.default[0] : this.message); } haveSlots() { if (!this.$slots.default) { this.$slots.default = [`<slot></slot>` as unknown as VNode]; } } mounted() { this.haveSlots(); // bus.$on('pushSlot', (value: VNode) => {this.haveSlots(value);}); this.popUpToast(); document.addEventListener('keydown', this.keydown); this.getRenderedHeight(); // 注意顺序 必须放在 this.popUpToast() 之后 this.startTimer(); this.$once('hook:beforeDestroy', () => { // bus.$off('pushSlot'); document.removeEventListener('keydown', this.keydown); this.clearVM(); }); } } </script> <style lang="scss" scoped> $font-size: 14px; $toast-min-height: 40px; @keyframes slide-up { 0% { opacity: 0; transform: translate(-50%, 100%); } 100% { opacity: 1; transform: translate(-50%, 0%); } } @keyframes slide-down { 0% { opacity: 0; transform: translate(-50%, -100%); } 100% { opacity: 1; transform: translate(-50%, 0%); } } .eat-toast-fade-enter-active, .eat-toast-fade-leave-active { opacity: 0; } .eat-toast-from-top-enter-active { animation: slide-down .5s; } .eat-toast-from-top-leave-active { animation: slide-down .5s reverse; } .eat-toast-from-bottom-enter-active { animation: slide-up .5s; } .eat-toast-from-bottom-leave-active { animation: slide-up .5s reverse; } .wrapper { position: fixed; left: 50%; transform: translateX(-50%); transition: opacity 0.3s ease, transform .4s ease, top 0.4s ease; &.position-top { top: 0; .toast { border-top-left-radius: 0; border-top-right-radius: 0; } } &.position-middle { transform: translate(-50%, -50%); top: 50%; } &.position-bottom { bottom: 0; .toast { border-bottom-left-radius: 0; border-bottom-right-radius: 0; } } } .toast { font-size: $font-size; min-height: $toast-min-height; max-width: 288px; background-color: rgba(0, 0, 0, .74); border-radius: 4px; box-shadow: 0 0 3px 0 rgba(0, 0, 0, .5); color: ghostwhite; padding: 0 16px; display: flex; align-items: center; .message { padding: 8px 0; } .line { height: 100%; border-left: 1px solid #666; margin-left: 16px; } .closeButton { padding-left: 16px; min-width: 40px; flex-shrink: 0; } } </style>
取消使用
eventbus监听触发$slots.default变更
重构toastPlugin.ts
提出
1
重构toastPlugin.ts
提出
1
测试用例
|
|
参考
- Framework7 UI github
- Framework7 UI
- 代码仓库
- UI 设计稿
- Vue 动态创建实例 方应杭
- 四种App弹窗设计:Toast、Dialog、Actionbar 和 Snackbar
- elementUI Message 消息提示