1
头图

抖音的小圆球加载效果相信大家都见识过,也对其中的实现原理应该有一定的好奇心吧,下面就让我带大家来探索一下小圆球加载效果的实现原理吧。

要实现两个小圆球,我们可以思考两种方案的实现,第一种就是css方案,画两个小圆球,然后使用css动画来实现,而第二种则是使用canvas方案。我们首先来尝试第一种方案,首先肯定是要画出两个小圆球,这不就是相当于画两个圆嘛,所以使用宽高加圆角属性即可实现。

html代码如下:

<div class="rotate-ball">
  <div class="small-ball small-left-ball"></div>
  <div class="small-ball small-right-ball"></div>
</div>

首先是一个旋转的容器元素,接着就是左右两个小圆球,我的思路也很简单,既然两个小球是互相旋转的,那也就是说我给它们的父元素旋转不就达到了两个小球互相旋转的效果吗?

接下来我们来看样式代码:

.small-ball {
    width: 11px;
    height: 11px;
    border-radius: 50%;
}
.small-ball.small-left-ball {
    background-color: #e94359;
}
.small-ball.small-right-ball {
    background-color: #74f0ed;
}
.rotate-ball {
    width: 22px;
    animation: rotate 5s ease-in infinite forwards;
    transition: 2s;
    display: flex;
    align-items: center;
    justify-content: space-between;
}
@keyframes rotate {
   0% {
      transform: rotateY(0deg);
   }
   100% {
      transform: rotateY(360deg);
   }
}

样式代码很简单,就是设置两个小圆球的宽高和圆角,然后分别设置不同的背景色,然后给父元素添加旋转动画,这看起来似乎很容易就实现了,接下来我们来看效果。

https://code.juejin.cn/pen/7231520565935210500

嗯大功告成,等等,这个效果差的太远了吧,没那么简单,好吧很显然这个方案不太合适,让我们换一种方式来实现,也就是第二种方案canvas方案。

使用canvas方案实现我们主要分为两个步骤,第一步即使用canvas画出两个小圆球,第二步则是让两个小圆球进行翻转,也就是添加翻转动画。

首先第一步当然是画小圆球,每个小圆球我们都可以看作是一个类,我们把它叫做ball,好的,接下来我们来看代码如下:

class Ball { 
  // 这里写核心代码
}

既然小圆球是一个类,那么我们小圆球就会有属性,思考一下,我们会有哪些属性呢?总结如下:

  • 圆心X坐标
  • 圆心Y坐标
  • 半径
  • 开始角度
  • 结束角度
  • 顺时针,逆时针方向指定
  • 是否描边
  • 是否填充
  • 缩放X比例
  • 缩放Y比例

首先小圆球有一个圆心坐标,既x和y坐标,其次还有半径,然后旋转的角度会有开始和结束,并且还会有旋转的方向,然后就是画小圆球是否有描边,是否能够填充,最后就是缩放比例(主要用于小球运动时,我们根据实际效果可以看到小球旋转的时候明显有缩放效果,所以这里需要一个缩放比例的属性)。

分析了属性之后,很显然我们第一步要做的就是初始化这些属性,代码如下:

class Ball {
  x: number;
  y: number;
  r: number;
  startAngle: number;
  endAngle: number;
  anticlockwise: boolean;
  stroke: boolean;
  fill: boolean;
  scaleX: number;
  scaleY: number;
  lineWidth: number;
  fillStyle: string | CanvasGradient | CanvasPattern;
  strokeStyle: string | CanvasGradient | CanvasPattern;
  constructor(o: AnyObj) {
    this.x = 0; // 圆心X坐标
    this.y = 0; // 圆心Y坐标
    this.r = 0; // 半径
    this.startAngle = 0; // 开始角度
    this.endAngle = 0; // 结束角度
    this.anticlockwise = false; // 顺时针,逆时针方向指定
    this.stroke = false; // 是否描边
    this.fill = false; // 是否填充
    this.scaleX = 1; // 缩放X比例
    this.scaleY = 1; // 缩放Y比例
    this.init(o);
  }
  init(o: AnyObj): void {
    Object.keys(o).forEach(k => (this[k] = o[k]));
  }
}

