[element-ui源码]v-loading源码分析

1,042 阅读2分钟

1.前言

此次分析element-ui的version为2.14.1。

此次分析只针对指令模式v-loading进行分析,服务模式即const loading=this.$loading({...})不作考虑。

本文大多数解释都直接放在源码里,故源码以外的文字描述相对较少。

2.源码分析

element-dev\packages\loading\index.js

import directive from './src/directive';
import service from './src/index';

export default {
  /**
   * 当:
   * import {Loading} from  'element-ui'
   * Vue.use(loading)时,就会执行下面定义的install方法
   */
  install(Vue) {
    // 同理,执行directive中的install方法
    Vue.use(directive);
    Vue.prototype.$loading = service;
  },
  directive,
  service
};

由上可知,若通过指令方式引用时,运行主要是./src/directive.js文件面的逻辑。下面看看该文件的整体结构。

element-dev\packages\loading\src\directive.js

import Vue from 'vue';
import Loading from './loading.vue';
import { addClass, removeClass, getStyle } from 'element-ui/src/utils/dom';
import { PopupManager } from 'element-ui/src/utils/popup';
import afterLeave from 'element-ui/src/utils/after-leave';
// 通过extend指令创建一个Vue的子类。可通过new实例化const Mask = Vue.extend(Loading);

const loadingDirective = {};
loadingDirective.install = Vue => {
    if (Vue.prototype.$isServer) return;
    // 用于处理v-loading绑定值为true和false的情况
    const toggleLoading = (el, binding) => {
        // ...
    };
    // 用于处理loading为true时,开启Loading遮罩层
    const insertDom = (parent, el, binding) => {
        // ...
    };
    Vue.directive('loading', {
        bind: function(el, binding, vnode) {
            // ...
        },
        update: function(el, binding) {
            // ...
        },
        unbind: function(el, binding) {
            // ...
        }
    });
};

export default loadingDirective;

以上是整个directive.js的结构,可知新建了一个loadingDirective对象,里面提供了install方法被element-dev\packages\loading\index.js中的Vue.use(directive)调用。

loadingDirective对象除了定义了bind,update,unbind三个指令周期函数外,还定义了toggleLoadinginsertDom方法。

代码开头通过Vue.extend给./loading.vue里面定义的类创建可供实例化的Vue子类。以下为./loading.vue中的源码。

loading.vue

// element-dev/packages/loading/src/loading.vue
<template>
  <transition name="el-loading-fade" @after-leave="handleAfterLeave">
    <div
      v-show="visible"
      class="el-loading-mask"
      :style="{ backgroundColor: background || '' }"
      :class="[customClass, { 'is-fullscreen': fullscreen }]">
      <div class="el-loading-spinner">
        <svg v-if="!spinner" class="circular" viewBox="25 25 50 50">
          <circle class="path" cx="50" cy="50" r="20" fill="none"/>
        </svg>
        <i v-else :class="spinner"></i>
        <p v-if="text" class="el-loading-text">{{ text }}</p>
      </div>
    </div>
  </transition>
</template>

<script>
  export default {
    data() {
      return {
        text: null,
        spinner: null,
        background: null,
        fullscreen: true,
        visible: false,
        customClass: ''
      };
    },

    methods: {
      handleAfterLeave() {
        this.$emit('after-leave');
      },
      setText(text) {
        this.text = text;
      }
    }
  };
</script>

回到directive.js上继续分析。先放出周期函数的详细内容及个人注解:

Vue.directive('loading', {
    /**
     * 只调用一次,指令第一次绑定到元素时调用。在这里可以进行一次性的初始化设置
     * @param {*} el 指令所绑定的元素,可以用来直接操作 DOM
     * @param {*} binding 一个包含多个property的对象
     * @param {*} vnode Vue 编译生成的虚拟节点
     */
    bind: function(el, binding, vnode) {
      // 获取el上的element-loading-text等属性
      const textExr = el.getAttribute('element-loading-text');
      const spinnerExr = el.getAttribute('element-loading-spinner');
      const backgroundExr = el.getAttribute('element-loading-background');
      const customClassExr = el.getAttribute('element-loading-custom-class');
      const vm = vnode.context;
      const mask = new Mask({
        // 创建挂载元素,相当于$el。用于在mounted执行之前挂载
        el: document.createElement('div'),
        /**
         * 覆盖Loading实例中data里的部分参数。
         * 不明白为什么这里要 vm && vm[textExr]。举个例子:
         * 如果在某个被绑定v-loading的实例的data中有temp: 'el-icon-loading',
         * 然后,以下两种写法,效果都一样。即加载时的图标类名都是'el-icon-loading'
         * (1) el-table(v-loading element-loading-spinner="temp")
         * (2) el-table(v-loading :element-loading-spinner="temp")
         */
        data: {
          text: vm && vm[textExr] || textExr,
          spinner: vm && vm[spinnerExr] || spinnerExr,
          background: vm && vm[backgroundExr] || backgroundExr,
          customClass: vm && vm[customClassExr] || customClassExr,
          fullscreen: !!binding.modifiers.fullscreen
        }
      });
      // 记录loading的实例mask和其挂载元素
      el.instance = mask;
      el.mask = mask.$el;
      // 初始化一个maskStyle的对象来记录之后要改变的mask中的style属性
      el.maskStyle = {};
      // 如果v-loading绑定的是真值,则执行toggleLoading函数
      binding.value && toggleLoading(el, binding);
    },
    // 被绑定元素所在的模板更新时调用,而不论绑定值是否变化。通过比较更新前后的绑定值,可以忽略不必要的模板更新。
    update: function(el, binding) {
      el.instance.setText(el.getAttribute('element-loading-text'));
      if (binding.oldValue !== binding.value) {
        toggleLoading(el, binding);
      }
    },
    // 只调用一次,指令与元素解绑时调用。
    unbind: function(el, binding) {
      // 检测domInserted确定el.mask是否已被插入到parent的子元素中
      if (el.domInserted) {
        el.mask &&
        el.mask.parentNode &&
        el.mask.parentNode.removeChild(el.mask);
        toggleLoading(el, { value: false, modifiers: binding.modifiers });
      }
      el.instance && el.instance.$destroy();
    }
 });

