如何做出一个与ElementUI一样高质量的Toast组件?

2,792 阅读5分钟

前言

可以说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组件的渲染、显示、销毁等一系列过程。

我们先来看看项目结构的改变。

  1. 创建了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
      }
      
  2. Message组件的Message文件夹下创建index.js文件,作为组件对外的唯一出口。

    // YDUI/Message/index.js
    import service from './code/main.js';
    export default {
      service
    };
    
  3. 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/>后组件跟着消失,也就是组件不受路由跳转的影响。

  4. 下面我们就来测试下这样子写能动态挂载组件不,我们删除原来的注册方式,给按钮点击事件去调用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组件的核心实现讲完啦,剩下的一些小功能的完善就不继续讲了,因为剩下的都非常简单啦,自己悟吧,哈哈,具体可以参照源码看看吧。传送门

呼,终于讲完了,头次费怎么长的精力来写一篇文章,又是编写文案,又是写代码,还得做效果图。最后希望我叭叭叭讲的哪些东东小伙伴们能明白,只要能帮到一人,这文就实现了它的价值,我也就满足了,哈哈~