Element 2 组件源码剖析之Loading 加载--指令实现

2,737 阅读6分钟

简介

加载组件 Loading 常用于页面和区块的加载中状态,页面局部处于等待异步数据或正在渲染过程时,合适的加载动效会有效缓解用户的焦虑。本文将分析其源码实现,耐心读完,相信会对您有所帮助。🔗 组件文档 Loading 🔗 gitee源码

更多组件分析详见 👉 📚 Element UI 源码剖析组件总览

本专栏的 gitbook 版本地址已经发布 📚《learning element-ui》 ,内容同步更新中!

使用方式

组件库提供了两种调用组件的方式:指令方式和服务方式,该组件无法像其他组件一样直接使用<el-loading>

loading插件声明跟之前介绍的组件有所不同,没有导出对应的组件,只导出了指令 directive 和方法service,用于指令方式和服务方式。

// packages/loading/index.js
import directive from './src/directive';
import service from './src/index';

export default {
 install(Vue) {
   Vue.use(directive);
   Vue.prototype.$loading = service;
 },
 directive,
 service
};

在组件库入口文件也是一样的处理。

// src/index.js
import Loading from '../packages/loading/index.js';  
//... 
const install = function(Vue, opts = {}) {
  //...
  Vue.use(Loading.directive); 
  Vue.prototype.$loading = Loading.service; 

}; 
export default {
  //...
  Loading, 
}; 

指令方式

使用自定义指令v-loading,只需要绑定Boolean即可。默认状况下,Loading 遮罩会插入到绑定元素的子节点,通过添加body修饰符,可以使遮罩插入至 DOM 中的 body 上。

<template> 
    <div v-loading.body="true">指令方式</div> 
</template>

全屏遮罩需要添加fullscreen修饰符(遮罩会插入至 body 上),此时若需要锁定屏幕的滚动,可以使用lock修饰。

在绑定了v-loading指令的元素上添加element-loading-text属性,其值会被渲染为加载文案,并显示在加载图标的下方。类似地,element-loading-spinnerelement-loading-background属性分别用来设定图标类名和背景色值。

<template>
  <div
    v-loading="true"
    element-loading-text="拼命加载中"
    element-loading-spinner="el-icon-loading"
    element-loading-background="rgba(0, 0, 0, 0.8)"
    style="height: 200px"
  >
    指令自定义
  </div>
</template>

服务方式

当使用服务方式时,遮罩默认即为全屏,无需额外设置。其中 options 参数为 Loading 的配置项,具体见 官方文档 options

Service 会返回一个 Loading 实例,可通过调用该实例的 close 方法来关闭它。

import { Loading } from 'element-ui';

let loadingInstance = Loading.service(options);
this.$nextTick(() => {
  // 以服务的方式调用的 Loading 需要异步关闭
  loadingInstance.close();
});

当完整/单独引入时,使用 Vue.use后,service 方法会被添加至 Vue.prototype 上,这样就会有一个全局方法 $loading,调用方式为:this.$loading(options)

<template> 
  <el-button type="primary" @click="openFullScreen"> 服务方式 </el-button> 
</template>

<script>
export default { 
  methods: { 
    openFullScreen() {
      const loading = this.$loading(); 
      this.$nextTick(() => {
        // 以服务的方式调用的 Loading 需要异步关闭
        setTimeout(() => {
          loading.close();
        }, 2000);
      });
    },
  },
};
</script> 

以服务的方式调用的 Loading 是单例的:若在前一个全屏 Loading 关闭前再次调用全屏 Loading,并不会创建一个新的 Loading 实例,而是返回现有全屏 Loading 的实例:

openFullScreen() {
  const loadingInstance1 = this.$loading({ fullscreen: true });
  const loadingInstance2 = this.$loading({ fullscreen: true });
  console.log(loadingInstance1 === loadingInstance2); // true 
  
  const loadingInstance3 = this.$loading({ fullscreen: false });
  console.log(loadingInstance1 === loadingInstance3); // false
},

Loading.vue组件

loading.vue 是一个带动画的遮罩组件。

// 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">
        // ...
      </div>
    </div>
  </transition>
</template> 
<script>
  export default {
    data() {
      return {
        text: null, // 加载图标下方的加载文案
        spinner: null, // 自定义加载图标类名
        background: null, // 遮罩背景色
        fullscreen: true, // 全屏遮罩
        visible: false, // 遮罩显示
        customClass: '' // 自定义类名
      };
    },
    // methods ...
  };
</script> 

使用transition 组件,在组件根节点的条件展示 (v-show)中添加过渡效果,定义了 JavaScript 钩子函数after-leave 用于设置过渡离开完成之后的组件状态,this.$emit('after-leave')触发定义的事件after-leave

<transition name="el-loading-fade" @after-leave="handleAfterLeave">

handleAfterLeave() {
  this.$emit('after-leave');
},

组件根节点实现一个绝对定位的遮罩层,并提供了自定义遮罩背景色、自定义类名功能。使用属性visible来控制整个loading组件显隐 v-show

