一、功能需求
-
组件功能
- 显示/隐藏Loading
- 渐变Transition
- loading 图标(动画)
- 文字信息
- 全屏Loading
-
指令模式
-
服务模式
二、注入app
作为一个指令,v-loading 会通过 app.directive 的方式来注入 app
// packages\components\Loading\index.ts
import { Loading } from './src/service'
import { vLoading } from './src/directive'
import "./src/index.scss";
export const ElLoading = {
install(app: App) {
app.directive('loading', vLoading)
app.config.globalProperties.$loading = Loading
},
directive: vLoading,
service: Loading,
}
export default ElLoading
export { vLoading, vLoading as ElLoadingDirective, Loading as ElLoadingService }
install:Vue项目中调用use就会执行install中的代码app.directive('loading', vLoading):指令的方式app.config.globalProperties.$loading = Loading:服务的方式
directive、service:将loading组件的指令和服务两种方式挂载在ElLoading上,并一起导出
三、v-loading` 指令
指令挂载钩子:mounted
// packages\components\Loading\src\directive.ts
export const vLoading: Directive<ElementLoading, LoadingBinding> = {
mounted(el, binding) {
if (binding.value) {
createInstance(el, binding)
}
},
...
}
指令挂载(mounted)期间,如果 binding.value 存在(v-loading 初始值不为 false),则执行 createInstance(el, binding)(创建实例)
createInstance
// packages\components\Loading\src\directive.ts
import { Loading } from './service'
const INSTANCE_KEY = Symbol("ElLoading");
const createInstance = (el: ElementLoading, binding: DirectiveBinding<LoadingBinding>) => {
const vm = binding.instance
// v-loading的值可以传对象,如果是对象的话,这里通过key来获取相应的value
const getBindingProp = <K extends keyof LoadingOptions>(
key: K
): LoadingOptions[K] => isObject(binding.value) ? binding.value[key] : undefined
// 尝试获取vm中对应的[key]值,如果没拿到则使用传入的[key]
const resolveExpression = (key: any) => {
const data = (isString(key) && vm?.[key]) || key
if (data) return ref(data) // 如果data有值则包裹ref
else return data
}
const getProp = <K extends keyof LoadingOptions>(name: K) =>
resolveExpression(
// 先尝试从传入的参数中获取
getBindingProp(name)
// 再尝试从 element-loading-xxx 中获取
|| el.getAttribute(`element-loading-${hyphenate(name)}`) // hyphenate:将驼峰转为中划线
)
// 全屏信息:先从Prop中拿,如果没拿到,则从指令修饰符(binding.modifiers)中拿
const fullscreen = getBindingProp('fullscreen') ?? binding.modifiers.fullscreen
const options: LoadingOptions = {
text: getProp('text'),
svg: getProp('svg'),
svgViewBox: getProp('svgViewBox'),
spinner: getProp('spinner'),
background: getProp('background'),
customClass: getProp('customClass'),
fullscreen,
// 将指令挂载的当前元素赋值给 `target`;如果fullscreen不为undefined,则target为undefined
target: getBindingProp('target') ?? (fullscreen ? undefined : el),
body: getBindingProp('body') ?? binding.modifiers.body,
lock: getBindingProp('lock') ?? binding.modifiers.lock,
};
el[INSTANCE_KEY] = {
options,
instance: Loading(options),
};
};
这里做的事情:
- 初始化
options Loading(options):初始化Loading组件- 将
options和组件实例挂载到el上
Loading(options)
我们进到 service.ts 文件中,来看看 Loading 函数是如何做的实例化操作
// packages\components\Loading\src\service.ts
let fullscreenInstance: LoadingInstance | undefined = undefined
export const Loading = function(options: LoadingOptions = {}): LoadingInstance {
if (!isClient) return undefined as any; // 只能在客户端渲染
const resolved = resolvedOptions(options); // 对 options 做相应的处理,返回一个object对象
if (resolved.fullscreen && fullscreenInstance) { // 如果是渲染全屏loading,则先销毁之前的全屏loading:可能存在之前的全屏loading
fullscreenInstance.remvoeElLoadingChild()
fullscreenInstance.close()
}
const instance = createLoadingComponent({ // 创建 Loading 组件实例
...resolved,
closed: () => {
resolved.closed?.()
if (resolved.fullscreen) fullscreenInstance = undefined
},
});
addStyle(resolved, resolved.parent, instance); // 处理全屏loading的样式,给样式赋值
addClassList(resolved, resolved.parent, instance); // 判断 `parent` 是否有定位,如果没有的话就为其添加定位
// 将addClassList函数挂载到parent上,方便之后销毁时使用
resolved.parent.vLoadingAddClassList = () => addClassList(resolved, resolved.parent, instance)
/**
* 将 loading-number 添加到parent
* 因为如果在某处触发了 “全屏加载”(v-loading.body),并且它的父对象是document.body(带有边距)
* 则全屏加载的destroySelf函数还会删除 'el-loading-parent--relative' ,
* 然后v-loading.body的位置将报错。
*/
let loadingNumber: string | null = resolved.parent.getAttribute('loading-number')
if (!loadingNumber) { // 计算loadingNumber
loadingNumber = '1'
} else {
loadingNumber = `${Number.parseInt(loadingNumber) + 1}`
}
// 给parent设置loading-number
resolved.parent.setAttribute('loading-number', loadingNumber)
// 将 `Loading` 组件的dom添加进 parent
resolved.parent.appendChild(instance.$el);
// 实例渲染完之后, 再修改 visible 来触发 transition
nextTick(() => (instance.visible.value = resolved.visible)); // 赋值用户传入的 visible
if (resolved.fullscreen) { // 如果是全屏loading,则给fullscreenInstance赋值
fullscreenInstance = instance
}
return instance;
};
resolvedOptions
对用户的传参进行处理
import { isString } from '@vue/shared'
const resolvedOptions = (options: LoadingOptions): LoadingOptionsResolved => {
let target: HTMLElement;
// 如果 options.target 是字符串的话,就拿到对应的dom节点;否则就直接是dom节点了,直接赋值
if (isString(options.target)) {
target = document.querySelector(options.target) || document.body;
} else {
target = options.target || document.body;
}
return {
parent: target === document.body || options.body ? document.body : target,
background: options.background || '',
svg: options.svg || '',
svgViewBox: options.svgViewBox || '',
spinner: options.spinner || false,
text: options.text || '',
fullscreen: target === document.body && (options.fullscreen ?? true),
lock: options.lock ?? false,
customClass: options.customClass || '',
visible: options.visible ?? true,
target,
};
};
addStyle
import { useZIndex } from '@element-plus/hooks'
const addStyle = async (
options: LoadingOptionsResolved,
parent: HTMLElement,
instance: LoadingInstance
) => {
const { nextZIndex } = useZIndex() // 获取下一级z-index
const maskStyle: CSSProperties = {} // 最外层遮罩(mask)的样式
if (options.fullscreen) {
instance.originalPosition.value = getStyle(document.body, 'position')
instance.originalOverflow.value = getStyle(document.body, 'overflow')
maskStyle.zIndex = nextZIndex()
} else if (options.parent === document.body) {
instance.originalPosition.value = getStyle(document.body, 'position')
/**
* await dom render when visible is true in init,
* because some component's height maybe 0.
* e.g. el-table.
*/
await nextTick()
for (const property of ['top', 'left']) {
const scroll = property === 'top' ? 'scrollTop' : 'scrollLeft'
maskStyle[property] = `${
(options.target as HTMLElement).getBoundingClientRect()[property] +
document.body[scroll] +
document.documentElement[scroll] -
Number.parseInt(getStyle(document.body, `margin-${property}`), 10)
}px`
}
for (const property of ['height', 'width']) {
maskStyle[property] = `${
(options.target as HTMLElement).getBoundingClientRect()[property]
}px`
}
} else {
instance.originalPosition.value = getStyle(parent, 'position')
}
for (const [key, value] of Object.entries(maskStyle)) {
instance.$el.style[key] = value
}
}
addClassList
判断 parent 是否有定位,如果没有的话就为其添加定位
const addClassList = (parent: HTMLElement, instance: any) => {
if (instance.originalPosition.value !== "absolute" && instance.originalPosition.value !== "fixed") {
addClass(parent, "el-loading-parent--relative");
} else {
removeClass(parent, "el-loading-parent--relative");
}
};
createLoadingComponent
创建组件实例
// packages\components\Loading\src\loading.ts
export function createLoadingComponent(options: any) {
let afterLeaveTimer: number; // 定时器,Loading组件的显示隐藏渐变是300毫秒,在隐藏渐变完成后将Loading组件销毁(这里定时器定为400毫秒)
const afterLeaveFlag = ref(false); // 是否需要销毁:用户可能会多次调用关闭Loading的方法,这个flag用来防止多次调用“销毁方法”
const data = reactive({
...options, // 目前的包含:parent、visible、target 三项
originalPosition: "", // 源元素的position信息
originalOverflow: '', // 源元素的overflow信息
visible: false, // visible 覆盖为 false
});
// 设置文字信息
function setText(text: string) {
data.text = text
}
// 销毁自身
function destroySelf() {
const target = data.parent
if (!target.vLoadingAddClassList) { // 如果target.vLoadingAddClassList不存在,说明正在执行close方法
let loadingNumber: number | string | null = target.getAttribute('loading-number')
loadingNumber = Number.parseInt(loadingNumber as any) - 1
if (!loadingNumber) { // 如果loadingNumber为0,则说明是最底一层loading了
removeClass(target, 'el-loading-parent--relative') // 删除父级class
target.removeAttribute('loading-number') // 删除父级loading-number属性
} else { // 否则说明还不是最后一层loading,只修改loading-number属性
target.setAttribute('loading-number', loadingNumber.toString())
}
removeClass(target, 'el-loading-parent--hidden')
}
remvoeElLoadingChild() // 移除Loading组件
}
function remvoeElLoadingChild(): void {
// vm.$el:组件DOM
vm.$el?.parentNode?.removeChild(vm.$el);
}
// loading关闭
function close() {
if (options.beforeClose && !options.beforeClose()) return
const target = data.parent
target.vLoadingAddClassList = undefined
afterLeaveFlag.value = true; // 将是否需要销毁的flag设置为true
clearTimeout(afterLeaveTimer);
afterLeaveTimer = window.setTimeout(() => {
if (afterLeaveFlag.value) {
afterLeaveFlag.value = false; // 进入销毁逻辑后,将flag设置会false
destroySelf(); // 销毁操作
}
}, 400); // 400毫秒后销毁,因为设置的动画渐变是 300 毫秒
data.visible = false;
options.closed?.() // 如果传入参数有closed函数,则执行一下(相当于回调函数)
}
// Transition结束后(显示/隐藏)执行的回调函数
function handleAfterLeave() {
if (!afterLeaveFlag.value) return
afterLeaveFlag.value = false
destroySelf()
}
// Loading组件
const hayLoadingComponent = {
name: "ElLoading",
setup() {
return () => {
const svg = data.spinner || data.svg; // 看看用户是否传入了旋转器图标
// 旋转器图标
const spinner = h(
"svg", // svg标签
{
class: "circular",
viewBox: data.svgViewBox || "25 25 50 50", // 默认从 25 25 开始,向右侧和下侧延伸 50px
...(svg ? { innerHTML: svg } : {}),
},
[
h("circle", { class: "path", cx: "50", cy: "50", r: "20", fill: "none" }), // svg标签内包裹circle标签
],
);
// 旋转器文字:如果用户传入了text,则生成p标签,否则为undefined
const spinnerText = data.text
? h("p", { class: "el-loading-text" }, [data.text])
: undefined;
// 真正返回的渲染函数
return h(
Transition, // 最外层是一个Transition组件,使其有渐变的效果
{ name: "el-loading-fade", onAfterLeave: handleAfterLeave }, // Transition组件的属性
{
// 默认位置
// withDirectives:允许将指令应用于VNode,返回一个包含应用指令的VNode
// withDirectives(vnode, directives)
default: () => withDirectives(
createVNode( // 创建vnode
"div", // 最外层遮罩
{
style: {
backgroundColor: data.background || "",
},
class: [
"el-loading-mask",
data.customClass, // 用户传入的自定义class
data.fullscreen ? 'is-fullscreen' : '',
],
},
[
h(
"div", // 旋转器和文字
{
class: "el-loading-spinner",
},
[spinner, spinnerText],
),
],
),
[[vShow, data.visible]], // 指令对应的vnode绑定v-show,绑定依赖的值为 data.visible。详见withDirectives使用
),
},
);
}
}
}
// createApp创建组件实例并挂载到一个空的div上
const vm = createApp(hayLoadingComponent).mount(document.createElement("div"));
return {
...toRefs(data), // data中含有全部的options
setText,
remvoeElLoadingChild,
close, // 销毁方法
vm, // Loading组件实例
// 通过 $el 来获取 vm 的dom
get $el(): HTMLElement {
return vm.$el;
},
};
}
export type LoadingInstance = ReturnType<typeof createLoadingComponent>
指令更新钩子:updated
export const vLoading: Directive = {
...
updated(el, binding) {
const instance = el[INSTANCE_KEY]; // mounted时候挂载的instance
if (binding.oldValue !== binding.value) { // 新旧内容如果不一致,才进入更新
if (binding.value && !binding.oldValue) { // 新值存在并且旧值不存在---创建实例
createInstance(el, binding);
} else if (binding.value && binding.oldValue) { // 新值旧值都存在
if (isObject(binding.value)) { // 如果值是对象,则进行更新
updateOptions(binding.value, instance!.options)
}
} else { // 新值旧值都不存在,销毁实例
instance?.instance.close();
}
}
},
};
updateOptions
const updateOptions = (
newOptions: UnwrapRef<LoadingOptions>, // 新配置
originalOptions: LoadingOptions // 旧配置
) => {
for (const key of Object.keys(originalOptions)) {
if (isRef(originalOptions[key]))
originalOptions[key].value = newOptions[key]
}
}
指令销毁钩子:unmounted
export const vLoading: Directive<ElementLoading, LoadingBinding> = {
...
unmounted(el) {
el[INSTANCE_KEY]?.instance.close()
},
}
四、样式
// packages\components\Loading\src\index.scss
.el-loading-mask { // 遮罩样式
position: absolute;
top: 0;
bottom: 0;
right: 0;
left: 0;
background-color: rgba(255, 255, 255, 0.9);
z-index: 2000;
transition: opacity 2s;
.el-loading-spinner {
position: absolute;
width: 100%;
top: 50%;
transform: translateY(-50%);
display: flex;
flex-direction: column;
align-items: center;
.circular {
display: inline;
height: 42px;
width: 42px;
animation: loading-rotate 2s linear infinite;
}
.path {
animation: loading-dash 1.5s ease-in-out infinite;
stroke-dasharray: 90,150;
stroke-dashoffset: 0;
stroke-width: 2;
stroke: #409eff;
stroke-linecap: round;
}
.el-loading-text{
color: #409eff;
margin: 3px 0;
font-size: 14px;
}
}
}
@keyframes loading-rotate {
100% {
transform: rotate(360deg);
}
}
@keyframes loading-dash {
0% {
stroke-dasharray: 1, 200;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 90, 150;
stroke-dashoffset: -40px;
}
100% {
stroke-dasharray: 90, 150;
stroke-dashoffset: -120px;
}
}
// Transition渐变样式
.el-loading-fade-enter-active {
transition: all .3s ease;
}
.el-loading-fade-leave-active {
transition: all .3s ease;
}
.el-loading-fade-enter-from,
.el-loading-fade-leave-to {
opacity: 0;
}