本文是我学习 ElementUI 源码的第四篇文章,上一篇文章学习了 ElementUI 中 Scrollbar 组件的实现,这篇文章来学习一下 ElementUI 是如何实现 Message(消息提示)组件的。

每款浏览器都有自带的 alert 消息提示,但是每款样式都不一样,而且基本都很丑,更重要的是还可能被浏览器阻止。因此 ElementUI 自制了 Message 组件,以在不同浏览器上实现样式统一的消息提示。

组件效果

我们先来看看 ElementUI 提供的 Message 组件有哪些特色:

  1. 向下进入动画,向上退出动画
  2. 可自动关闭,可手动闭关,关闭后完全从 DOM 中移除
  3. 多个提示时从按顺序从上往下排列,不相互覆盖,不相互影响;某提示关闭后,处于其后的提示位置上移
  4. ESC 关闭全部提示
  5. 内容可插入 html 片段
  6. 自定义图标
  7. 自定义显示时间
  8. 全局调用
  9. 光标移入到提示上时停止退出计时,移出后重新计时

在继续下文前,先思考一下自己可能会如何去实现这些需求。

组件实现

Message 组件的模版如下:

  <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>
 






















第 1 行中的 @after-leavetransition 组件的一个钩子函数,用于在组件退出后执行某些操作,handleAfterLeave 内容如下:

export default {
    //...
    methods:{
      handleAfterLeave() {
        this.$destroy(true);
        this.$el.parentNode.removeChild(this.$el);
      },
    }
    //...
}

可以看到,handleAfterLeave 做了两件事,第一件是在此提示退出后销毁此组件,第二件是将元素从 DOM 中移除。销毁后可以确保代码中无法在访问此组件,而移除则是为了去除无用的代码,当然也更能保障组件无法再使用。

data 的内容如下:

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
      };
    },
    //...
}

为什么要特地提一下 data 呢?因为这里面的内容对于实现全局调用来说非常重要。

mountedbefeoreDestroy 钩子的内容如下:

export default {
    //...
    mounted() {
      this.startTimer();
      document.addEventListener('keydown', this.keydown);
    },
    beforeDestroy() {
      document.removeEventListener('keydown', this.keydown);
    }
}

当组件 mounted 后,就开始计时,即执行 this.startTimer(),然后为 document 绑定键盘事件,当用户按下 ESC 键时关闭所有提示,并在组件销毁前移除监听器。

keydown 的内容如下:

export default {
    methods:{
      keydown(e) {
        if (e.keyCode === 27) { // esc关闭消息
          if (!this.closed) {
            this.close();
          }
        }
      }
    }
}

可以看出,Message 组件的实现其实比较简单,这里我们只分析了部分重要的代码,接下来我们重点分析全局调用的实现。

全局调用

在 Message 组件的源码文件夹下有一个 main.js 文件,这个文件中的代码就是实现全局调用的主要代码:

message
├── index.js
└── src
    ├── main.js
    └── main.vue



 

打开 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';
let MessageConstructor = Vue.extend(Main);

let instance;
let instances = [];
let seed = 1;




 




首先在头部引入了 Vue,组件的主逻辑 Main 和一些辅助用的函数,我们重点关注第 5 行的 extend 方法。

官网文档中对于 extend 的释义如下:

Create a “subclass” of the base Vue constructor. The argument should be an object containing component options.

中文文档的翻译是这样的:

使用基础 Vue 构造器,创建一个“子类”。参数是一个包含组件选项的对象。

不过我个人觉得前半句的翻译似乎不是很恰当,我觉得应该是:创建基础 Vue 构造器的一个子类。当然,这不是重点,我们知道怎么用就可以了。

官网给出的示例如下:

// create constructor
var Profile = Vue.extend({
  template: '<p>{{firstName}} {{lastName}} aka {{alias}}</p>',
  data: function () {
    return {
      firstName: 'Walter',
      lastName: 'White',
      alias: 'Heisenberg'
    }
  }
})
// create an instance of Profile and mount it on an element
new Profile().$mount('#mount-point')












 