bind函数中调用toggleLoading开始分析,放出toggleLoading函数和afterLeaving函数的详细内容及个人注解。推荐先看afterLeaving函数后看toggleLoading函数。

element-dev\src\utils\after-leave.js

/**
 * Bind after-leave event for vue instance. Make sure after-leave is called in any browsers.
 *
 * @param {Vue} instance Vue instance.
 * @param {Function} callback callback of after-leave event
 * @param {Number} speed the speed of transition, default value is 300ms
 * @param {Boolean} once weather bind after-leave once. default value is false.
 */
export default function(instance, callback, speed = 300, once = false) {
  if (!instance || !callback) throw new Error('instance & callback is required');
  // 执行标志位,当callback执行后设置为true
  let called = false;
  /**
   * 每次执行之前检测called,为true则不往下执行。
   * 目的是为了只执行一次callback
   */
  const afterLeaveCallback = function() {
    if (called) return;
    called = true;
    if (callback) {
      callback.apply(null, arguments);
    }
  };
  // 感觉once是否为true都没区别,因为called的存在注定callback只执行一次。
  if (once) {
    instance.$once('after-leave', afterLeaveCallback);
  } else {
    instance.$on('after-leave', afterLeaveCallback);
  }
  // 如果从绑定到触发after-leave事件超过speed+100毫秒,则直接触发callback()事件
  setTimeout(() => {
    afterLeaveCallback();
  }, speed + 100);
};

directive.js的toggleLoading

