vant 4 之loading组件源码阅读

前言

loading组件介绍

1. 下载源码

shell 复制代码
## 克隆官方仓库
git clone git@github.com:vant-ui/vant.git 
## 进入文件夹根目录
cd vant 
## 安装依赖 
pnpm i
## 运行package.json中的命令
pnpm dev

2. 找到页面的loading组件,如下图

3. 如何使用van-loading组件

3.1 注入到vue全局组件,整个项目直接使用

js 复制代码
import { createApp } from 'vue';
import { Loading } from 'vant';

const app = createApp();
app.use(Loading);
html 复制代码
<van-loading />
<van-loading type="spinner" />
   
### Color
<van-loading color="#1989fa" />
<van-loading type="spinner" color="#1989fa" />

### Size
<van-loading size="24" />
<van-loading type="spinner" size="24px" />

### Text Color
<!-- the color of text and icon will be changed -->
<van-loading color="#0094ff" />

<!-- only change text color -->
<van-loading text-color="#0094ff" />

### Custom Icon
<van-loading vertical>
  <template #icon>
    <van-icon name="star-o" size="30" />
  </template>
  Loading...
</van-loading>

loading源码分析

让我们找到loading文件夹/index.ts开启源码之旅把

1. index.ts解析

  • 为什么需要Loading = withInstall(_Loading)?
typescript 复制代码
import { withInstall } from '../utils';
import _Loading from './Loading';

// 为什么Loading组件导出要先执行withInstall(_Loading)
export const Loading = withInstall(_Loading);
// 默认导出Loading
export default Loading;

// 导出LoadingProps的一些属性,用于TS开发导入类型
export { loadingProps } from './Loading';
export type { LoadingType, LoadingProps } from './Loading';
export type { LoadingThemeVars } from './types';

// 给已有的 `vue` 模块增加 VanLoading 类型,便于TS提示
declare module 'vue' {
  export interface GlobalComponents {
    VanLoading: typeof Loading;
  }
}
  • withInstall是什么函数?
    为组件添加 install 方法,使其能够通过 app.use() 进行全局注册,同时提供 TypeScript 类型增强
typescript 复制代码
// 1. 优先学习下withInstall中引用的函数camelize
// camelize函数实现my-button 转换成myButton, 将匹配到的-b字符替换成了B字符
// 为什么需要 camelize(`-${name}`)?
// 这是为了同时注册 kebab-case (van-loading) 和 PascalCase (VanLoading) 两种形式的组件
// 例如:
// app.use(Loading) 后
// 以下两种写法都有效:
// <van-loading> 和 <VanLoading>

const camelizeRE = /-(\w)/g;
export const camelize = (str: string): string =>
  str.replace(camelizeRE, (_, c) => c.toUpperCase());

// with-install.ts 文件 code
import { camelize } from './format';
import type { App, Component } from 'vue';

type EventShim = {
  new (...args: any[]): {
    $props: {
      onClick?: (...args: any[]) => void;
    };
  };
};
// 使用 `&` 把T类型属性、install属性和EventShim属性融合在一起成一个新的类型
export type WithInstall<T> = T & {
  install(app: App): void;
} & EventShim;

export function withInstall<T extends Component>(options: T) {
// 给传入的options添加一个install方法
  (options as Record<string, unknown>).install = (app: App) => {
    const { name } = options;
    if (name) {
      app.component(name, options); // PascalCase形式
      app.component(camelize(`-${name}`), options); // kebab-case形式
    }
  };
  
  // 通过 `withInstall()` 在组件上动态添加了一个 `install()` 方法,因此需要在类型上声明它,所以必须要as WithInstall<T>类型,都在后面使用该loading组件TS会报错
  return options as WithInstall<T>;
}

2. Loading.tsx源码阅读

  • 前置函数getSizeStyle
    该函数返回一个对象,包含width 和 height属性
javascript 复制代码
// 判断val值不为空
const isDef = <T>(val: T): val is NonNullable<T> =>
  val !== undefined && val !== null;

// 添加px单位函数
function addUnit(value?: Numeric): string | undefined {
  if (isDef(value)) {
    return isNumeric(value) ? `${value}px` : String(value);
  }
  return undefined;
}
// 获取{width: size, height: size}的css样式
export function getSizeStyle(
  originSize?: Numeric | Numeric[],
): CSSProperties | undefined {
  if (isDef(originSize)) { // 判断originSize不为空
    if (Array.isArray(originSize)) { // 判断传参是数组
      return {
        width: addUnit(originSize[0]),
        height: addUnit(originSize[1]),
      };
    }
    const size = addUnit(originSize);
    return {
      width: size,
      height: size,
    };
  }
}
  • makeStringProp 获取一个对象类型{ type: String, default: defaultVal }, 返回Vue 组件 prop 定义类型模板
