简介
加载组件 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-spinner
和element-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-fullscreen
,fixed
表示通过指定元素相对于屏幕视口(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-dasharray
、 stroke-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
以外的属性是相对于视图窗口的左上角来计算的。
📚参考&关联阅读
'plugins',vuejs
'api/Vue-extend',vuejs
'transitions#JavaScript 钩子',vuejs
'CSS/position',MDN
自定义指令,vuejs
VNode class declaration
'Element/getBoundingClientRect',MDN
关注专栏
此文章已收录到专栏中 👇,可以直接关注。