6.1 简单轮子:VueToast 信息弹出组件
大纲链接 §
[toc]
需求分析
用例图
- 用户触发
- 弹出信息位置
- 置顶
- 底部
- 中心
- 弹出信息
- 自动关闭
- 可设置 n 秒后关闭
- 点击后关闭
- 关闭按钮
- 多行文字关闭按钮
- 关闭后执行回调
- 自动关闭
- 弹出信息位置
- 用户连续触发两次以上
- 弹出一个信息
- 关闭上个信息
- 再弹出一个
- 期间不能再次点击
- 可设置 不会同时出现两个 toast 提示信息
- 可设置允许多个 Toast 出现
- 在相同的位置,依次出现
- 限制出现次数,超过次数,清楚最前一次的
- 弹出动画
API设计
初步实现 UI
VueToast.vue
|
|
- 初步实现,只包含一个插槽来传递信息
如何使用 VueToast
组件
- 添加方法:
showToast() {this.$toast('提示信息提示信息提示信息')}
- 实现
this.$toast
方法- 使用
install
API,开发插件的方式实现 - 开发者使用
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
方法
|
|
使用时或者在入口文件中
- 引入
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 用法:
|
|
- 为组件实例添加
$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;
|
|
添加关闭按钮
使用外部数据传入参数,来判断是否出现关闭按钮
- 外部数据为对象时,默认值需要是一个返回此对象的工厂函数
@Prop({ type: Object, default: ()=>(...);
- 组件的复用性
- 组件选项为
Vue.component('xxx', {...})
的第二个参数 - 每次初始化一个组件的时候,需要保证在内存中产生一个新的选项(不复用内存中同一个选项)
- 保证了同类的组件的状态改变不会互相影响
- 组件选项为
|
|
- 显示在提示信息里
<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
中定义选项toastOptions
const 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重载实现
|
|
在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
中
|
|
传
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
里创建实例,也在其中加上限制创建实例的条件
重构第一步:创建函数
|
|
- 创建一个函数,并调用
- 和原来的代码等价
第二步:命名函数
|
|
第三部:提出参数
- 需要的传参
|
|
- 函数
createToast
返回一个toast
实例
使用变量
currentToast
接收toast
实例
- 判断当前是否有
currentToast
,执行不同逻辑- 有,就直接关闭
- 无,就创建一个
|
|
小 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
负责居中
|
|
可以出现多个信息框
添加 按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);}
- 提出
|
|
取消使用
eventbus
监听触发$slots.default
变更
重构toastPlugin.ts
- 提出
|
|
重构toastPlugin.ts
- 提出
|
|
测试用例
|
|
参考
- Framework7 UI github
- Framework7 UI
- 代码仓库
- UI 设计稿
- Vue 动态创建实例 方应杭
- 四种App弹窗设计:Toast、Dialog、Actionbar 和 Snackbar
- elementUI Message 消息提示