vbnet 复制代码
export const makeStringProp = <T>(defaultVal: T) => ({
  type: String as unknown as PropType<T>,
  default: defaultVal,
});
/**
 * 为什么需要 String as unknown as PropType<T> 这种双重断言?
 * 
 * 1. 运行时需求:Vue 需要知道这是一个 String 类型的 prop
 * 2. 编译时需求:TypeScript 需要推断出更精确的类型(如 'circular' | 'spinner')
 * 
 * 例如:
 * type: makeStringProp<LoadingType>('circular')
 * 
 * 在运行时,Vue 看到的是 { type: String, default: 'circular' }
 * 在编译时,TypeScript 看到的是 PropType<LoadingType>,即 'circular' | 'spinner'
 * 
 * 这是 Vue + TypeScript 项目中处理枚举类型 props 的常用技巧
 */
  • createNamespace工具函数
    这里插播一下,什么是BEM命名?
    BEM (Block Element Modifier) 是一种CSS类名命名方法 BEM由三个核心部分组成,每个部分都有明确的语义和命名规则:

    1. Block (块)
    • 定义:独立的、可复用的组件或功能单元

    • 命名规则block-name

    • 示例.button, .menu, .header, .loading

    1. Element (元素)
    • 定义:Block的组成部分,没有独立意义

    • 特点

      • 必须属于某个Block
      • 不能单独使用
      • 通常不能被其他Block共享
    • 命名规则block-name__element-name

    • 示例.menu__item, .button__icon, .loading__spinner

    1. Modifier (修饰符)
    • 定义:表示Block或Element的不同状态或变体
    • 特点
      • 描述外观、状态或行为的变化
      • 不单独使用,必须与Block或Element一起使用
      • 类似于"形容词"修饰"名词"
    • 命名规则block-name--modifier-nameblock-name__element-name--modifier-name
    • 示例.button--primary, .menu__item--active, .loading--vertical
typescript 复制代码
// createBEM和createTranslate函数执行之后都是返回一个函数,这里是对闭包的一个优秀运用案例!
export function createNamespace(name: string) {
  const prefixedName = `van-${name}`; // 添加统一前缀,避免样式冲突

  return [
    prefixedName, // 基础类名,例如'vant-loading'
    createBEM(prefixedName), // BEM命名工具函数
    createTranslate(prefixedName), // 国际化翻译工具
  ] as const;
}
/**
 * 哈哈,我们看官方注释,createBEM返回BEM命名格式
 * bem helper
 * b() // 'button'
 * b('text') // 'button__text'
 * b({ disabled }) // 'button button--disabled'
 * b('text', { disabled }) // 'button__text button__text--disabled'
 * b(['disabled', 'primary']) // 'button button--disabled button--primary'
 */
export function createBEM(name: string) {
  return (el?: Mods, mods?: Mods): Mods => {
    if (el && typeof el !== 'string') {
      mods = el;
      el = '';
    }

    el = el ? `${name}__${el}` : name;

    return `${el}${genBem(el, mods)}`;
  };
}
function genBem(name: string, mods?: Mods): string {
  if (!mods) {
    return '';
  }

  if (typeof mods === 'string') {
    return ` ${name}--${mods}`;
  }

  if (Array.isArray(mods)) {
    return (mods as Mod[]).reduce<string>(
      (ret, item) => ret + genBem(name, item),
      '',
    );
  }

  return Object.keys(mods).reduce(
    (ret, key) => ret + (mods[key] ? genBem(name, key) : ''),
    '',
  );
}
// 获取国际化的message
export function createTranslate(name: string) {
  const prefix = camelize(name) + '.';

  return (path: string, ...args: unknown[]) => {
    const messages = locale.messages();
    const message = get(messages, prefix + path) || get(messages, path);

    return isFunction(message) ? message(...args) : message;
  };
}
  • Loading.tsx源码
typescript 复制代码
// 导入vue的一些内部函数
import { computed, defineComponent, type ExtractPropTypes } from 'vue';
// 从utils中导入一些内部封装的函数,详情看前文具体介绍
import {
  extend,  // const extend = Object.assign
  addUnit, // 将数字格式为带px的字符串,例如传参是10, 格式化为10px
  numericProp, // const numericProp = [Number, String]
  getSizeStyle,
  makeStringProp,
  createNamespace,
} from '../utils';
// bem创建BEM规范的css类名
const [name, bem] = createNamespace('loading');

