前言
可以说Toast组件是一个项目的必不可少的组成,一个好看的Toast提示更能给用户良好的体验,本文我们就来实现ElementUI中的Message消息提示组件。
先上效果图。(这...能留住你吗?)
前置知识
还不懂Vue.extend()与vm.$mount() ?
官网没看懂?没关系,请下看。
首先我们先知道有怎么三个概念:Vue构造器 > Vue组件 > Vue实例。
它们各自的基本结构如下:
// Vue构造器
Vue.extend({
template: '<div></div>',
data: function() {
return {}
}
});
// Vue组件-全局注册组件
Vue.component('componentName', {
template: '<div></div>',
data: function() {
return {}
}
});
// Vue实例
new Vue({
el: '',
data: {}
});
Vue组件与Vue实例就不多说了,你懂的,so easy~
关键是Vue构造器是什么?构造器?构造的是什么?
我们可以这样子简单的理解:Vue构造器(Vue.extend(options))是一个构造组件的语法器,也就是你给他一个参数,它还你一个组件,它接收的参数options基本与Vue实例参数一致。
生成后的组件,我们能通过注册挂载到Vue实例上使用。
var toast = Vue.extend({
template: '<div></div>',
data: function() {
return {}
}
});
Vue.component('toast', toast);
或者我们也能在Vue实例内部注册该组件,通过components属性。
new Vue({
components: { toast: toast}
})
再或者我们也能利用vm.$mount()来进行手动挂载(敲黑板!!!)。
var toast = Vue.extend({
template: '<div></div>',
data: function() {
return {}
}
});
new toast().$mount('#app');
不知道你有没有发现,前两种与最后一种注册方式的区别,前者的注册都是前置,也就是无法通过JS逻辑来把控的,而后者通过vm.$mount()来手动挂载组件的是能通过JS逻辑来控制组件是否渲染并挂载的,这就是精髓所在,这为我们后面要实现通过JS来调用组件(this.$toast())开了扇门。
vm.$mount()如果没有参数,那说明组件只渲染但还没有挂载到页面上,如果有正确的元素作参数则直接挂载到元素下面。- 不同的Vue组件可以拥有同一个Vue构造器,不同的vue实例可以共用同一个vue组件。(Vue构造器 > Vue组件 > Vue实例。)
正文
需求分析
简单分析了下,我们Message组件大致会有如下图的功能,这如下满足项目中Toast提示的需求,也差不多与ElementUI的一致。
实现一 &【基础结构与样式】
大致知道我们要做什么了,我们就赶紧进入正题。
直接用vue-cli初始化了一个项目,我们先把结构与样式给干出来,直接上代码。
<template>
<div class="yd-message">
<div class="yd-message__content">
<img :src="icons.infoIcon" class="yd-message__icon" />
<p class="yd-message__text">测试测试测试</p>
</div>
<img :src="icons.infoCloseIcon" class="yd-message__close-btn"/>
</div>
</template>
<script>
import infoIcon from './images/infoIcon.png'
import infoCloseIcon from './images/infoCloseIcon.png'
import successIcon from './images/successIcon.png'
import successCloseIcon from './images/successCloseIcon.png'
import errorIcon from './images/errorIcon.png'
import errorCloseIcon from './images/errorCloseIcon.png'
import warningIcon from './images/warnIcon.png'
import warningCloseIcon from './images/warnCloseIcon.png'
export default {
name: 'YdMessage',
data() {
return {
icons:{
infoIcon, infoCloseIcon, successIcon, successCloseIcon,
errorIcon, errorCloseIcon, warningIcon, warningCloseIcon
}
}
}
}
</script>
Message组件完整的样式,本章节样式不是重点就先全怼出来,也不多。
// main.vue 完整样式代码
<style scoped>
.yd-message{
width: 380px;
min-height: 44px;
position: fixed;
z-index: 10000;
top: 20px;
left: 50%;
margin-left: -190px;
box-sizing: border-box;
padding: 0 8px 0 10px;
background: rgba(156,212,255,0.72);
border: 1px solid rgba(156,212,255,1);
border-radius: 5px;
overflow: hidden;
transition: opacity .3s, transform .4s, top .4s;
display: flex;
align-items: center;
}
.yd-message__content{
overflow: hidden;
display: flex;
align-items: center;
flex: 1;
}
.yd-message__icon{
min-width: 32px;
width: 32px;
height: 32px;
}
.yd-message__text{
color: #fff;
font-size: 14px;
}
.yd-message__close-btn{
min-width: 32px;
width: 32px;
height: 32px;
cursor: pointer;
}
.yd-message.success{
background: rgba(13,206,127,0.72);
border: 1px solid rgba(13,206,127,1);
}
.yd-message.error{
background: rgba(255,68,68,0.72);
border: 1px solid rgba(255,68,68,1);
}
.yd-message.warning{
background: rgba(254,132,86,0.72);
border: 1px solid rgba(254,132,86,1);
}
.ani_yd-message-fade-enter, .ani_yd-message-fade-leave-active {
opacity: 0;
transform: translate(0, -100%)
}
.center{
justify-content: center;
}
</style>
我们简单引入一下,看效果如下。
实现二 &【手动控制注册组件】
简单搞完结构样式后,我们来思考第二个问题,也是比较关键的一步,就是利用Vue.extend()与vm.$mount() 来实现按钮控制Message组件的出现与消失?
我们来观察下ElementUI调用Message组件的形式: this.$message('这是一条消息提示');
由上调用信息我们能大概观察到以下两个信息:
- 我们需要在this身上挂载一个$message方法,也就是在Vue原型(Vue.prototype)上挂载一个方法,方便调用。
- 该方法要接受一个参数,我们根据这个参数的信息来完成
Message组件的渲染、显示、销毁等一系列过程。
我们先来看看项目结构的改变。
-
创建了YDUI文件夹,在底下创建了一个
index.js,作为所有组件注册统一管理的文件,假设你有另外一个组件,也依旧可以在YDUI文件夹下创建另一个文件夹,在index.js中引入,我们以一个文件夹一块组件为单位来统一管理。我们把
index.js文件在main.js文件中导入注册如下:// main.js import Vue from 'vue' import App from './App.vue' import router from './router' Vue.config.productionTip = false // 全局注册组件 import YDUI from '@/components/YDUI'; Vue.use(YDUI) new Vue({ router, render: h => h(App) }).$mount('#app')-
这里使用了
Vue.use(Function | Object)方法来完成组件的注册,该方法简单说就是:- 如果参数是一个函数则会立刻被执行,并且默认第一个参数为Vue实例。
- 如果参数是一个对象,则对象中必须提供
install()方法,方法也会被执行,也会默认注入Vue实例。
-
我们按上面分析的信息,在Vue原型上挂载后续调用的方法,
Vue.prototype.$message = Message.service;可能你在疑问Message.service是什么?仔细分析下去,它其实就是在Message/code/main.js文件中定义的message(),不相信你往下接着看。// YDUI/index.js import Message from './Message'; const install = function(Vue, opts = {}) { Vue.prototype.$message = Message.service; // 挂载方法 }; /* istanbul ignore if */ if(typeof window !== undefined && window.Vue) { install(window.Vue); } export default { install }
-
-
在
Message组件的Message文件夹下创建index.js文件,作为组件对外的唯一出口。// YDUI/Message/index.js import service from './code/main.js'; export default { service }; -
把
Message组件的核心代码都放在code文件夹里面,main.vue该文件还是上面的结构与样式未改动过,重点在main.js文件。// YDUI/Message/code/main.js import Vue from 'vue'; import Main from './main.vue'; const messageConstructor = Vue.extend(Main); let instance = null; let instances = []; const message = (options = {}) => { // 实例化 instance = new messageConstructor(); // 手动挂载-上面说过了, $mount()没有参数的话只是渲染还没挂载 instance.$mount(); // 把组件加入页面的body中 document.body.appendChild(instance.$el); // 保存每个组件实例 instances.push(instance); // 每一个调用this.$message()都能返回对应的组件的实例,方便后续的操作 return instance }; export default message上面
message()函数只做一件事,就是通过手动挂载的方式把组件挂载到 body 下面去,组件挂载在 body 中能防止路由跳转刷新<router-view/>后组件跟着消失,也就是组件不受路由跳转的影响。 -
下面我们就来测试下这样子写能动态挂载组件不,我们删除原来的注册方式,给按钮点击事件去调用
Message组件。// view/Home.vue methods: { onClick() { this.$message() } }看上面动图,注意观察控制台的变化,点击按钮确实能添加动态挂载出来组件,且每点击一次就挂载一个组件,页面上组件效果因为都是
fiexd布局所以都叠在一起了,但我们能看到 body 下 dom 节点一直在增加。
是不是也挺好玩的,Emmmm,当然关闭销毁功能还没做,不过也算踏出第一步,下面继续努力搬砖吧。
实现三 &【间距、手动关闭、自动隐藏】
经过了上面两个步骤,我们剩下的工作大部分就只是在Message/code里面两个文件了。
上面我们让组件显示了,那么下面就是干掉它的时候了,但为了方便观察到效果,我们先给每个组件添加一点间距,但要注意,每个组件的距离顶部top应该是不一样,所以我们应该要给每个组件一个唯一的id以作区分。
// main.vue
<template>
<div :style="positionStyle" class="yd-message">
...
</div>
</template>
<script>
...
export default {
data() {
return {
...,
offset: 20 // 默认偏移量
}
},
computed: {
positionStyle() {
return {
'top': `${ this.offset }px`
};
}
},
}
</script>
main.js文件做两件事情,给实例添加唯一的id,计算好整体偏移量与每个组件的偏移量。
// main.js
import Vue from 'vue';
import Main from './main.vue';
const messageConstructor = Vue.extend(Main);
let instance = null;
let instances = [];
let seed = 1;
const message = (options = {}) => {
// 将传递进来参数与data中的合并
instance = new messageConstructor({
data: options // 会自动与main.vue中的data数据进行同名合并
});
// 唯一id标识
let id = 'myMessage_' + seed++;
instance.id = id;
instance.$mount();
document.body.appendChild(instance.$el);
// 距离顶部偏移量
let offset = options.offset || instance.offset; // 如果没有传递就取默认值20
instances.forEach(item => {
offset += item.$el.offsetHeight + 16 // 下一个组件与上一个组件相距16px, 也可以将它改成可变参数
});
instance.offset = offset;
instances.push(instance);
return instance
};
export default message
效果图。
然后接下来我们就要来尝试干掉这些组件了,我们默认三秒后组件自动消失,先看代码的变动。因为是自动消失我们必然少不了得来上一个定时器(setTimeout),而关闭的操作我们单独写成一个方法,也方便后面手动关闭的时候直接调用它就行了。
// main.vue
<template>
<div v-if="visible" :style="positionStyle" class="yd-message">
...
</div>
</template>
<script>
...
export default {
data() {
return {
...,
visible: false, // 控制组件显示与隐藏的标识
duration: 3000, // 默认的隐藏时间
onClose: null, // 关闭时的回调函数
}
},
computed: {
...
},
mounted() {
// 组件激活开启一个定时器
this.startTimer();
},
methods: {
// 关闭方法
close() {
this.visible = false;
if (typeof this.onClose === 'function') {
this.onClose(this); // 组件销毁后,我们回调onClose()方法, 这个方法是在 main.js 文件中挂载上去的, 因为我们前面在 main.js 文件保存过每个组件的实例, 现在组件销毁了, 这些实例也得处理掉, 防止占内存.
}
},
clearTimer() {
clearTimeout(this.timer);
},
startTimer() {
if (this.duration > 0) { // 时间为0则不自动关闭
this.timer = setTimeout(() => {
if (this.visible) {
this.close()
}
}, this.duration);
}
}
}
}
</script>
main.js文件,我们先处理一下如果传递进来的options参数是个字符串的情况(this.$message('这是一条消息提示');),然后就得做组件隐藏后需要干的三件事情,那三件事呢?嘿,请看下面。
// main.js 完整代码
import Vue from 'vue';
import Main from './main.vue';
var messageConstructor = Vue.extend(Main);
let instance = null;
let instances = [];
let seed = 1;
const message = (options = {}) => {
// 处理直接传字符串的情况
if(typeof options === 'string') {
options = {
message: options
}
}
instance = new messageConstructor({
data: options
});
let id = 'myMessage_' + seed++;
instance.id = id;
// 处理options参数传递进来的关闭回调
let optionsOnClose = options.onClose;
// 在实例的data属性中挂载onClose()方法, 等关闭的事件触发后来回调处理后续的事情。
instance.onClose = () => {
message.close(id, optionsOnClose);
};
instance.$mount();
document.body.appendChild(instance.$el);
let offset = options.offset || instance.offset;
instances.forEach(item => {
offset += item.$el.offsetHeight + 16
});
instance.offset = offset;
// 显示dom
instance.visible = true;
instances.push(instance);
return instance
};
/**
* 当前组件隐藏后需要做的事情:
* 1. 处理一下其他组件的偏移量往上移
* 2. 调用options参数传进来的关闭回调
* 3. 删除保存instances变量中的实例
* @param id: 实例id
*/
message.close = function(id, optionsOnClose) {
let len = instances.length;
let index = -1;
for (let i = 0; i < len; i++) {
if (id === instances[i].id) {
index = i;
if (typeof optionsOnClose === 'function') {
optionsOnClose(instances[i]);
}
instances.splice(i, 1);
break;
}
}
// 多个的时候关闭中间的,要下面的上移
if (len <= 1 || index === -1 || index > instances.length - 1) return;
const removedHeight = instances[index].$el.offsetHeight;
for (let i = index; i < len - 1 ; i++) {
let dom = instances[i].$el;
dom.style['top'] = parseInt(dom.style['top'], 10) - removedHeight - 16 + 'px';
}
};
export default message
写到这里main.js文件的代码也算暂时完整完了,开心,撒花撒花^-^。
然后我们来看看隐藏的效果,这里注意因为使用了v-if所以看控制台的节点也在组件消失的时候也是相应的删除掉,是不是挺棒哒?
最后我们来看看手动关闭,其实也比较简单,我们直接上改动的代码。
// view/Home.vue
methods: {
onClick() {
// 把时间设置为0则不会自动关闭
this.$message({duration: 0});
}
}
直接给关闭的icon注册事件
// main.vue
<template>
<div v-if="visible" :style="positionStyle" class="yd-message">
...
<img @click="close" :src="icons.infoCloseIcon" class="yd-message__close-btn"/>
</div>
</template>
...
效果图。
实现四 &【入场动画、切换不同主题】
Message组件出现的时候还是比较呆板的,我们来给它弄个过渡效果,利用Vue的<transition/>来实现。
<template>
<transition name="ani_yd-message-fade" >
<div v-if="visible" :style="positionStyle" class="yd-message">
...
</div>
</transition>
</template>
<style scoped>
.ani_yd-message-fade-enter, .ani_yd-message-fade-leave-active {
opacity: 0;
transform: translate(0, -100%)
}
</style>
Message一共有四种主题,切换主题其实就是更换不同的类名与不同的icon就行啦。
// view/Home.vue
methods: {
onClick() {
this.$message({
type: 'success'
});
}
}
main.vue文件
// main.vue
<template>
<transition name="ani_yd-message-fade" >
<div :class="[type]" v-if="visible" :style="positionStyle" class="yd-message">
...
<img @click="close" :src="icons[type + 'CloseIcon']" class="yd-message__close-btn"/>
</div>
</transition>
</template>
<script>
import infoIcon from '../images/infoIcon.png'
import infoCloseIcon from '../images/infoCloseIcon.png'
import successIcon from '../images/successIcon.png'
import successCloseIcon from '../images/successCloseIcon.png'
import errorIcon from '../images/errorIcon.png'
import errorCloseIcon from '../images/errorCloseIcon.png'
import warningIcon from '../images/warnIcon.png'
import warningCloseIcon from '../images/warnCloseIcon.png'
export default {
data() {
return {
icons:{
infoIcon, infoCloseIcon, successIcon, successCloseIcon,
errorIcon, errorCloseIcon, warningIcon, warningCloseIcon
},
...
}
},
...
}
</script>
源码
最后我们贴一下main.vue完整的HTML与JS的代码(样式完整代码在上面哦)。
// main.vue 完整的HTML与JS的代码
<template>
<transition name="ani_yd-message-fade" >
<div v-if="visible"
:class="[type]"
:style="positionStyle"
class="yd-message"
@mouseenter="clearTimer"
@mouseleave="startTimer">
<div class="yd-message__content"
:class="{'center': center}">
<img v-if="['info', 'success', 'error', 'warning'].includes(type)"
:src="icons[type + 'Icon']" class="yd-message__icon" />
<p class="yd-message__text" v-text="message" />
</div>
<img v-if="showClose" @click="close" :src="icons[type + 'CloseIcon']" class="yd-message__close-btn"/>
</div>
</transition>
</template>
<script>
import infoIcon from '../images/infoIcon.png'
import infoCloseIcon from '../images/infoCloseIcon.png'
import successIcon from '../images/successIcon.png'
import successCloseIcon from '../images/successCloseIcon.png'
import errorIcon from '../images/errorIcon.png'
import errorCloseIcon from '../images/errorCloseIcon.png'
import warningIcon from '../images/warnIcon.png'
import warningCloseIcon from '../images/warnCloseIcon.png'
export default {
name: 'YdMessage',
data() {
return {
icons:{
infoIcon, infoCloseIcon, successIcon, successCloseIcon,
errorIcon, errorCloseIcon, warningIcon, warningCloseIcon
},
closed: false,
timer: null,
visible: false,
type: 'info',
showClose: false,
message: '',
duration: 3000,
offset: 20, // 顶部偏移量
onClose: null,
center: false
}
},
computed: {
positionStyle() {
return {
'top': `${ this.offset }px`
};
}
},
watch: {
closed(newVal) {
if (newVal) {
this.visible = false;
}
}
},
mounted() {
this.startTimer();
},
methods: {
close() {
this.closed = true;
if (typeof this.onClose === 'function') {
this.onClose(this);
}
},
clearTimer() {
clearTimeout(this.timer);
},
startTimer() {
if (this.duration > 0) {
this.timer = setTimeout(() => {
if (!this.closed) {
this.close()
}
}, this.duration);
}
}
}
}
</script>
经过上面四个实现过程基本把Message组件的核心实现讲完啦,剩下的一些小功能的完善就不继续讲了,因为剩下的都非常简单啦,自己悟吧,哈哈,具体可以参照源码看看吧。传送门
呼,终于讲完了,头次费怎么长的精力来写一篇文章,又是编写文案,又是写代码,还得做效果图。最后希望我叭叭叭讲的哪些东东小伙伴们能明白,只要能帮到一人,这文就实现了它的价值,我也就满足了,哈哈~