6、loading组件:原理详解+实现 -- 渐进式vue3的组件库通关秘籍
1、前言
vue3中loading组件的主要有两种实现方式:
-
参考 antd 使用loading组件包裹需要增加loading状态的组件,实现覆盖内容的loading
<Loading> you content </Loading>
-
参考 antd 直接使用loading组件,单一的一个行内loading
<Loading></Loading>

- 参考 element 通过指令和服务实现。实现自定义指令 v-loading 和 组件的 service 方法完成模板绑定和命令式的调用。
Loading.service(options)
// 或者
<template>
<el-button
v-loading.fullscreen.lock="fullscreenLoading"
type="primary"
@click="openFullScreen1"
>
As a directive
</el-button>
</template>
相比来说,element的方案较为直观和易用,且相对组件式的,能够了解到的知识点更多。本节参照element实现我们的loading组件。
2、v-loading基础
2.1 自定义指令
我们知道在 vue 中,复用代码的形式主要有两种:组件和组合式函数。组件主要是构建模块,组合式函数侧重于有状态逻辑的复用。另一方面,在 vue 中,可以通过自定义指令的方式,来重用涉及普通元素的底层 DOM 访问的逻辑。
需要注意的是:
只有当所需功能只能通过直接的 DOM 操作来实现时,才应该使用自定义指令。其他情况下应该尽可能地使用
v-bind
这样的内置指令来声明式地使用模板,这样更高效,也对服务端渲染更友好。
这也正好和我们的需求相符合:通过 v-loading 指令,将 loading
标签插入目标元素即可
一个指令的定义对象可以提供几种钩子函数 (都是可选的):
const myDirective = {
// 在绑定元素的 attribute 前
// 或事件监听器应用前调用
created(el, binding, vnode, prevVnode) {
// 下面会介绍各个参数的细节
},
// 在元素被插入到 DOM 前调用
beforeMount(el, binding, vnode, prevVnode) {},
// 在绑定元素的父组件
// 及他自己的所有子节点都挂载完成后调用
mounted(el, binding, vnode, prevVnode) {},
// 绑定元素的父组件更新前调用
beforeUpdate(el, binding, vnode, prevVnode) {},
// 在绑定元素的父组件
// 及他自己的所有子节点都更新后调用
updated(el, binding, vnode, prevVnode) {},
// 绑定元素的父组件卸载前调用
beforeUnmount(el, binding, vnode, prevVnode) {},
// 绑定元素的父组件卸载后调用
unmounted(el, binding, vnode, prevVnode) {}
}
钩子参数:
-
el
:指令绑定到的元素。这可以用于直接操作 DOM。 -
binding
:一个对象,包含以下属性。value
:传递给指令的值。例如在v-my-directive="1 + 1"
中,值是2
。oldValue
:之前的值,仅在beforeUpdate
和updated
中可用。无论值是否更改,它都可用。arg
:传递给指令的参数 (如果有的话)。例如在v-my-directive:foo
中,参数是"foo"
。modifiers
:一个包含修饰符的对象 (如果有的话)。例如在v-my-directive.foo.bar
中,修饰符对象是{ foo: true, bar: true }
。instance
:使用该指令的组件实例。dir
:指令的定义对象。
-
vnode
:代表绑定元素的底层 VNode。 -
prevVnode
:代表之前的渲染中指令所绑定元素的 VNode。仅在beforeUpdate
和updated
钩子中可用。
2.2 v-loading实现
第一步目标:实现 v-loading 指令,展示加载中文字。类似于这样:
新建 components/loading/loading.ts
。
import { createApp, defineComponent, h } from 'vue';
export function createLoadingElement() {
const loadingElement = defineComponent({
setup() {
return () => {
// <div class='loading-container'>
// <p class='text-center'>正在加载中</p>
// <div>
const sinpperText = h('p', { class: 'text-center' }, '正在加载中...');
return h('div', { class: 'loading-container' }, sinpperText);
};
},
});
// loadingApp
const loadingApp = createApp(loadingElement);
// loadingInstance是根组件实例,也就是通过执行应用实例挂载方法,返回了根组件的实例,也就是loadingElement的实例,
const loadingInstance = loadingApp.mount(document.createElement('div'));
return {
loadingInstance,
};
}
export type LoadingApp = ReturnType<typeof createLoadingElement>;
loading.ts
的任务就是实现一个创建loading元素
的函数,返回一个包含vue根组件实例的对象,相当于每一个loading我们都会创建一个vue应用实例,通过挂载,得到该应用的根组件实例,也就是我们的loadingElement
。后续可以通过指令将这一步得到的loadingElement
挂载到目标元素上。
另外,由于在指令中,我们需要动态的手动创建DOM,所以这时候模版语法和jsx都无法满足运行时创建的需求(需要编译),所以需要通过h函数函数来创建,以能够使用vue
提供的能力和js的完全编程能力。
新建 component/loading/service.ts
// 通过指令创建loading元素
import { createLoadingElement, type LoadingApp } from './loading';
import type { LoadingOptions } from './type';
// 创建loading元素
export const Loading = function (options: LoadingOptions): LoadingApp {
const loadingApp = createLoadingElement();
// 将loading元素添加到指定位置
options.parent.appendChild(loadingApp.loadingInstance.$el);
return loadingApp;
};
无论是服务还是指令调用 loading,最终底层还是通过服务的方式创建的,service.ts
就是创建并挂载loading
元素的实际执行方。由于需要知道要将loading
元素挂载到哪里,所以需要传入一个options
参数,来明确挂载位置。
新建 componets/loading/type.ts
export interface LoadingOptions {
parent: HTMLElement;
}
由于目前我们只需要一个挂载位置参数,所以这里就先声明一个参数parent
,类型为HTMLElement
。
最后就是实际的指令的逻辑了。
新建 componets/loading/directive.ts
import type { Directive } from 'vue';
import type { LoadingOptions } from './type';
import { Loading } from './service';
const INSTANCE_KEY = Symbol('loading');
const createInstace = (el: HTMLElement, binding: any) => {
const options: LoadingOptions = {
parent: el,
};
el[INSTANCE_KEY] = {
options,
instance: Loading(options),
};
};
export const loadingDirective: Directive = {
created(el, binding) {
if (binding.value) {
createInstace(el, binding);
}
},
};
我们在指令created
钩子函数处创建loading
实例并挂载。并在目标元素(父元素)上保存loading
实例和参数,方便后续进行其它的操作,比如关闭。
由于在父元素
上保存了loading
实例和参数,所以最好扩展一下父元素
的类型。
修改 componets/loading/type.ts
import type { LoadingApp } from './loading';
export interface LoadingOptions {
parent: HTMLElement;
}
export const INSTANCE_KEY = Symbol('loading');
// 需要loading的元素
export interface ElementLoading extends HTMLElement {
[INSTANCE_KEY]: {
instance: LoadingApp;
options: LoadingOptions;
};
}
- 新增了
ElementLoaing
类型,也就是需要loading的目标元素 - parent的类型修改为了
ElementLoading
INSTANCE_KEY
放到移动到本文件中。
修改 componets/loading/directive.ts
import { INSTANCE_KEY, type ElementLoading, type LoadingOptions } from './type';
const createInstace = (el: ElementLoading, binding: any) => {
// el类型修改为ElementLoading
}
最后,新建components/loading/index.ts
, 导出指令。
import { Loading as LoadingService } from './service';
import { loadingDirective } from './directive';
import type { App } from 'vue';
export const Loading = {
install(app: App) {
// 注册指令
app.directive('loading', loadingDirective);
// 全局挂载服务
app.config.globalProperties.$loading = LoadingService;
},
directive: loadingDirective,
setvice: LoadingService,
};
export default Loading;
export { loadingDirective, LoadingService };
这里不能用之前的通用的withInstall
函数了,需要额外全局注册组件才能使用,同时将$loading
服务挂载到vue
应用实例上。
这里我们注册的名称是loading
,使用的时候直接使用v-loading
即可。
然后修改一下preview/app.tsx
预览一下效果
import { defineComponent } from 'vue';
export default defineComponent({
setup() {
const render = () => {
return (
<>
<div
style={{
height: '100vh',
backgroundColor: '#333333',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
fontSize: '48px',
color: '#ffffff',
}}
v-loading={true}
>
hello world
</div>
</>
);
};
return render;
},
});
运行:npm run dev
指令按照预期工作。
3、 v-loading 完善
上一步已经完成了v-loading
指令的整体架构和代码逻辑,后续只需要按需添加想要的功能就行了,可能有以下内容:
- 加载中的icon,而且能够自定义。
- 加载中需要位于
目标元素顶层
,并添加mask
遮罩,以禁止操作。 - 需要根据外部响应式数据自动开启或者关闭。
- 加载中的文字需要能够自定义
- 最好能够加入自定义的class
- 加入一些修饰符,比如是否全屏等。
下面我们逐一进行实现。
3.1 根据响应式数据开启或者关闭
在vue中,实现元素可见或者不可见的方式有两种:v-if
和v-show
。为了减少和创建元素的和删除元素的开销,这里我们使用v-show
来loading元素的是否可见。
先定义响应式数据:
const data = reactive({
visible: false,
});
后面可能会加入其它的参数,所以这里使用一个对象。
如果想在渲染函数中使用指令,则需要使用withDirectives
函数,我们使用v-show
来控制元素的可见性。
import { createApp, defineComponent, h, reactive, vShow, withDirectives } from 'vue';
export function createLoadingElement() {
// 响应式数据、
const data = reactive({
visible: true,
});
const loadingElement = defineComponent({
setup() {
return () => {
const sinpperText = h('p', { class: 'text-center' }, '正在加载中...');
// 修改处
const loadingContainer = h('div', { class: 'loading-container' }, sinpperText);
// 如果要使用v-show, 则需要使用withDirectives
return withDirectives(loadingContainer, [[vShow, data.visible]]);
};
},
});
// 其余代码
}
export type LoadingApp = ReturnType<typeof createLoadingElement>;
大家可以手动修改一下visible的值,看一下页面的变化:
接下来,就需要从指令或者服务处接受数据的变化(在指令的钩子函数updated),修改visible的值即可。这时就需要给createLoadingElement
加入参数了。
在这之前我们需要先看一下用户传参的流向,有两种方式:
-
指令调用(模板内传递的属性)
-
服务调用:通过js对象传递的参数
所以对于创建一个loading
,应该有两种参数,一种对外给用户通过服务或者指令
调用时使用,一种是将用户的入参,转换为内部逻辑使用的参数,一般我们可以命名为:
- LoadingOptions: 用户入参
- LoadingOptionsResolved: 处理后的内部参数
- 指令调用可以传递 boolean
- 服务调用传递 LoadingOptions
所以这里我们先修改和补充一下我们的参数类型:
修改components/loading/type.ts
import type { UnwrapRef } from 'vue';
import type { LoadingApp } from './loading';
// 内部处理后参数
export type LoadingOptionsResolved = {
/**
* @description v-show使用
*/
visible: boolean;
/**
* @description 父级元素
*/
parent: HTMLElement;
target: HTMLElement;
};
export type LoadingOptions = Partial<Omit<LoadingOptionsResolved, 'parent' | 'target'>> & {
/**
* @description loading覆盖的目标元素,如果传递string,则使用document.querySelector获取
*/
target: string | HTMLElement;
};
export const INSTANCE_KEY = Symbol('loading');
// 需要loading的元素
export interface ElementLoading extends HTMLElement {
[INSTANCE_KEY]: {
instance: LoadingApp;
options: LoadingOptions;
};
}
其中内部处理后参数为:
- visible : 是否可见
- parent : 挂载元素,全屏的时候需要挂载到body,或者指定挂载元素
- target : 需要loading的元素。
用户使用的参数为:
- visible
- target
然后修改一下service.ts
:
// 通过指令创建loading元素
import { createLoadingElement, type LoadingApp } from './loading';
import type { LoadingOptions, LoadingOptionsResolved } from './type';
import { isString } from '@vue/shared';
// 创建loading元素
export const Loading = function (options: LoadingOptions): LoadingApp {
const loadingApp = createLoadingElement();
const resolvedOptions = resolveOptions(options); // 修改
// 将loading元素添加到指定位置
resolvedOptions.parent.appendChild(loadingApp.loadingInstance.$el); // 修改
return loadingApp;
};
// 解析options,新增
const resolveOptions = (options: LoadingOptions): LoadingOptionsResolved => {
let target: HTMLElement;
if (isString(options.target)) {
target = document.querySelector<HTMLElement>(options.target) ?? document.body;
} else {
target = options.target || document.body;
}
return {
parent: target === document.body ? document.body : target,
target,
visible: options.visible ?? true,
};
};
新增了一个解析options的函数,以处理为内部可用的参数,比如将从target
获取到要挂载的parent
.
然后将用户输入的LoadingOptions
转换为LoadingOptionsResolved
即可。
最后修改directive.ts
,
import type { Directive, DirectiveBinding } from 'vue';
import { INSTANCE_KEY, type ElementLoading, type LoadingOptions } from './type';
import { Loading } from './service';
const createInstace = (el: ElementLoading, binding: DirectiveBinding<boolean>) => {
const options: LoadingOptions = {
target: el,
visible: binding.value,
};
el[INSTANCE_KEY] = {
options,
instance: Loading(options),
};
};
export const loadingDirective: Directive<ElementLoading, boolean> = {
mounted(el, binding) {
if (binding.value) {
createInstace(el, binding);
}
},
updated(el, binding) {
const app = el[INSTANCE_KEY];
app.options.visible = binding.value;
},
};
将参数名称从parent
修改为target
即可。
此时通过v-loading
指令可以传递的参数类型为:boolean
然后修改loading.ts
,增加入参。这时候创建元素的参数就应该是LoadingOptionsResolved
了。
import { createApp, defineComponent, h, reactive, vShow, withDirectives } from 'vue';
import type { LoadingOptionsResolved } from './type';
export function createLoadingElement(options: LoadingOptionsResolved) {
// 响应式数据、
const data = reactive({
...options,
});
const loadingElement = defineComponent({
setup() {
return () => {
const sinpperText = h('p', { class: 'text-center' }, '正在加载中...');
const loadingContainer = h('div', { class: 'loading-container' }, sinpperText);
// 如果要使用v-show, 则需要使用withDirectives
return withDirectives(loadingContainer, [[vShow, data.visible]]);
};
},
});
const close = function () {
data.visible = false;
};
const show = function () {
data.visible = true;
};
// loadingApp
const loadingApp = createApp(loadingElement);
// loadingInstance是根组件实例,也就是通过执行应用实例挂载方法,返回了根组件的实例,也就是loadingElement的实例,
const loadingInstance = loadingApp.mount(document.createElement('div'));
return {
loadingInstance,
close,
show,
};
}
export type LoadingApp = ReturnType<typeof createLoadingElement>;
这里我们删除了data
中的visible
字段,从options获取。同时返回了开发和关闭的函数show
,close
。
然后修改调用处,增加传参:service.ts
// 通过指令创建loading元素
import { createLoadingElement, type LoadingApp } from './loading';
import type { LoadingOptions, LoadingOptionsResolved } from './type';
import { isString } from '@vue/shared';
// 创建loading元素
export const Loading = function (options: LoadingOptions): LoadingApp {
const resolvedOptions = resolveOptions(options);
const loadingApp = createLoadingElement(resolvedOptions); // 传入参数
// 将loading元素添加到指定位置
resolvedOptions.parent.appendChild(loadingApp.loadingInstance.$el);
return loadingApp;
};
// 其它代码
};
最后,如果binding有更新,直接在updated
的钩子函数中,调用开启或者关闭函数即可。
修改components/loading/directive.ts
import type { Directive, DirectiveBinding } from 'vue';
import { INSTANCE_KEY, type ElementLoading, type LoadingOptions } from './type';
import { Loading } from './service';
const createInstace = (el: ElementLoading, binding: DirectiveBinding<boolean>) => {
const options: LoadingOptions = {
target: el,
visible: binding.value,
};
el[INSTANCE_KEY] = {
options,
instance: Loading(options),
};
};
export const loadingDirective: Directive<ElementLoading, boolean> = {
mounted(el, binding) {
if (binding.value) {
createInstace(el, binding);
}
},
updated(el, binding) {
const app = el[INSTANCE_KEY];
// 修改处
if (binding.value) {
app.instance.show();
} else {
app.instance.close();
}
},
};
修改一下app.tsx预览一下:定时2s结束
import { defineComponent, ref } from 'vue';
export default defineComponent({
setup() {
const visible = ref(true);
setTimeout(() => {
visible.value = false;
}, 2000);
const render = () => {
return (
<>
<div>
<div
style={{
height: '100vh',
backgroundColor: '#333333',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
fontSize: '48px',
color: '#ffffff',
}}
v-loading={visible.value}
>
hello world
</div>
</div>
</>
);
};
return render;
},
});
3.2 服务式调用
服务式的调用直接传参就行了,传入
- visible
- target
import { defineComponent, onMounted } from 'vue';
import { LoadingService } from '../components/loading';
export default defineComponent({
setup() {
onMounted(() => {
const loading = LoadingService({
visible: true,
target: '#test',
});
setTimeout(() => {
loading.close();
}, 2000);
});
const render = () => {
return (
<>
<div>
<div
style={{
height: '100vh',
backgroundColor: '#333333',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
fontSize: '48px',
color: '#ffffff',
}}
id="test"
>
hello world
</div>
</div>
</>
);
};
return render;
},
});
3.3 样式调整
通常loading都是需要居中置顶显示的,并且需要有遮罩。这时候就需要给目标元素设置一个相对定位
,从而使loading元素可以绝对定位
,覆盖整个目标元素。
首先完善themes/theme/_dark.less
和themes/theme/_light.less
,补充全局变量
:root,
:root[theme-mode='dark'] {
// 其它
// 新增
// 遮罩
--fu-mask-disabled: rgba(0, 0, 0, 60%); // 遮罩-禁用
// 文字 & 图标 颜色
--fu-font-white-1: rgba(255, 255, 255, 100%);
--fu-font-white-2: rgba(255, 255, 255, 55%);
--fu-font-white-3: rgba(255, 255, 255, 35%);
--fu-font-white-4: rgba(255, 255, 255, 22%);
--fu-font-gray-1: rgba(0, 0, 0, 90%);
--fu-font-gray-2: rgba(0, 0, 0, 60%);
--fu-font-gray-3: rgba(0, 0, 0, 40%);
--fu-font-gray-4: rgba(0, 0, 0, 26%);
}
:root,
:root[theme-mode='light'] {
// 其它
// 新增
// 遮罩
--fu-mask-disabled: rgba(255, 255, 255, 60%); // 遮罩-禁用
// 文字 & 图标 颜色
--fu-font-white-1: rgba(255, 255, 255, 100%);
--fu-font-white-2: rgba(255, 255, 255, 55%);
--fu-font-white-3: rgba(255, 255, 255, 35%);
--fu-font-white-4: rgba(255, 255, 255, 22%);
--fu-font-gray-1: rgba(0, 0, 0, 90%);
--fu-font-gray-2: rgba(0, 0, 0, 60%);
--fu-font-gray-3: rgba(0, 0, 0, 40%);
--fu-font-gray-4: rgba(0, 0, 0, 26%);
}
修改 themes/vars/_var.less
, 将新增的全局变量映射为less。
// 颜色色板
// 其它
// 新增
// 遮罩
@mask-disabled: var(--fu-mask-disabled);
// 文字 & 图标 颜色
@font-white-1: var(--fu-font-white-1);
@font-white-2: var(--fu-font-white-2);
@font-white-3: var(--fu-font-white-3);
@font-white-4: var(--fu-font-white-4);
@font-gray-1: var(--fu-font-gray-1);
@font-gray-2: var(--fu-font-gray-2);
@font-gray-3: var(--fu-font-gray-3);
@font-gray-4: var(--fu-font-gray-4);
新建 components/laoding/style/token.less
,
@import '../../../themes//base.less';
// 颜色
@loading-mask-color: @mask-disabled;
@loading-color: @brand-color;
目前只需要两种颜色,mask和文字颜色。
新建 components/laoding/style/index.less
,
@import './token.less';
.loading-parent--reative {
position: relative;
}
.loading-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: @loading-mask-color;
z-index: 1000;
display: flex;
justify-content: center;
align-items: center;
color: @brand-color;
}
这是最终的样式。
然后在 componnets/loading/service.ts
中加入className:
// 通过指令创建loading元素
import { createLoadingElement, type LoadingApp } from './loading';
import type { LoadingOptions, LoadingOptionsResolved } from './type';
import { isString } from '@vue/shared';
// 创建loading元素
export const Loading = function (options: LoadingOptions): LoadingApp {
const resolvedOptions = resolveOptions(options);
const loadingApp = createLoadingElement(resolvedOptions);
// 将loading元素添加到指定位置
resolvedOptions.parent.appendChild(loadingApp.loadingInstance.$el);
addClassList(resolvedOptions); // 新增
return loadingApp;
};
// 新增
const addClassList = function (options: LoadingOptionsResolved) {
// 给loading父元素添加class
options.parent.classList.add('loading-parent--reative');
};
// 解析options
const resolveOptions = (options: LoadingOptions): LoadingOptionsResolved => {
let target: HTMLElement;
if (isString(options.target)) {
target = document.querySelector<HTMLElement>(options.target) ?? document.body;
} else {
target = options.target || document.body;
}
return {
parent: target === document.body ? document.body : target,
target,
visible: options.visible,
};
};
这里新建了一个辅助函数addClassList
,用来给元素添加样式,后续可能还有操作元素样式的可能。
最后,在componnets/loading/index.ts
引入样式即可
import { Loading as LoadingService } from './service';
import { loadingDirective } from './directive';
import '../loading/style/index.less'; // 新增
import type { App } from 'vue';
export const Loading = {
install(app: App) {
app.directive('loading', loadingDirective);
app.config.globalProperties.$loading = LoadingService;
},
directive: loadingDirective,
service: LoadingService,
};
export default Loading;
export { loadingDirective, LoadingService };
保存预览一下:
4、总结
本节我们完成了一个loading组件,可以通过以下两种方式调用:
- 指令
- 服务
两种形式的调用最终都通过service.ts
内的逻辑进行执行。由于传参不同,通转化,将两种不同的参数转化为内部统一的参数给service进行调用。
通过渲染函数手动创建loading元素挂载到目标父元素上。同时使用v-show控制loading元素的可见性。
对于其它的额外属性,比如自定义文字,图标等,都可以通过新增loadingOptions
的配置完成想要的功能,核心逻辑不变
- 指令调用可以从元素的attribute获取绑定的配置
- 服务调用直接从options获取。
整体代码流程如下:
本节代码分支feature_1.4_loading。
5、参考文献
转载自:https://juejin.cn/post/7372082380481806370