const SpinIcon: JSX.Element[] = Array(12)
  .fill(null)
  .map((_, index) => <i class={bem('line', String(index + 1))} />);
/**
 * 为什么是12个元素?
 * 
 * 这是实现 spinner 加载动画的关键:12个线条通过CSS动画依次显示/隐藏
 * 形成旋转效果。每个线条有不同的动画延迟,创建出流畅的旋转效果。
 * 
 */

const CircularIcon = (
  <svg class={bem('circular')} viewBox="25 25 50 50">
    <circle cx="50" cy="50" r="20" fill="none" />
  </svg>
);

export type LoadingType = 'circular' | 'spinner';

/** makeStringProp 返回了一个对象类型, type是String类型,default是'circular'默认值
*{
*   type: String,
*   default: 'circular'
* }
**/
export const loadingProps = {
  size: numericProp, // [Number, String]类型
  type: makeStringProp<LoadingType>('circular'), 
  color: String,
  vertical: Boolean,
  textSize: numericProp, // [Number, String]类型
  textColor: String,
};

// ExtractPropTypes 是 Vue 3 专门为 TypeScript 提供的类型工具
// 它的作用是:从组件的 props 选项中提取运行时的 TypeScript 类型
export type LoadingProps = ExtractPropTypes<typeof loadingProps>;

export default defineComponent({
  name,

  props: loadingProps,

  setup(props, { slots }) {
    // 使用computed 优化性能,只有当prop变化时才重新计算
    const spinnerStyle = computed(() =>
      extend({ color: props.color }, getSizeStyle(props.size)),
    );
    
    // 内置2种类型的icon, 优先使用用户插槽自定义的图标
    const renderIcon = () => {
    // 根据type选择内置图标
      const DefaultIcon = props.type === 'spinner' ? SpinIcon : CircularIcon;
      return (
        <span class={bem('spinner', props.type)} style={spinnerStyle.value}>
          {slots.icon ? slots.icon() : DefaultIcon}
        </span>
      );
    };
    // 渲染文本
    const renderText = () => {
      if (slots.default) {
        return (
          <span
            class={bem('text')}
            style={{
              fontSize: addUnit(props.textSize),
              color: props.textColor ?? props.color,
            }}
          >
            {slots.default()}
          </span>
        );
      }
    };
    
    // 返回组件的div结构
    return () => {
      const { type, vertical } = props;
      return (
        <div
          class={bem([type, { vertical }])}
          aria-live="polite"
          aria-busy={true}
        >
          {renderIcon()}
          {renderText()}
        </div>
      );
    };
  },
});

Loading Icon的CSS动画

让我们来看看这2个loading icon动画是怎么实现的!

1. 圆圈loading是通过svg绘制的

ini 复制代码
<span class="van-loading__spinner van-loading__spinner--circular">
    <svg class="van-loading__circular" viewBox="25 25 50 50">
        <circle cx="50" cy="50" r="20" fill="none"></circle>
    </svg>
</span>
css 复制代码
//注入到root中的一些变量
:root {
    --van-loading-spinner-size: 30px;
    --van-loading-spinner-duration: .8s;
    ...
}
.van-loading__spinner--circular {
   animation-duration: 2s; // 动画的一个运行过程时间,该属性覆盖了animation中的0.8s
}
.van-loading__spinner {
    width: var(--van-loading-spinner-size);  // --van-loading-spinner-size是挂载在root上的变量
    max-width: 100%;
    height: var(--van-loading-spinner-size);
    vertical-align: middle;
    max-height: 100%;
    animation: van-rotate var(--van-loading-spinner-duration) linear infinite; // 定义动画效果和时间
    display: inline-block;
    position: relative;
}
// van-rotate的动画效果就是旋转360deg
@keyframes van-rotate {
  from {
    transform: rotate(0deg); // 等同于0%, 定义动画的起始状态
  }
  to {
    transform: rotate(360deg); // 等同于100%, 定义动画的结束状态
  }
}

animation 是一个复合属性,包含了 8 个子属性。在代码中的简写形式:

css 复制代码
animation: van-rotate var(--van-loading-spinner-duration) linear infinite;

等价于以下完整形式:

css 复制代码
animation-name: van-rotate;           /* 动画名称 */
animation-duration: 0.8s;             /* 动画持续时间 - 来自CSS变量 */
animation-timing-function: linear;    /* 动画速度曲线 */
animation-iteration-count: infinite;  /* 无限循环 */
animation-direction: normal;          /* 默认正向播放 */
animation-delay: 0s;                  /* 无延迟 */
animation-fill-mode: none;            /* 动画结束后不保留状态 */
animation-play-state: running;        /* 默认运行状态 */