初始化属性之后我们要干什么?那当然是渲染小圆球啦,写一个render方法就可以了。

class Ball {
    // 以上代码以省略
    render(){
      // 渲染小圆球代码
    }
}

如何画小圆球?也就是canvas画圆的步骤,最核心的就是canvas的arc方法,总的说来,我们主要分为设置原点坐标,设置缩放,调用arc方法画圆,设置线宽,填充颜色,以及描边这几步,然后我们返回小圆球实例,因而代码如下:

class Ball {
    // 以上代码已省略
    render(ctx: CanvasRenderingContext2D | null): Ball | void {
    if (!ctx) {
      return;
    }
    ctx.save();
    ctx.beginPath();
    ctx.translate(this.x, this.y); // 设置原点的位置
    ctx.scale(this.scaleX, this.scaleY); // 设定缩放
    ctx.arc(0, 0, this.r, this.startAngle, this.endAngle); // 画圆
    if (this.lineWidth) {
      // 线宽
      ctx.lineWidth = this.lineWidth;
    }
    if (this.fill) {
      // 是否填充
      this.fillStyle ? (ctx.fillStyle = this.fillStyle) : null;
      ctx.fill();
    }
    if (this.stroke) {
      // 是否描边
      this.strokeStyle ? (ctx.strokeStyle = this.strokeStyle) : null;
      ctx.stroke();
    }
    ctx.restore();
    return this;
  }
}

如此一来,我们画小圆球这一步就算是完成了,接下来我们要做的就是让小圆球动起来。要让小圆球动起来,那么就需要用到定时器,然而我这里并没有使用setInterval函数,这是为什么呢?

如果我们把定时器每次执行一次看作是一个任务,那么setInterval就相当于是按照一定的时间间隔来执行任务,而一个任务的开始时间和结束时间我们是无法保证它们之间的时间间隔的,也就是说有时候我们的循环定时任务会被跳过,而setTimeout因为是在条件满足的时候会自动停止,所以我们可以使用setTimeout来避免这个问题,因此,接下来我要说的就是我们会使用setTimeout来模拟实现setInterval函数。

那么如何实现呢?

我们把每次setTimeout执行也看作是一个任务,然后我们通过一个对象来存储每一次执行的任务,这样我们每次执行的任务都可以通过在对象当中找到,因此,我们要清除任务同样也可以从对象当中取出任务来清除。

也就是说,我们存储每一个setTimeout任务的延迟id,这个函数返回一个数值型的延迟id,我们把这个值记录到对象当中,方便后面从对象当中取出然后清除任务。

实现这个函数主要分成两部分,第一部分当然还是模拟实现执行定时任务,第二部分就是模拟实现一个清除定时任务的函数,即clearInterval函数的模拟实现。

模拟实现定时任务我们可以使用递归来实现,这个应该还是比较好理解,这里我们还有一点,那就是存储在对象当中的延迟id,我们需要一个属性名,对象不就是一种含有属性名属性值的键值对数据类型吗?在这里属姓名我们可以使用Symbol类型,为什么使用这个数据类型?因为这个数据类型确保了唯一性。

最后还有一点,那就是如果要写ts类型,那么定时器任务的回调函数应该是任意类型的函数,因此这里需要编写类型代码。如下:

type AnyFunction = (...args: any) => any;

通过以上分析,我们的模拟函数代码就很好理解了,代码如下:

export const defineSetInterval = (): {
  setInterval: (fn: AnyFunction, time: number) => symbol;
  clearInterval: (k: symbol) => void;
} => {
  const timeWorker = {};
  const key = Symbol();
  const defineInterval = (handler: AnyFunction, interval: number) => {
    const executor = (fn: AnyFunction, time: number) => {
      timeWorker[key] = setTimeout(() => {
        fn();
        executor(fn, time);
      }, time);
    };
    executor(handler, interval);
    return key;
  };
  const defineClearInterval = (k: symbol):void => {
    if (k in timeWorker) {
      clearTimeout(timeWorker[k] as number);
      delete timeWorker[k];
    }
  };
  return {
    setInterval: defineInterval,
    clearInterval: defineClearInterval
  };
};