可以看到,在使用 extend 创建构造器的时候,传入的参数和我们在单文件组件中导出的那个对象一致,对应到这里的 Message 组件中就是引入的 Main 单文件。

当创建好构造器后,使用 new 创建实例,并使用 $mount 进行挂载。

接下来,ElementUI 定义了一个函数 Message,其内容如下:

const Message = function(options) {
  if (Vue.prototype.$isServer) return;
  options = options || {};
  if (typeof options === 'string') {
    options = {
      message: options
    };
  }
  let userOnClose = options.onClose;
  let id = 'message_' + seed++;

  options.onClose = function() {
    Message.close(id, userOnClose);
  };
  instance = new MessageConstructor({
    data: options
  });
  instance.id = id;
  if (isVNode(instance.message)) {
    instance.$slots.default = [instance.message];
    instance.message = null;
  }
  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 的内部逻辑并不是很复杂,我们重点分析一部分。

  • 第 1 行

传入的参数 options 对应的是单文件组件中的 data 返回的对象,前文之所以特地提了一下 data,原因就在这里,如果不明确这一点的话,会对后面的内容不明所以。

  • 第 12-14 行

onClose 是 ElementUI 开放给用户的回调函数接口,允许用户在提示关闭后处理一些业务逻辑。

ElementUI 将用户传入的函数指针 onclose 先暂存到变量 userOnClose 中,然后将 onclose 指向一个新的函数,并在这个新函数中将 userOnClose 与组件 id 一起传入到自己创建的 close 方法中,而这个新函数才是真正传入到组件中 onclose 的。这样做的目的是为了在移除组件的同时,能够一并从管理所有组件实例的数组中删除对应实例,不得不说,这番操作,很是值得学习:plus1:。

close 方法是定义在 Message 上的,其内容如下:

Message.close = function(id, userOnClose) {
  let len = instances.length;
  let index = -1;
  for (let i = 0; i < len; i++) {
    if (id === instances[i].id) {
      index = i;
      if (typeof userOnClose === 'function') {
        userOnClose(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';
  }
};

close 方法内部逻辑就是根据传入的 id 到提示实例数组 instances 中将其删除,同时执行用户传入的回调函数(如果有的话),并将被删除的实例作为参数传入。

再次强调,这里的 close 方法并不是真正移除组件的方法,它只是负责将实例从数组中删除,并执行用户的回调函数。

这里还有一个重要的点是,所有提示都是使用绝对定位显示在页面上的,因此一旦移除某个提示后,需要将处于其后的提示位置上移,也就是 close 方法内部后续逻辑的作用。

  • 第 15-17 行

options 传入构造器 MessageConstructor 中,创建一个新实例,前文说过,options 就是 data 中返回的对象,这点一定要注意。

  • 第 19 行

Message 组件是允许用户传入一段 html 片段作为提示内容的,因此需要判断用户传入的是纯文本还是 html 片段,以作不同的处理,这里使用的 isVNodevdom.js 文件中,其内容如下:

import { hasOwn } from 'element-ui/src/utils/util';

export function isVNode(node) {
  return node !== null && typeof node === 'object' && hasOwn(node, 'componentOptions');
};

hasOwn 的内容如下:

export function hasOwn(obj, key) {
  return hasOwnProperty.call(obj, key);
};
  • 第 23-24 行

在第 23 行挂载实例的时候,ElementUI 并没有向 $mount 传入参数,对于这种挂载方式,官方文档是这样解释的:

If elementOrSelector argument is not provided, the template will be rendered as an off-document element, and you will have to use native DOM API to insert it into the document yourself.

elementOrSelector 就是指的参数,即如果不传入参数,则模版将被渲染为脱离文档的元素,需要自己使用原生 DOM API 将其插入到文档中。

所以在第 24 行,ElementUI 将渲染出来的元素手动挂载到了 body 上,事实上,挂载到 body 也确实是最佳选择。

  • 第 31 行

Message 的功能是提示,因此它出现的层级应该是在所有页面元素的最上面(不绝对,还涉及到 Notification 和 Modal 等),第 31 行就是设置组件层级的。

PopupManager 是管理所有弹出类组件的一个通用对象,内容比较多,本文只讲 Message 组件用到的部分。

nextZIndexPopupManager 上的一个方法,其内容如下:

const PopupManager = {
      nextZIndex: function() {
        return PopupManager.zIndex++;
      },
}

当调用 nextZIndex 方法的时候,实际上还会受到另一个函数的控制,就是下面这个:

Object.defineProperty(PopupManager, 'zIndex', {
  configurable: true,
  get() {
    if (!hasInitZIndex) {
      zIndex = zIndex || (Vue.prototype.$ELEMENT || {}).zIndex || 2000;
      hasInitZIndex = true;
    }
    return zIndex;
  },
  set(value) {
    zIndex = value;
  }
});




 








Object.definePropertyPopupManager 做了拦截,当获取其 zIndex 的时候会先判断 hasInitZIndex 是否为 true,如果没有,则会从第 5 行依次获取初始 zIndex 值,hasInitZIndexzIndex 都在文件头部有定义:

let hasInitZIndex = false;
let zIndex;

Vue.prototype.$ELEMENT 中的 zIndex 的值实际是在最终注册所有组件的 index.js 中定义的:

const install = function(Vue, opts = {}) {
  locale.use(opts.locale);
  locale.i18n(opts.i18n);

  components.forEach(component => {
    Vue.component(component.name, component);
  });

  Vue.use(InfiniteScroll);
  Vue.use(Loading.directive);

  Vue.prototype.$ELEMENT = {
    size: opts.size || '',
    zIndex: opts.zIndex || 2000
  };

  Vue.prototype.$loading = Loading.service;
  Vue.prototype.$msgbox = MessageBox;
  Vue.prototype.$alert = MessageBox.alert;
  Vue.prototype.$confirm = MessageBox.confirm;
  Vue.prototype.$prompt = MessageBox.prompt;
  Vue.prototype.$notify = Notification;
  Vue.prototype.$message = Message;

};











 
 
 
 







 


从第 12-15 行可以看出,当我们在全局引入 ElementUI 组件的时候,还可以通过向 use 中传入第二个参数来定义表单类组件的大小和弹窗类组件的 zIndex,非常的实用。

在使用 Message 组件的时候,我们通常都是通过 this.$message 来使用的,这得益于第 23 行。可以看到,ElementUI 将 main.js 导出的 Message 方法添加到了 Vue 的原型上,取名 $messgae,以此来实现通过 this 调用,同理,其他的弹窗类组件也是如此。

Message 组件还有一些简便方法,例如,当我们需要一个错误提示时,我们可以直接使用 this.$message.error({}),在代码中是这样实现的:

['success', 'warning', 'info', 'error'].forEach(type => {
  Message[type] = options => {
    if (typeof options === 'string') {
      options = {
        message: options
      };
    }
    options.type = type;
    return Message(options);
  };
});

ElementUI 对每一种提示类型都在 Message 上新增一个对应的方法,以此实现便捷调用。

Message 上还有一个 closeAll 方法,用于关闭所有实例:

Message.closeAll = function() {
  for (let i = instances.length - 1; i >= 0; i--) {
    instances[i].close();
  }
};


 


需要特别注意的是,第 3 行调用的 close 方法,是实例的方法,不是 Message 的,因为只有调用实例上的 close 方法才能真正关闭实例(当然,关闭的时候会调用 Message 的 close 方法,将实例从数组中删除,详见前文),而 Message 上的 close 方法只能将实例从数组中删除,这两点是有本质区别的,一定要分清。

结语

经过以上分析,我们可以看到,实现一个弹窗组件并不难,难的地方在于如何实现全局调用,但归根结底,还是需要对所用的工具有一个比较全面了解,才能更好的加以利用。

本文分析基于 ElementUI 2.12.0 版本。

最近更新:
作者: MeFelixWang