Element-UI源码剖析(一)—— Message组件

1,138 阅读3分钟

作为一名前端开发者,开发中绝对离不开使用各种组件库,例如 Element-UI、Ant-design-vue 等,都是非常优秀的组件库,很多时候,我们只是会使用就行,不需要去考虑是如何实现的,一般来说都是基于这些组件进行二次封装。

但是,如果我们想要进一步提升自己的前端水平,我觉得很有必要去研究下组件库的实现细节,它里面用到的一些模式、技巧、代码处理等,都是值得我们去学习研究的。因此,我便开始了研究 Element-UI 源码之路,总结下其中的一些比较难处理的组件的实现思路, 那么这篇文章就从 Message 组件开始,后续也会不断更新,希望各位大佬多多指导!

一、使用示例

该组件的文档:element.eleme.cn/#/zh-CN/com…

image.png

先来看下在代码中是如何使用的:

this.$message.error('错了哦,这是一条错误消息');

this.$message({ 
    message: '恭喜你,这是一条成功消息', 
    type: 'success' 
});

this.$message({ 
    message: h('p', null, [ 
        h('span', null, '内容可以是 '), 
        h('i', { style: 'color: teal' }, 'VNode') 
    ]) 
});

虽然有多种使用形式,但是本质都是函数调用,而且 message 方法是挂载在 Vue 实例中的,该方法接收一个配置项,根据传递的参数实现不同的效果,文档中给出了具体的参数值,其中 message 可以是字符串,也可以是虚拟结点。

image.png

二、梳理结构

github中将项目拷贝下来本地,观察项目的目录结构,packages 目录下是所有组件的源码,src 目录下是一些公用的方法,其中 index.js 文件中提供了 install 方法,用户注册组件以及挂载属性以及方法到 Vue 实例上。 image.png

接下来找到 message 组件所在的位置,main.vue 文件中主要是 HTML 结构代码,核心是 main.js 文件,里面向外暴露了 Message 方法:

image.png

三、源码剖析

main.vue

<template>
  <transition name="el-message-fade" @after-leave="handleAfterLeave">
    <div
      :class="[
        'el-message',
        type && !iconClass ? `el-message--${ type }` : '',
        center ? 'is-center' : '',
        showClose ? 'is-closable' : '',
        customClass
      ]"
      :style="positionStyle"
      v-show="visible"
      @mouseenter="clearTimer"
      @mouseleave="startTimer"
      role="alert">
      <i :class="iconClass" v-if="iconClass"></i>
      <i :class="typeClass" v-else></i>
      <slot>
        <p v-if="!dangerouslyUseHTMLString" class="el-message__content">{{ message }}</p>
        <p v-else v-html="message" class="el-message__content"></p>
      </slot>
      <i v-if="showClose" class="el-message__closeBtn el-icon-close" @click="close"></i>
    </div>
  </transition>
</template>

主要的 HTML 代码在 main.vue 文件中,最外层使用了 Vue 的内置组件 transition,从而使得消息弹出和消失的时候有个过渡动画效果,HTML 代码结构比较简单,只需展示图标、消息文本、关闭按钮,其中消息文本可以是字符串,也可以是 HTML 片段:

image.png

但是官方文档上有提示:message 属性虽然支持传入 HTML 片段,但是在网站上动态渲染任意 HTML 是非常危险的,因为容易导致 XSS 攻击。

因此在 dangerouslyUseHTMLString 打开的情况下,请确保 message 的内容是可信的,永远不要将用户提交的内容赋值给 message 属性。

下面来看一下相关的逻辑代码:

<script type="text/babel">
  const typeMap = {
    success: 'success',
    info: 'info',
    warning: 'warning',
    error: 'error'
  };

  export default {
    data() {
      return {
        visible: false,
        message: '',
        duration: 3000,
        type: 'info',
        iconClass: '',
        customClass: '',
        onClose: null,
        showClose: false,
        closed: false,
        verticalOffset: 20,
        timer: null,
        dangerouslyUseHTMLString: false,
        center: false
      };
    },

    computed: {
      typeClass() {
        return this.type && !this.iconClass
          ? `el-message__icon el-icon-${ typeMap[this.type] }`
          : '';
      },
      positionStyle() {
        return {
          'top': `${ this.verticalOffset }px`
        };
      }
    },

    watch: {
      closed(newVal) {
        if (newVal) {
          this.visible = false;
        }
      }
    },

    methods: {
      // 离开时销毁
      handleAfterLeave() {
        this.$destroy(true);
        this.$el.parentNode.removeChild(this.$el);
      },

      // 关闭消息
      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);
        }
      },
      
      // 监听键盘事件
      keydown(e) {
        // 按下 ESC 键关闭消息
        if (e.keyCode === 27) { // esc关闭消息
          if (!this.closed) {
            this.close();
          }
        }
      }
    },
    mounted() {
      this.startTimer();
      document.addEventListener('keydown', this.keydown);
    },
    beforeDestroy() {
      document.removeEventListener('keydown', this.keydown);
    }
  };
</script>

下面我们来分析核心逻辑,主要是暴露 Message 方法,供外部调用唤起弹层:

main.js

import Vue from 'vue';
import Main from './main.vue';
import { PopupManager } from 'element-ui/src/utils/popup';
import { isVNode } from 'element-ui/src/utils/vdom';
import { isObject } from 'element-ui/src/utils/types';
// 创建组件的构造函数
let MessageConstructor = Vue.extend(Main);