以下是该函数的使用示例代码:

const { setInterval, clearInterval } = defineSetInterval();
const timeId = setInterval(() => alert('hello,world!'), 1000);
// 取消定时器// clearInterval(timeId);

让我们继续下一步,下一步我们当然是创建这两个小圆球,然后暴露出一个start方法和一个clear方法,顾名思义,就是在这个函数当中我们创建小圆球,然后默认不执行动画,将执行动画的逻辑包装在start方法中,而之所以留下一个clear方法,那就是如果需要实现暂停效果,也就是清除定时器了,那么我们就需要调用clear方法清除定时器,暂停执行动画,如果需要重新执行动画,那么我们也就重新调用start方法即可。因此这个函数的结构我们可以定义如下:

export interface CreateBallReturnType {
  clear: () => void;
  start: (time?: number) => void;
}
export interface AnyObj {
    [prop: string]: unknown
}
export const createBall = (
  el: HTMLElement | string,
  leftBallConfig?: AnyObj,
  rightBallConfig?: AnyObj
): CreateBallReturnType => {
  // 这里写核心代码
}

这个函数有3个参数,第一个参数是一个dom元素,也就是说,我们需要将两个小圆球渲染到canvas元素上,再将这个canvas元素添加到一个容器元素当中,这个el参数就是代表传入一个容器元素中,如果不传,那么我们默认就添加到body元素中,第二个和第三个参数分别是两个小圆球的配置属性对象,其实这里我们直接采用默认的就好,不需要传入这两个参数,因此这两个参数是可选的,虽然这里定义的是任意对象,但实际上根据前面小圆球类含有哪些属性的分析结果来看,这两个参数很明显传入的就是初始化的那些属性,如果有特别需求,可以传入这些属性进行更改。

在实现该函数的核心之前,我们这里会涉及到一个计算缩放比例的公式,代码如下:

export const computedScale = (val: number, dir: number, dis: number): number =>
  (val * 1000 + dir * (dis * 1000)) / 1000;

这里就不多分析这个公式的原理了,只要记住它是一个公式就可以了。

接下来我们看该函数的核心实现,我们主要也还是分成2个部分,第一个部分渲染两个小圆球并添加到容器元素中,定义动画函数,并封装到start函数当中,然后暴露出start和clear函数。这里需要注意的一点,那就是小圆球的宽高以及canvas元素的宽高不会太大,然后小圆球移动有个边界,因此x坐标和y坐标有个最小值和最大值,我们定义成一个一维数组即可。

接下来,我们按照相应的分析步骤去实现每一步骤的代码就可以了,每一步在代码当中也有所注明,所以我们只需要看完整代码即可。

