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

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

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

一、使用示例

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

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

js 复制代码
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 可以是字符串,也可以是虚拟结点。

二、梳理结构

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

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

三、源码剖析

main.vue

js 复制代码
<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 片段:

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

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

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

js 复制代码
<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

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 的内容有点难理解,后面再专门写一篇文章总结下。

js 复制代码
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 组合就派上用场了。

基本用法:

js 复制代码
// 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官方文档

相关推荐
我不吃饼干6 分钟前
TypeScript 类型体操练习笔记(三)
前端·typescript
华仔啊10 分钟前
除了防抖和节流,还有哪些 JS 性能优化手段?
前端·javascript·vue.js
CHU72903517 分钟前
随时随地学新知——线上网课教学小程序前端功能详解
前端·小程序
清粥油条可乐炸鸡19 分钟前
motion入门教程
前端·css·react.js
这是个栗子22 分钟前
【Vue3项目】电商前台项目(四)
前端·vue.js·pinia·表单校验·面包屑导航
前端Hardy26 分钟前
Electrobun 正式登场:仅 12MB,JS 桌面开发迎来轻量化新方案!
前端·javascript·electron
树上有只程序猿26 分钟前
新世界的入场券,不再只发给程序员
前端·人工智能
confiself36 分钟前
deer-flow前端分析
前端
刘宇琪37 分钟前
Vite 生产环境代码分割与懒加载优化
前端
恋猫de小郭1 小时前
让你的 OpenClaw 带你学习,清华开源 AI 私人导师 OpenMAIC
前端·人工智能·ai编程