likes
comments
collection
share

6、loading组件:原理详解+实现 -- 渐进式vue3的组件库通关秘籍

作者站长头像
站长
· 阅读数 88

1、前言

vue3中loading组件的主要有两种实现方式:

  • 参考 antd 使用loading组件包裹需要增加loading状态的组件,实现覆盖内容的loading

    <Loading>
      you content
    </Loading>
    

    6、loading组件:原理详解+实现 -- 渐进式vue3的组件库通关秘籍

  • 参考 antd 直接使用loading组件,单一的一个行内loading

    <Loading></Loading>
    
6、loading组件:原理详解+实现 -- 渐进式vue3的组件库通关秘籍
  • 参考 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:之前的值,仅在 beforeUpdateupdated 中可用。无论值是否更改,它都可用。
    • arg:传递给指令的参数 (如果有的话)。例如在 v-my-directive:foo 中,参数是 "foo"
    • modifiers:一个包含修饰符的对象 (如果有的话)。例如在 v-my-directive.foo.bar 中,修饰符对象是 { foo: true, bar: true }
    • instance:使用该指令的组件实例。
    • dir:指令的定义对象。
  • vnode:代表绑定元素的底层 VNode。

  • prevVnode:代表之前的渲染中指令所绑定元素的 VNode。仅在 beforeUpdateupdated 钩子中可用。

2.2 v-loading实现

第一步目标:实现 v-loading 指令,展示加载中文字。类似于这样:

6、loading组件:原理详解+实现 -- 渐进式vue3的组件库通关秘籍

新建 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

6、loading组件:原理详解+实现 -- 渐进式vue3的组件库通关秘籍

指令按照预期工作。

3、 v-loading 完善

上一步已经完成了v-loading指令的整体架构和代码逻辑,后续只需要按需添加想要的功能就行了,可能有以下内容:

  • 加载中的icon,而且能够自定义。
  • 加载中需要位于目标元素顶层,并添加mask遮罩,以禁止操作。
  • 需要根据外部响应式数据自动开启或者关闭。
  • 加载中的文字需要能够自定义
  • 最好能够加入自定义的class
  • 加入一些修饰符,比如是否全屏等。

下面我们逐一进行实现。

3.1 根据响应式数据开启或者关闭

在vue中,实现元素可见或者不可见的方式有两种:v-ifv-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的值,看一下页面的变化:

6、loading组件:原理详解+实现 -- 渐进式vue3的组件库通关秘籍

接下来,就需要从指令或者服务处接受数据的变化(在指令的钩子函数updated),修改visible的值即可。这时就需要给createLoadingElement加入参数了。

在这之前我们需要先看一下用户传参的流向,有两种方式:

  • 指令调用(模板内传递的属性)

  • 服务调用:通过js对象传递的参数

    6、loading组件:原理详解+实现 -- 渐进式vue3的组件库通关秘籍

所以对于创建一个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;
  },
});

6、loading组件:原理详解+实现 -- 渐进式vue3的组件库通关秘籍

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.lessthemes/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 };

保存预览一下:

6、loading组件:原理详解+实现 -- 渐进式vue3的组件库通关秘籍

4、总结

本节我们完成了一个loading组件,可以通过以下两种方式调用:

  • 指令
  • 服务

两种形式的调用最终都通过service.ts内的逻辑进行执行。由于传参不同,通转化,将两种不同的参数转化为内部统一的参数给service进行调用。

通过渲染函数手动创建loading元素挂载到目标父元素上。同时使用v-show控制loading元素的可见性。

对于其它的额外属性,比如自定义文字,图标等,都可以通过新增loadingOptions的配置完成想要的功能,核心逻辑不变

  • 指令调用可以从元素的attribute获取绑定的配置
  • 服务调用直接从options获取。

整体代码流程如下:

6、loading组件:原理详解+实现 -- 渐进式vue3的组件库通关秘籍

本节代码分支feature_1.4_loading

5、参考文献

转载自:https://juejin.cn/post/7372082380481806370
评论
请登录