.el-loading-mask {
  position: absolute;  
  z-index: 2000;
  background-color: rgba(255, 255, 255, 0.9);
  top: 0;
  right: 0;
  bottom: 0;
  left: 0; 
  ...
}

默认区域加载,因为使用了absolute, 元素会被移出正常文档流,通过指定元素相对于最近的非 static 定位祖先元素的偏移,来确定元素位置。

当全屏整页加载时,会添加 class 类 .is-fullscreenfixed表示通过指定元素相对于屏幕视口(viewport)的位置来指定元素位置,元素的位置在屏幕滚动时不会改变。

.el-loading-mask.is-fullscreen {
  position: fixed;
}

类名el-loading-spinner的 div 元素在遮罩中提供一个垂直居中的容器,行内内容居中。内部含有3个子元素,若设置了属性spinner自定义加载图标类名,则使用icon 实现加载图标。

  • 默认svg加载图标
  • 自定义icon 加载图标
  • 加载图标下方的加载文案
<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>

.el-loading-spinner {
  top: 50%; 
  width: 100%;
  text-align: center;
  position: absolute;
}

SVG使用CSS动画实现加载效果,在样式文件中使用 @keyframes规则通过在动画序列中定义关键帧(或waypoints)的样式来控制CSS动画序列,具体实现逻辑跟之前 Spinner组件相同 Spinner_CSS动画

圆形加载中使用了 stroke-dasharraystroke-dashoffset等属性设置偏移错位,具体属性讲解详见 Progress_stroke-dasharray/stroke-dashoffset

若传入自定义Icon图标类名,需要指定图标实现动效,例如el-icon-loading。具体其实现逻辑详见 Icon 图标 loding 旋转实现

指令实现

指令v-loading通过开发插件的方式,在install 方法注册一个全局自定义指令,更多信息详见开发插件

MyPlugin.install = function (Vue, options) { 
  // 注册一个全局自定义指令
  Vue.directive('my-directive', {
    bind (el, binding, vnode, oldVnode) {
      // 逻辑...
    }
    ...
  }) 
}

指令代码实现如下,提供如下几个钩子函数, 函数参数具体 官方文档

  • bind:只调用一次,指令第一次绑定到元素时调用。
  • update:所在组件的 VNode 更新时调用。
  • unbind:只调用一次,指令与元素解绑时调用。
// packages\loading\src\directive.js
const loadingDirective = {};
loadingDirective.install = Vue => {
  // 判断 Vue 实例是否运行于服务器
  if (Vue.prototype.$isServer) return; 
  
  // 注册指令loading  使用时的 v-loading
  Vue.directive('loading', {
    // el:指令所绑定的元素,可以用来直接操作 DOM。
    // binding:一个对象,包含以下 property:
    //   * name:指令名,不包括 v- 前缀。
    //   * value:指令的绑定值,例如:v-my-directive="1 + 1" 中,绑定值为 2。
    //   * oldValue:指令绑定的前一个值,仅在 update 和 componentUpdated 钩子中可用。无论值是否改变都可用。
    //   * expression:字符串形式的指令表达式。例如 v-my-directive="1 + 1" 中,表达式为 "1 + 1"。
    //   * arg:传给指令的参数,可选。例如 v-my-directive:foo 中,参数为 "foo"。
    //   * modifiers:一个包含修饰符的对象。例如:v-my-directive.foo.bar 中,修饰符对象为 { foo: true, bar: true }。
    // vnode:Vue 编译生成的虚拟节点。
    // oldVnode:上一个虚拟节点,仅在 update 和 componentUpdated 钩子中可用。
    bind: function(el, binding, vnode) {
       // 逻辑...
    }, 
    update: function(el, binding) {
       // 逻辑...
    }, 
    unbind: function(el, binding) {
       // 逻辑...
    }
  });
}; 
export default loadingDirective; 

各函数中实现逻辑如下:

// 只调用一次,指令第一次绑定到元素时调用。
bind: function(el, binding, vnode) {
  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'); // 自定义样式类名

  // 类型VueComponent,是当前虚拟节点所在的上下文环境 相对于vue组件中使用的this
  const vm = vnode.context;

  // 创建一个loading实例,挂载到一个新生成的div元素上
  const mask = new Mask({
    el: document.createElement('div'),
    data: {
      text: vm && vm[textExr] || textExr,
      spinner: vm && vm[spinnerExr] || spinnerExr,
      background: vm && vm[backgroundExr] || backgroundExr,
      customClass: vm && vm[customClassExr] || customClassExr,
      // 判断 fullscreen 修饰符使用情况决定是否全屏
      fullscreen: !!binding.modifiers.fullscreen
    }
  });
  el.instance = mask; // 实例挂载到el
  el.mask = mask.$el; // 实例的根 DOM 元素挂载到el
  el.maskStyle = {};

  // 指令绑定的值true 执行方法toggleLoading
  binding.value && toggleLoading(el, binding);
},
// 所在组件的 VNode 更新时调用
update: function(el, binding) {
  // 调用组件setText 方法更新加载文案
  el.instance.setText(el.getAttribute('element-loading-text'));
  // 指令绑定的值前后不一样时
  if (binding.oldValue !== binding.value) {
    toggleLoading(el, binding);
  }
},
// 只调用一次,指令与元素解绑时调用
unbind: function(el, binding) {
  // el元素存在新增操作
  if (el.domInserted) {
    // mask存在父节点,就移除mask节点
    el.mask &&
      el.mask.parentNode &&
      el.mask.parentNode.removeChild(el.mask);
    toggleLoading(el, { value: false, modifiers: binding.modifiers });
  }
  // 若实例存在,销毁该实例。清理它与其它实例的连接,解绑它的全部指令及事件监听器。
  el.instance && el.instance.$destroy();
}