// 当前消息提示实例
let instance;
// 存储所有的消息提示实例
let instances = [];
// 用于生成唯一id
let seed = 1;

// 核心方法,以函数的形式唤起弹窗
const Message = function(options) {
  // 配置项参数处理
  options = options || {};
  if (typeof options === 'string') {
    options = {
      message: options
    };
  }
  // 获取配置里的关闭函数并保存
  let userOnClose = options.onClose;
  // 生成唯一id
  let id = 'message_' + seed++;

  // 重写 options 里的关闭方法
  options.onClose = function() {
    Message.close(id, userOnClose);
  };
  // 创建消息实例,将 options 传入
  // instance 就相当于是 main.vue 组件实例
  // 可以想象成 new class,能够使用里面的一些变量以及方法
  instance = new MessageConstructor({
    data: options
  });
  instance.id = id;
  // 如果 message 是虚拟结点
  if (isVNode(instance.message)) {
    instance.$slots.default = [instance.message];
    instance.message = null;
  }
  // 挂载元素  放到body下面
  instance.$mount();
  document.body.appendChild(instance.$el);
  // 设置偏移量,当同时出现多个消息提示时,从上往下排列
  let verticalOffset = options.offset || 20;
  instances.forEach(item => {
    verticalOffset += item.$el.offsetHeight + 16;
  });
  instance.verticalOffset = verticalOffset;
  // 设置属性,并显示
  instance.visible = true;
  instance.$el.style.zIndex = PopupManager.nextZIndex();
  instances.push(instance);
  // 返回消息实例
  return instance;
};

// 在 Message 原型上挂载四个方法,调用方式:this.$message.error('haha');
['success', 'warning', 'info', 'error'].forEach(type => {
  Message[type] = (options) => {
    if (isObject(options) && !isVNode(options)) {
      return Message({
        ...options,
        type
      });
    }
    return Message({
      type,
      message: options
    });
  };
});

// 在 Message 上定义关闭方法
// 传入需要关闭项的id,以及关闭时的回调函数
Message.close = function(id, userOnClose) {
  let len = instances.length;
  let index = -1;
  let removedHeight;
  for (let i = 0; i < len; i++) {
    if (id === instances[i].id) {
      // 找到了
      removedHeight = instances[i].$el.offsetHeight;
      index = i;
      if (typeof userOnClose === 'function') {
        // 调用关闭时的回调函数, 参数为被关闭的 message 实例
        userOnClose(instances[i]);
      }
      // 删掉并退出循环
      instances.splice(i, 1);
      break;
    }
  }
  if (len <= 1 || index === -1 || index > instances.length - 1) return;
  // 当同时显示多个消息提示的时候,删掉了其中一个,那么它下面的都需要往上移动一个偏移量
  for (let i = index; i < len - 1 ; i++) {
    let dom = instances[i].$el;
    dom.style['top'] =
      parseInt(dom.style['top'], 10) - removedHeight - 16 + 'px';
  }
};

// 全部关闭的方法
Message.closeAll = function() {
  for (let i = instances.length - 1; i >= 0; i--) {
    instances[i].close();
  }
};

export default Message;

这段代码里有两个方面值得我们去学习,下面我们分别来看下:

1. 使用PopupManager

顾名思义,弹窗管理者,我们知道有很多组件其实都是有弹出层的效果,比如 message、dialog、notifition、drawer 等,那么为了更好的管理这些弹出层组件的优先级,Element-UI 团队封装了 PopupManager 用于管理,关于 PopupManager 的内容有点难理解,后面再专门写一篇文章总结下。

instance.$el.style.zIndex = PopupManager.nextZIndex();

那么在 message 组件中,使用到了 PopupManager 中的一个方法 nextZIndex,用于获取下一个 z-index 的值,其内部就是将 z-index++,使得越后出现的组件显示在最上面。

2. Vue.extend()组件扩展

Vue.extend 可以创建一个组件的构造函数,这个构造函数可以继承父级组件的数据和方法,也可以添加自己的属性和方法,然后用这个构造函数去创建一个子组件。

这个 API 在实际业务开发中我们很少使用,因为相比常用的 Vue.component 写法使用 extend 步骤要更加繁琐一些,但是在一些独立组件开发场景中,Vue.extend + $mount 这对组合是我们需要去关注的。

Vue.extend() 的应用场景:在 Vue 项目中,初始化的根实例后,所有页面基本上都是通过 router 来管理,组件也是通过 import 来进行局部注册,所以组件的创建不需要去关注,相比 extend 要更省心一点点。

但是这样做会有几个缺点:

  1. 组件模板都是事先定义好的,如果我要从接口动态渲染组件怎么办?
  2. 所有内容都是在 #app 下渲染,注册组件都是在当前位置渲染。如果我要实现一个类似于 window.alert() 提示组件要求像调用 JS 函数一样调用它,该怎么办?

这时候,Vue.extend + vm.$mount 组合就派上用场了。

基本用法:

// 1. 定义一个vue模版 
let  tem ={
    template:'{{firstName}} {{lastName}} aka {{alias}}',
    data:function(){    
    return{    
	    firstName:'Walter',   
	    lastName:'White',    
	    alias:'Heisenberg'
    }
}
 
// 2. 调用
const TemConstructor = Vue.extend(tem) 
// 生成一个实例,并且挂载在 #app 上
const intance = new TemConstructor({el:"#app"})

更多关于 Vue.extend 的用法请移步Vue官方文档