2.花瓣loading

js代码中SpinIcon的定义, 用了12个i标签

javascript 复制代码
const SpinIcon: JSX.Element[] = Array(12) .fill(null) .map((_, index) => <i class={bem('line', String(index + 1))} />);

页面渲染如下代码

html 复制代码
<div class="van-loading van-loading--spinner" aria-live="polite" aria-busy="true">
  <span class="van-loading__spinner van-loading__spinner--spinner" style="color: rgb(25, 137, 250);">
    <i class="van-loading__line van-loading__line--1"></i>
    <i class="van-loading__line van-loading__line--2"></i>
    <i class="van-loading__line van-loading__line--3"></i>
    <i class="van-loading__line van-loading__line--4"></i>
    <i class="van-loading__line van-loading__line--5"></i>
    <i class="van-loading__line van-loading__line--6"></i>
    <i class="van-loading__line van-loading__line--7"></i>
    <i class="van-loading__line van-loading__line--8"></i>
    <i class="van-loading__line van-loading__line--9"></i>
    <i class="van-loading__line van-loading__line--10"></i>
    <i class="van-loading__line van-loading__line--11"></i>
    <i class="van-loading__line van-loading__line--12"></i>
  </span>
  </div>

.van-loading__spinner--spinner {
    animation-timing-function: steps(12, end); // 将整个动画分成 **12 个离散步骤**,-   `steps(12, end)` 将动画周期分成 12 个相等时间段
}

每个 .van-loading__line 的通用样式

css 复制代码
css
编辑
.van-loading__line {
  width: 100%;
  height: 100%;
  position: absolute;
  top: 0;
  left: 0;
}
  • 所有 line 元素都 完全覆盖父容器(spinner 容器)
  • 通过 position: absolute 叠在一起。 每个 line 有独立的旋转角度和透明度
css 复制代码
.van-loading__line--1 { opacity: 1;     transform: rotate(30deg);  }
.van-loading__line--2 { opacity: 0.9375; transform: rotate(60deg); }
...
.van-loading__line--10 { ... }
  • 每个 line 被 单独旋转到特定角度(30°、60°、90°......),说明它们呈放射状分布。
  • 同时,opacity 逐渐递减 (1 → 0.9375 → ...),形成"由亮到暗"的渐变拖尾效果,模拟旋转时的运动模糊或惯性。 关键点来了:如果每个 line 的角度和透明度都是静态写死的,那"旋转"效果从何而来?

动画如何工作?

答案是:整个 .van-loading__spinner 容器在旋转!

css 复制代码
   @keyframes van-spinner-rotate {
     0%   { transform: rotate(0deg); }
     100% { transform: rotate(360deg); }
   }

   .van-loading__spinner--spinner {
     animation: van-spinner-rotate 1s infinite steps(12, end);
   }

那么发生了什么?
12 个 line 已经以 30° 间隔放射状排好(像钟表的刻度)。

  1. 整个容器steps(12, end) 的方式 每 1/12 周期跳转 30°
  2. 由于容器旋转,原本朝 30° 的 line--1 会依次出现在 0°、330°、300°......的位置。
  3. 同时,因为每个 line 的 opacity 不同 (越靠后的越透明),当容器旋转时,你会看到:
    • 一个 高亮的"头" (opacity=1)
    • 后面跟着 逐渐变淡的"尾巴" (opacity 递减)
    • 整体形成 顺时针旋转的光弧效果

总结

  1. 整个loading组件功能较少,代码很简洁
  2. 了解了组件库的BEM格式的css命名创建
  3. TS类型学习,我的TS类型学习的很少
  4. css动画学习
    如有问题,欢迎指正,下篇源码见~
相关推荐
hxmmm3 小时前
自定义封装 vue多页项目新增项目脚手架
前端·javascript·node.js
ETA83 小时前
JS执行机制揭秘:你以为的“顺序执行”,其实是V8引擎在背后搞事情!
前端·javascript
鹏北海-RemHusband3 小时前
微前端实现方式:HTML Entry 与 JS Entry 的区别
前端·javascript·html
瘦的可以下饭了4 小时前
3 链表 二叉树
前端·javascript
我那工具都齐_明早我过来上班4 小时前
WebODM生成3DTiles模型在Cesium地图上会垂直显示问题解决(y-up-to-z-up)
前端·gis
粉末的沉淀4 小时前
jeecgboot:electron桌面应用打包
前端·javascript·electron
1024肥宅4 小时前
浏览器相关 API:DOM 操作全解析
前端·浏览器·dom
烟西4 小时前
手撕React18源码系列 - Event-Loop模型
前端·javascript·react.js