toggleLoading()

方法 toggleLoading用于处理组件 loading 的显隐。

const toggleLoading = (el, binding) => {
  // v-loading绑定值true 显示loading组件
  if (binding.value) {
    // 等组件渲染异步处理
    Vue.nextTick(() => {
      // 使用fullscreen修饰符 全屏加载
      if (binding.modifiers.fullscreen) {
        // 获取body元素position属性值
        el.originalPosition = getStyle(document.body, 'position');
        // 获取body元素overflow属性值
        el.originalOverflow = getStyle(document.body, 'overflow');
        // 设置loading的DOM堆叠层级 z-index
        el.maskStyle.zIndex = PopupManager.nextZIndex();
        // loading 实例DOM元素添加样式类 is-fullscreen
        addClass(el.mask, 'is-fullscreen');
        // 将 loading 实例插入body节点中
        insertDom(document.body, el, binding);
      } else {
        // 区域加载 移除样式类 is-fullscreen
        removeClass(el.mask, 'is-fullscreen');
        // 使用body修饰符 使遮罩插入至 DOM 中的 body 上
        if (binding.modifiers.body) {
          el.originalPosition = getStyle(document.body, 'position');

          // 使用 getBoundingClientRect 获取元素的位置和大小
          // 计算遮罩的left、top
          // 因为是区域加载 偏移量要基于绑定元素的left、top值
          ['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';
          });
          // 计算遮罩的 width 、height
          ['height', 'width'].forEach(property => {
            el.maskStyle[property] = el.getBoundingClientRect()[property] + 'px';
          });
          // 将 loading 实例插入body节点中
          insertDom(document.body, el, binding);
        } else {
          // 将 loading 实例插入绑定元素中
          el.originalPosition = getStyle(el, 'position');
          insertDom(el, el, binding);
        }
      }
    });
  } else {
    // v-loading绑定值false 隐藏loading组件
    // 为loading实例 after-leave事件绑定回调函数
    afterLeave(el.instance, _ => {
      if (!el.instance.hiding) return;
      console.log(el.instance.hiding);
      // 更新 domVisible 不可见
      el.domVisible = false;
      // 若使用 fullscreen body 修饰符,父节点就是body元素
      const target = binding.modifiers.fullscreen || binding.modifiers.body
        ? document.body
        : el;
      // 移除父节点样式
      removeClass(target, 'el-loading-parent--relative');
      removeClass(target, 'el-loading-parent--hidden');
      // hiding为false,代表隐藏结束
      el.instance.hiding = false;
    }, 300, true);

    el.instance.visible = false;
    el.instance.hiding = true; // 隐藏操作中
  }
};

insertDom()

insertDom用于将laoding实例附加至页面DOM元素中。

const insertDom = (parent, el, binding) => {
  // 防止重复插入
  if (!el.domVisible && getStyle(el, 'display') !== 'none' && getStyle(el, 'visibility') !== 'hidden') {
    // 设置遮罩样式 width 、height、left、top、z-index
    Object.keys(el.maskStyle).forEach(property => {
      el.mask.style[property] = el.maskStyle[property];
    });
    // 根据父节点Position属性  决定父级是否需要增加relative属性
    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;
    // 将loading实例附加到指定父节点的子节点列表的末尾
    parent.appendChild(el.mask);
    Vue.nextTick(() => {
      if (el.instance.hiding) {
        el.instance.$emit('after-leave');
      } else {
        el.instance.visible = true;
      }
    });
    el.domInserted = true;
  } else if (el.domVisible && el.instance.hiding === true) {
    // 节点存在只是隐藏了 直接更新属性显示
    el.instance.visible = true;
    el.instance.hiding = false;
  }
};

PopupManager.zIndex

组件库的堆叠层级 z-index 使用PopupManager 统一管理,提供配置入口,初始值为2000

// src\utils\popup\popup-manager.js
const PopupManager = { 
  // ...
  nextZIndex: function() {
    return PopupManager.zIndex++;
  },  
};

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

// src\index.js
const install = function(Vue, opts = {}) { 

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

getBoundingClientRect()

getBoundingClientRect() 用于获取元素的位置和大小。除了 width 和 height 以外的属性是相对于视图窗口的左上角来计算的。

image.png

📚参考&关联阅读

'plugins',vuejs
'api/Vue-extend',vuejs
'transitions#JavaScript 钩子',vuejs
'CSS/position',MDN
自定义指令,vuejs
VNode class declaration
'Element/getBoundingClientRect',MDN

关注专栏

此文章已收录到专栏中 👇,可以直接关注。