乐趣区

关于前端:抖音两个旋转小球的loading实现

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

要实现两个小圆球,咱们能够思考两种计划的实现,第一种就是 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

退出移动版