/**
   * 用于处理v-loading绑定值为true和false的情况
   * @param {*} el 指令所绑定的元素,可以用来直接操作 DOM
   * @param {*} binding 一个包含多个property的对象
   */
  const toggleLoading = (el, binding) => {
    // 绑定值为true,且
    if (binding.value) {
      // 使用nextTick把传入函数在页面dom更新后再执行,以获取最新的style
      Vue.nextTick(() => {
        /**
         * v-loading.fullscreen时执行。此时遮罩会插入至 body 上,且通过设置以下css样式实现全屏覆盖:
         * {
         *   position: fixed;
         *   margin:0;
         *   top:0;
         *   bottom:0;
         *   left:0;
         *   right:0;
         * }
         */ 
        if (binding.modifiers.fullscreen) {
          el.originalPosition = getStyle(document.body, 'position');
          el.originalOverflow = getStyle(document.body, 'overflow');
          // 通过 PopupManager.nextZIndex()获取最新的也是最大的z-index。以让遮罩层显示在页面最上级
          el.maskStyle.zIndex = PopupManager.nextZIndex();
          addClass(el.mask, 'is-fullscreen');
          insertDom(document.body, el, binding);
        } else {
          removeClass(el.mask, 'is-fullscreen');
          // v-loading.body时执行。此时遮罩会插入至 body 上而不是子元素上。
          if (binding.modifiers.body) {
            el.originalPosition = getStyle(document.body, 'position');
            /**通过
             * getBoundingClientRect(dom元素左上角到显示屏上边和左边的距离)
             * +
             * body.scrollTop/scrollLeft(body当前的滚动条向下/向右的滚动进度)
             * -
             * window.getComputedStyle(body, ['margin-left'/'margin-top'])(减去经计算后的margin值)
             * 获取el左上角距离body(不包括margin)的左上角的距离left,top
             * 放在makeStyle中
             */
            ['top', 'left'].forEach(property => {
              const scroll = property === 'top' ? 'scrollTop' : 'scrollLeft';
              el.maskStyle[property] = el.getBoundingClientRect()[property] +
                document.body[scroll] +
                document.documentElement[scroll] -
                parseInt(getStyle(document.body, `margin-${ property }`), 10) +
                'px';
            });
            /**
             * 通过
             * getBoundingClientRect获取el的width和height
             * 放在makeStyle中
             */
            ['height', 'width'].forEach(property => {
              el.maskStyle[property] = el.getBoundingClientRect()[property] + 'px';
            });

            insertDom(document.body, el, binding);
          } else {
            // 获取el的position属性,放在makeStyle中
            el.originalPosition = getStyle(el, 'position');
            insertDom(el, el, binding);
          }
        }
      });
    } else {
      el.instance.visible = false;
      el.instance.hiding = true;
      /**
       * 当el.instance.visible为false。
       * 在loading.vue的源码(以下)可知:
       *     <transition name="el-loading-fade" @after-leave="handleAfterLeave">
       *        <div
       *          v-show="visible"
       *          ...........
       * transition中的钩子函数after-leave会被触发,继而触发handleAfterLeave函数,
       * handleAfterLeave中通过this.$emit('after-leave')触发执行下面afterLeave传入的回调函数
       */
      afterLeave(el.instance, _ => {
        /**
         * 这里的instance.hiding想了蛮久的,以下是个人猜测:
         * instance.hiding作为标志位用来记录loading是否已被取消,true代表已被取消。
         * callback根据hiding===true来移除parent的class中的类'el-loading-parent--relative''el-loading-parent--hidden'
         * 再把domVisible设置为false代表parent的类已被移除。
         * 为什么下面代码已经把hiding设置为true的情况下,还要检测hiding是否为true?
         *    从afterLeave源码可知,afterLeave把传入的回调函数用setTimeout回调执行,此时可当作宏任务放在任务队列中(准确来说是定时触发器线程检测到计时结束后才把该宏任务放置到任务队列中)。
         *    可是如果hiding在宏任务还没执行前,就变成了false,那回调函数就不应该执行。例如:
         *      在宏任务还没执行前:
         *        binding.value变为true,执行insertDom方法。
         *        如果此时domVisible已经为true,即parent中的对应的class:'el-loading-parent--relative'和'el-loading-parent--hidden'还没被移除
         *        el.instance.hiding就会被设置为false。
         */
        if (!el.instance.hiding) return;
        el.domVisible = false;
        const target = binding.modifiers.fullscreen || binding.modifiers.body
          ? document.body
          : el;
        removeClass(target, 'el-loading-parent--relative');
        removeClass(target, 'el-loading-parent--hidden');
        el.instance.hiding = false;
      }, 300, true);
    }
  }

toggleLoading函数中若绑定值为true时调用insertDom函数。最后放出insertDom函数的源码和注解:

/**
   * 用于处理loading为true时,开启Loading遮罩层
   * @param {*} parent 要被插入loading的父元素
   * @param {*} el 实例
   * @param {*} binding 一个包含多个property的对象
   */
  const insertDom = (parent, el, binding) => {
    /**
     * domVisible:作为标志位,个人猜测:为true代表parent的class属性已经插入对应的class,如下面的'el-loading-parent--relative'或'el-loading-parent--hidden'
     */
    if (!el.domVisible && getStyle(el, 'display') !== 'none' && getStyle(el, 'visibility') !== 'hidden') {
      // 通过遍历把之前放置在makeStyle中的属性全部设置到el.mask的style中
      Object.keys(el.maskStyle).forEach(property => {
        el.mask.style[property] = el.maskStyle[property];
      });

      if (el.originalPosition !== 'absolute' && el.originalPosition !== 'fixed') {
        addClass(parent, 'el-loading-parent--relative');
      }
      if (binding.modifiers.fullscreen && binding.modifiers.lock) {
        addClass(parent, 'el-loading-parent--hidden');
      }
      el.domVisible = true;
      parent.appendChild(el.mask);
      // 使用nextTick把传入函数在页面dom更新后再执行,以获取最新的hiding状态
      Vue.nextTick(() => {
        if (el.instance.hiding) {
          el.instance.$emit('after-leave');
        } else {
          el.instance.visible = true;
        }
      });
      // domInserted是记录el.mask是否已被插入到parent的子元素中。为true代表已被插入。
      el.domInserted = true;
      /**
       * 当domVisible为true,即parent中的对应的class:'el-loading-parent--relative'和'el-loading-parent--hidden'还没被移除,
       * 则把instance.hiding设置为false
       * 且把instance.visible设置为true
       */
    } else if (el.domVisible && el.instance.hiding === true) {
      el.instance.visible = true;
      el.instance.hiding = false;
    }
 };

看到这里可以返回去看update和unbind的周期函数顺着分析,会觉得逻辑蛮清晰的。本文就不重复说明了,注释里