export const createBall = (
  el: HTMLElement | string,
  leftBallConfig?: AnyObj,
  rightBallConfig?: AnyObj
): CreateBallReturnType => {
  const container = (typeof el === 'string' ? document.querySelector(el) : el) || document.body;
  const canvas = document.createElement('canvas');
  canvas.width = 34;
  canvas.height = 20;
  container.appendChild(canvas);
  const w = canvas.width;
  const h = canvas.height;
  const ctx = canvas.getContext('2d');
  const xArr = [10, 22];
  const yArr = [10, 10];
  const leftBall = new Ball({
    x: xArr[0],
    y: yArr[0],
    r: 6,
    startAngle: 0,
    endAngle: 2 * Math.PI,
    fill: true,
    fillStyle: '#E94359',
    lineWidth: 1.2,
    ...leftBallConfig
  }).render(ctx);
  const rightBall = new Ball({
    x: xArr[1],
    y: yArr[1],
    r: 6,
    startAngle: 0,
    endAngle: 2 * Math.PI,
    fill: true,
    fillStyle: '#74F0ED',
    lineWidth: 1.2,
    ...rightBallConfig
  }).render(ctx);
  const a = 1.04; // 加速度
  let dir = 1; // 方向
  let dis = 1; // X轴移动初始值
  const move = (): void => {
    if (!ctx || !leftBall || !rightBall) {
      return;
    }
    // 清理画布
    ctx.clearRect(0, 0, w, h);
    // 通过加速度计算移动值
    dis *= a;
    // 更改x轴位置
    leftBall.x += dir * dis;
    rightBall.x -= dir * dis;
    // 计算缩放比例
    leftBall.scaleX = computedScale(-dir, 0.005, leftBall.scaleX);
    leftBall.scaleY = computedScale(-dir, 0.005, leftBall.scaleY);
    rightBall.scaleX = computedScale(dir, 0.005, rightBall.scaleX);
    rightBall.scaleY = computedScale(dir, 0.005, rightBall.scaleY);

    // 到达指定位置后
    if (leftBall.x >= 22 || rightBall.x >= 22 || leftBall.x <= 10 || rightBall.x <= 10) {
      // 设定缩放比例
      leftBall.scaleX = rightBall.scaleX;
      leftBall.scaleY = rightBall.scaleY;
      rightBall.scaleX = leftBall.scaleX;
      rightBall.scaleY = leftBall.scaleY;
      // 还原X轴移动初始值
      dis = 1;
      // 变更移动方向
      dir = -dir;
    }
    // 绘制
    if (dir > 0) {
      // 方向不一样时,小球的绘制顺序要交换,移模拟旋转
      rightBall.render(ctx);
      leftBall.render(ctx);
    } else {
      leftBall.render(ctx);
      rightBall.render(ctx);
    }
  };
  let timer: symbol;
  const { setInterval: setHandler, clearInterval: clearHandler } = defineSetInterval();
  const start = (time = 50): void => {
    timer = setHandler(move, time);
  };
  return {
    start,
    clear: (): void => {
      if (timer) {
        clearHandler(timer);
      }
    }
  };
};

可以看到我们先是创建canvas元素,设置宽高,然后创建两个小圆球添加到canvas元素当中,再然后我们定义一个move方法,也就是小圆球的翻转动画的实现,难点可能就主要是翻转动画的实现原理。

如此一来,我们如果是写js/ts代码,使用起来就很简单,直接调用方法即可,如:

createBall();
// 如果需要指定特定的容器元素,那么传入一个dom元素,例如 document.querySelector('#app')
// 又或者传入一个字符串也可以,既'#app'
// 也就是createBall('#app');

接下来我们再封装一下,将这个函数用到react框架中,做成一个组件,很简单,我们利用ref对象来存储dom元素,然后使用useEffect函数监听这个dom元素是否存在,然后存在就调用该方法。代码如下:

import React, { createRef, CSSProperties, ReactElement, useEffect } from 'react';
import { createBall } from './ball';
import '../style/loading.scss';
export interface LoadingProps extends AnyObj {
  style?: CSSProperties;
}
const Loading = (props: LoadingProps = {}): ReactElement | null => {
  const loadingRef = createRef<HTMLDivElement>();
  useEffect(() => {
    // 这里多一个children判断是因为如果该元素已经被渲染过,我们就不需要添加到容器元素中了
    if (loadingRef.current && !loadingRef.current.children.length) {
      const ball = createBall(loadingRef.current);
      ball.start();
    }
  }, [loadingRef]);
  return <div ref={loadingRef} className="loading" {...props} />;
};

export default Loading;

这里涉及到了一点样式,样式随便自己写:

@import './extend.scss';

.loading {
  position: absolute;
  left: 0;
  top: 0;
  @extend .perfect, .flex-center;
}

extend.scss代码如下:

.flex-content-center {
  display: flex;
  justify-content: center;
}
.flex-align-center {
  display: flex;
  align-items: center;
}
.flex-center {
  @extend .flex-content-center, .flex-align-center;
}
.perfect {
  width: percentage(1);
  height: percentage(1);
}

如此一来,我们的抖音旋转小圆球效果就实现了,如下所示:

https://code.juejin.cn/pen/7231520933775671330


夕水
5.2k 声望5.7k 粉丝

问之以是非而观其志,穷之以辞辩而观其变,资之以计谋而观其识,告知以祸难而观其勇,醉之以酒而观其性,临之以利而观其廉,期之以事而观其信。