Skip to content

贝塞尔曲线从基础到实战

前言

本篇文章的内容会从贝塞尔曲线的基础概念再到原理,最后到几个实战例子来整体介绍贝塞尔曲线,最后再看几个常用的前端动画库,来加深自己在前端动画领域的一个知识储备,这篇文章主要起一个对前端动画抛砖引玉的作用。

贝塞尔曲线

基础概念

要认识前端动画,我们就要先来了解一下基本的动画概念。平时可能我们一直在写 CSS3 动画的 transition/animation,里面有一个我们经常用但可能又不太熟悉的属性,比如: linear、ease、ease-in、ease-out、ease-in-out, 这些都分别是个什么意思?

想要了解这些那首先就要了解一下贝塞尔曲线了。

贝塞尔曲线于 1962 年,由法国工程师皮埃尔·贝济埃(Pierre Bézier)所广泛发表,他运用贝塞尔曲线来为汽车的主体进行设计,贝塞尔曲线最初由保尔·德·卡斯特里奥于 1959 年运用德卡斯特里奥算法开发。

贝塞尔经常被用于绘制计算机图形以及各种动画,用贝塞尔曲线绘制的动画可以很平滑,给到用户一种舒适感。

贝塞尔曲线由控制点来进行定义,通常由两个及以上的点来构成一个贝塞尔曲线。

比如有两个控制点的时候:

image-20230709103337379

三个控制点的时候:

image-20230709103407388

四个控制点的时候:

image-20230709103429642

观察这些曲线,我们能够发现一个现象:

  • 控制点不总是在曲线上
  • 曲线顺序等于控制点数减一
  • 曲线总是在控制点围成的图形内

当我们想要改变贝塞尔曲线的时候,可以去改变控制点的位置,比如这样:

1

像上面这个图,最后我们可以生成一个 cubic-bezier(0,1,1,0) 这样的贝塞尔曲线,

它的动画表现:先快速加速,然后停止,然后再加速直到停止,以一个小球从左到右运动为例就是这样子的:

3

另外贝塞尔曲线的运动是可以超出它本身的位置的,比如这个曲线,它会先向后运动,再开始加速到超出右边位置,最后再回到终点本身的位置:

image-20230709160211583

动画表现是这样的:

3333

生成上面贝塞尔曲线的网站,大家可以来这里自行调试。

深入理解

贝塞尔曲线的生成使用了 De Casteljau (德卡斯特里奥) 这个算法,维基百科上的解释

DeCasteljau 函数接受一个包含贝塞尔曲线控制点的数组 points 和参数 t。通过递归将相邻的控制点进行插值,最终计算出贝塞尔曲线上指定参数 t 处的点坐标。

这个算法的 JavaScript 实现:

js
function deCasteljau(points, t) {
  if (points.length === 1) {
    return points[0];
  }

  const newPoints = [];
  for (let i = 0; i < points.length - 1; i++) {
    const p0 = points[i];
    const p1 = points[i + 1];

    const x = (1 - t) * p0[0] + t * p1[0];
    const y = (1 - t) * p0[1] + t * p1[1];
    newPoints.push([x, y]);
  }

  return deCasteljau(newPoints, t);
}

对于这个算法的解释:

比如我们有一个宽高都为 1 的矩形,把它看做一个坐标系,原点是(0,0),终点是(0,1),另外两个控制点分别为(0,1)和(1,0),此时假如我们的 t 是 0.5,那么就可以这个矩形最中间点 (0.5,0.5) 的坐标。比如下方这个图

image-20230709173323321

当我们把 t 从 0 -> 1 位置不断计算并绘制,那么就可以得出一条贝塞尔曲线了。

我们来写一个示例 demo,来实现这样的一个绘制效果,

它的控制点有 4 个,分别是我们上面提到的那 4 个点

js
const controlPoints = [
  [0, 0], // 起点
  [0, 1], // 控制点1
  [1, 0], // 控制点2
  [1, 1], // 终点
];

ggg

上面这个动图的实现代码:

js
<!DOCTYPE html>
<html>
  <head>
    <title>Bezier Curve Demo</title>
    <style>
      canvas {
        border: 1px solid black;
      }
    </style>
  </head>
  <body>
    <canvas id="canvas" width="500" height="500"></canvas>

    <script>
      const controlPoints = [
        [0, 0], // 起点
        [0, 1], // 控制点1
        [1, 0], // 控制点2
        [1, 1], // 终点
      ];

      // 在页面加载完成后执行绘制函数
      window.onload = function () {
        const canvas = document.getElementById("canvas");
        const ctx = canvas.getContext("2d");

        // 将 y 轴反转,让左下角的位置变为原点
        ctx.transform(1, 0, 0, -1, 0, canvas.height);

        drawBezierCurve(ctx);
      };

	  // 绘制贝塞尔曲线,我们的画布是 500 * 500 的,所以要做一些额外处理
      function drawBezierCurve(ctx) {
        for (let i = 0; i < 500; i++) {
          setTimeout(() => {
            const [x, y] = deCasteljau(controlPoints, i / 500);
            drawPoint(ctx, x * 500, y * 500);
          }, 10 * i);
        }
      }

      // 绘制点,传入点的 x 和 y 坐标
      function drawPoint(ctx, x, y) {
        ctx.beginPath();
        ctx.arc(x, y, 2, 0, 360 * (Math.PI / 180));
        ctx.closePath();

        ctx.stroke();
      }

      // 德卡斯特里奥算法
      function deCasteljau(points, t) {
        if (points.length === 1) {
          return points[0];
        }

        const newPoints = [];
        for (let i = 0; i < points.length - 1; i++) {
          const p0 = points[i];
          const p1 = points[i + 1];
          const x = (1 - t) * p0[0] + t * p1[0];
          const y = (1 - t) * p0[1] + t * p1[1];
          newPoints.push([x, y]);
        }

        return deCasteljau(newPoints, t);
      }
    </script>
  </body>
</html>

接下来为了加深对这个算法的理解,我们再来看下这个贝塞尔曲线上的点是如何被找到的,以这个数组为例:

js
const controlPoints = [
  [0, 0], // 起点
  [0, 1], // 控制点1
  [1, 0], // 控制点2
  [1, 1], // 终点
];

然后以简单的画图形式,把这几个点一一连接起来,先把 (0, 0) -> (0, 1) 连接起来,再把 (0, 1) -> (1, 0) 连接起来, 最后把 (1, 0) -> (1, 1) 连接起来,然后就可以得到下面这个图,看红线部分。

image-20230709193759476

那么连接起来之后,有啥用呢?

问得好,这些线可以辅助我们找到贝塞尔曲线上的点。下面我们先来个简单的,我们将分析过程一步一步拆解来看。

我们就以 t = 0.25 的时候来分析,也就是 1/4 。

  • 分别找到 (0, 0) -> (0, 1)、(0, 1) -> (1, 0) 、 (1, 0) -> (1, 1) 这三根线段的 1/4 处

    image-20230709194654909

  • 接下来把这三个 1/4 处的点一一连接起来

    image-20230709195423044

  • 向上面一样,接下来再找到我们新连接的两根线的 1/4 处,并且把它们连接起来

    image-20230709195852034

  • 最后,再找到我们最后连接的这根线的 1/4 处,那么这个点就是在贝塞尔曲线上的位置

    image-20230709200045235

最后,为了让我们加深理解,这里再看一张动图来领会这个过程

哈哈哈

比如 t = 0.75 的时候,图就是这样子的了,可以看到两根绿色的线和蓝色的线,它们的位置都处在了 3/4 处,那么蓝色线 3/4 处的点就是它在贝塞尔曲线上的点。

image-20230709200825018

以上就是一个对贝塞尔曲线上点的发现过程,其实就是不断通过密集点的绘制,最后在页面上就呈现出了一条平滑的曲线。

小结

De Casteljau 算法是一个递归的算法,它可以构造任意阶数的贝塞尔曲线,但是实践中我们一般都不会使用超过立方阶的贝塞尔曲线。

现在再回到开头我们说的 ease、ease-in、ease-out、ease-in-out 这几个属性,其实它们本质上就是贝塞尔曲线的一种绘制方式。

cubic-bezier 这个函数的四个参数对应的是三次贝塞尔曲线的第二个和第三个控制点的坐标 (x1 ,y1, x2, y2),因为第一个控制点固定为(0, 0),第四个控制点坐标固定为(1,1),x1, x2 必须要在 [0, 1] 范围。

ease:cubic-bezier(0.25, 0.1, 0.25, 1.0)

ease-in:cubic-bezier(0.42, 0.0, 1.0, 1.0)

ease-out:cubic-bezier(0.0, 0.0, 0.58, 1.0)

ease-in-out:cubic-bezier(0.42, 0.0, 0.58, 1.0)

下面是它们对应的贝塞尔曲线图(图片来自 MDN),如果要看实际的一个表现效果,可以去这个网站

image-20230709202649399

最后这里给几个贝塞尔曲线计算公式,不过一般的场景中很少用到,通过可视化鼠标拖拽绘制的方式生成贝塞尔曲线就能完成大部分的需求了。

计算二维空间中的贝塞尔曲线公式:

  • 2 个控制点:

    P = (1-t)P1 + tP2

  • 3 个控制点:

    P = (1−t)^2P1 + 2(1−t)tP2 + t^2P3

  • 4 个控制点:

  • P = (1−t)^3P1 + 3(1−t)^2tP2 +3(1−t)t^2P3 + t^3P4

其它更多的贝塞尔曲线可以参考这个网站

实战

首先说明,大部分的动画都可以使用纯 CSS3 来完成,但是我们这里主要还是讲怎么以 JS 来实现一些常见的动画,并温习上面我们学到的贝塞尔曲线知识。关于 CSS3 和 JS 动画之间,如果不需要一些自定义事件之类的动画能用 CSS3 动画实现就使用 CSS3,否则就使用 JS 实现,一般来说,使用 CSS3 可以得到更好的浏览器优化,比如说开启 3D 加速等。但是使用 JS 来写动画的话,定制化程度会更高,取决于具体的业务场景。

这里我们先来封装一个基础的通用动画函数,它提供了自定义绘制和自定义动画过渡的能力。

封装完成之后我们就以这个函数来实现一些业务需求中很常见的功能

js
interface IAnimateOptions {
  /** 动画运行的总毫秒数 */
  duration?: number;
  /** 动画执行结束 */
  onEnd?: () => void;
  /** 计算动画进度的函数。获取从 0 到 1 的小数时间。可以理解为坐标系中的 x 轴 */
  timing: (timeFraction: number) => number;
  /** 绘制动画的函数 */
  onDraw: (progress: number) => void;
}

export function animate({
  timing,
  duration = 1000,
  onDraw,
  onEnd,
}: IAnimateOptions) {
  const start = performance.now();

  /** 确保一个值在给定的最小值和最大值之间,如果超出范围,则返回最小值或最大值 */
  const minMax = (val: number, min: number, max: number) =>
    Math.min(Math.max(val, min), max);

  requestAnimationFrame(function animate(time: number) {
    // timeFraction 从 0 增加到 1
    const timeFraction = minMax((time - start) / duration, 0, 1);
    // 计算当前动画状态
    const progress = timing(timeFraction);

    onDraw(progress);

    if (timeFraction < 1) {
      requestAnimationFrame(animate);
    } else {
      onEnd?.();
    }
  });
}

水平移动

首先我们来看下线性执行的一个效果,可以看到动画就是一个线性的移动效果

ts
const Bezier = () => {
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    animate({
      duration: 3000,
      timing: (t) => t,
      onDraw: (progress) => {
        containerRef.current!.style.transform = `translateX(${
          progress * 300
        }px)`;
      },
    });
  }, []);

  return <div ref={containerRef} className={styles.container}></div>;
};

export default Bezier;

line

然后接下来我们换一个具有缓动的函数,到这个网站上去找一个,试试这个

image-20230917132619559

然后把它的函数复制下来

ts
function easeOutBounce(t: number): number {
  const n1 = 7.5625;
  const d1 = 2.75;

  if (t < 1 / d1) {
    return n1 * t * t;
  } else if (t < 2 / d1) {
    return n1 * (t -= 1.5 / d1) * t + 0.75;
  } else if (t < 2.5 / d1) {
    return n1 * (t -= 2.25 / d1) * t + 0.9375;
  } else {
    return n1 * (t -= 2.625 / d1) * t + 0.984375;
  }
}

最后把这函数放到我们的 timing 上去

ts
animate({
  duration: 3000,
  timing: easeOutBounce,
  onDraw: (progress) => {
    containerRef.current!.style.transform = `translateX(${progress * 300}px)`;
  },
});

最后来看下效果,嗯~ ,不错,有内味了。

ease

数字滚动

接下来我们再来点稍微复杂点的,比如让数字滚动起来。

首先我们来看下线性执行的一个效果,可以看到动画没任何的过渡效果

33333

js
const Bezier = () => {
  const numberRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const startVal = 0;
    const endVal = 9999.99;

    const diff = endVal - startVal;

    animate({
      duration: 3000,
      // 线性渐变
      timing: (t) => t,
      onDraw: (progress) => {
        const currentValue = startVal + diff * progress;
        numberRef.current!.textContent = currentValue.toFixed(2);
      },
      onEnd: () => {
        console.log("end");
      },
    });
  }, []);

  return <div ref={numberRef}></div>;
};

接下来我们尝试着给这个数字滚动加一点过渡效果,那么我们就要去改一下贝塞尔曲线函数了,我们去这个网站里面去搞一个带结束平滑过渡的贝塞尔曲线来,接下来我们把它里面的函数搞出来看看效果

image-20230711230223216

js
animate({
  duration: 5000,
  timing: (t) => 1 - Math.pow(1 - t, 5),
});

从这个执行效果可以看到确实是和上面那条贝塞尔曲线相符,后面带上了一个缓慢结束的拖尾效果

hahahahaha

其它更多的贝塞尔曲线效果就交由大家自己去尝试了,接下来我们再看另一个常见的业务场景

轮盘

lotteryWheel

jsx
const Bezier = () => {
  const lotteryWheelRef = useRef<HTMLDivElement>(null);
  // 分成 10 个格子
  const itemNum = 10;

  useEffect(() => {
    // 8 秒转 8 圈
    animate({
      duration: 8000,
      // 找一个平滑结束的贝塞尔曲线函数
      timing: (t) => Math.sqrt(1 - Math.pow(t - 1, 2)),
      onDraw: (progress) => {
        lotteryWheelRef.current!.style.transform = `rotate(${
          progress * 360 * 8
        }deg)`;
      },
    });
  }, []);

  return (
    <div ref={lotteryWheelRef} className={styles.lotteryWheel}>
      {new Array(itemNum).fill({}).map((_, index) => {
        return (
          <div
            key={index}
            className={styles.line}
            style={{
              transform: `rotate(${(360 / itemNum) * index}deg)`,
            }}
          ></div>
        );
      })}
    </div>
  );
};

// 样式
.lotteryWheel {
  width: 100px;
  height: 100px;
  border-radius: 50%;
  background: pink;
  border: 1px solid #666;
  position: relative;
}

.line {
  position: absolute;
  width: 1px;
  height: 50px;
  left: 50%;
  transform: translateX(-50%);
  transform-origin: 0 50px;
  top: 0;
  background: #666;
}

然后我们再试试这个贝塞尔曲线

image-20230917131226335

它的函数是这样子的:

变量 t 表示 0(动画开始)到 1(动画结束)范围内的值

typescript
function easeOutElastic(t: number): number {
  const c4 = (2 * Math.PI) / 3;

  return t === 0
    ? 0
    : t === 1
    ? 1
    : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1;
}

然后我们再放到转盘上,就得到了下面这样的一个效果,这就是贝塞尔曲线的一个巧妙之处

js
animate({
    duration: 10000,
    timing: easeOutElastic,
    onDraw: (progress) => {
        lotteryWheelRef.current!.style.transform = `rotate(${
        progress * 360 * 8
    }deg)`;
    onEnd: () => { console.log("end") }
    },
});

scroll

小结

还有很多具有复杂动画的业务场景,大家可以自行去尝试,这里只是进行一下抛砖引玉,另外一般在实际开发中遇到复杂的动画时,我们都会去使用一些动画库或者动画引擎来提升开发效率,接下来我们再来看下有哪些常用的动画库~

常用动画库

framer-motion

目前有 20k star,周下载量在 200 W 左右,Gzip 之后大小 41.6kb(有一点点大),更适用于 PC 端一些。这是一个 React 动画库,同时支持使用组件或者 hooks 的形式来进行调用,功能还是很强大的,还支持服务端渲染,自带支持各种事件,比如说进入可见区域、鼠标 hover、拖拽、动画结束等。它还可以结合 React-Router 进行使用,然后实现页面间的切换动画。

它使用的时候需要用 motion 这个标签,这个标签里包含了我们常用的比如 div、ul、li 、button、img 等,提供了很多额外的动画能力,它能实现帧动画、视差滚动、弹簧特性等

来看一个它的基本使用例子,代码示意也很简单,它还给我们自带了缓动效果,看起来很不错。

movv

tsx
import * as React from 'react';
import { useRef } from 'react';
import { motion } from 'framer-motion';

export const Example = () => {
  const constraintsRef = useRef(null);

  return (
    <>
      <motion.div className="drag-area" ref={constraintsRef} />
      <motion.div drag dragConstraints={constraintsRef} />
    </>
  );
};

它的文档也是比较友好的,更多的示例大家可以参考官方文档。

react-spring

到目前为止已经有 26.3k star 了,周下载量在 70~80 W, Gzip 之后只有 19.4 kb。看它名字就知道,这也是一个 React 动画库,它主要也是通过组件或 hooks 的方式来使用,不过感觉编程体验没有 framer-motion 那么好,特性上也没有 framer-motion 那么多。

来看个它的使用例子:

1111111

tsx
import React, { ReactNode } from 'react';
import { useSpring, animated } from '@react-spring/web';
import { useDrag } from 'react-use-gesture';

import styles from './styles.module.css';

const left = {
  bg: `linear-gradient(120deg, #f093fb 0%, #f5576c 100%)`,
  justifySelf: 'end',
};
const right = {
  bg: `linear-gradient(120deg, #96fbc4 0%, #f9f586 100%)`,
  justifySelf: 'start',
};

const Slider = ({ children }: { children: ReactNode }) => {
  const [{ x, bg, scale, justifySelf }, api] = useSpring(() => ({
    x: 0,
    scale: 1,
    ...left,
  }));
  const bind = useDrag(({ active, movement: [x] }) =>
    api.start({
      x: active ? x : 0,
      scale: active ? 1.1 : 1,
      ...(x < 0 ? left : right),
      immediate: (name) => active && name === 'x',
    })
  );

  const avSize = x.to({
    map: Math.abs,
    range: [50, 300],
    output: [0.5, 1],
    extrapolate: 'clamp',
  });

  return (
    <animated.div
      {...bind()}
      className={styles.item}
      style={{ background: bg }}
    >
      <animated.div
        className={styles.av}
        style={{ scale: avSize, justifySelf }}
      />
      <animated.div className={styles.fg} style={{ x, scale }}>
        {children}
      </animated.div>
    </animated.div>
  );
};

export default function App() {
  return (
    <div className={styles.container}>
      <Slider>Slide.</Slider>
    </div>
  );
}

官方文档写得还行,但是感觉不如 framer-motion。

GSAP

到目前为止已经有 17.1k star 了,周下载量在 40 W 左右,Gzip 之后包大小是 26.3 kb。这是一个不和任何框架耦合的 JavaScript 动画库。这个库的能力虽然挺强的,但是文档写得一般般,介绍的文字一大堆,放一大堆教学视频(个人不爱看),而且还有一股浓浓的商业味的感觉,个人不太推荐使用。

一个基本的例子:

js
<div class="container">
  <div class="box purple"></div>
  <div class="nav light">
    <button id="play">play()</button>
    <button id="pause">pause()</button>
    <button id="resume">resume()</button>
    <button id="reverse">reverse()</button>
    <button id="restart">restart()</button>
  </div>
</div>;

let nav = document.querySelector('.nav');

let tween = gsap.to('.purple', {
  duration: 4,
  x: () => nav.offsetWidth,
  xPercent: -100,
  rotation: 360,
  ease: 'none',
  paused: true,
});

document.querySelector('#play').onclick = () => tween.play();
document.querySelector('#pause').onclick = () => tween.pause();
document.querySelector('#resume').onclick = () => tween.resume();
document.querySelector('#reverse').onclick = () => tween.reverse();
document.querySelector('#restart').onclick = () => tween.restart();

gasp

animejs

到目前为止已经有 47k star 了,周下载量在 15 W 左右,Gzip 之后整包大小只有 6.9kb。这个是个轻量级的纯 JavaScript 动画库,不和任何的框架耦合,支持动画的串行与并行,并且各种事件都可以很方便的进行处理,代码可以进行链式调用,PC 端和移动端都适用,但是有一些常用的能力结合框架使用时可能还得自己手动封装一下。

下面是一个基本的串行动画的使用例子:

animejs

js
anime
  .timeline({
    easing: 'easeOutExpo',
    duration: 750,
  })
  .add({
    targets: '.basic-timeline-demo .el.square',
    translateX: 250,
  })
  .add({
    targets: '.basic-timeline-demo .el.circle',
    translateX: 250,
  })
  .add({
    targets: '.basic-timeline-demo .el.triangle',
    translateX: 250,
  });

然后再来看下并行动画的使用例子

rott

js
anime({
  targets: '.specific-prop-params-demo .el',
  translateX: {
    value: 250,
    duration: 800,
  },
  rotate: {
    value: 360,
    duration: 1800,
    easing: 'easeInOutSine',
  },
  scale: {
    value: 2,
    duration: 1600,
    delay: 800,
    easing: 'easeInOutQuart',
  },
  delay: 250,
});

除此之外,它还支持各种其它的特性,比如说循环播放,无限播放等。

更多的例子可以参考官方文档,文档很友好,总的来说,这个库很好用,推荐使用。

总结

以上就是关于贝塞尔曲线的全部内容,另外前端动画也涉及到 2D 和 3D,如果是 3D 的话,可以去了解下 Three.js/Babylon.js 这两个库。另外在前端动画里还有很多特性,比如弹簧动画、物理碰撞等等,这都是可以深入去研究的领域,毕竟图形学也是属于前端的一个细分领域,光是动画这一块都能够研究上很久,我这篇文章主要是想起到一个抛砖引玉的部分,毕竟我也还在不断学习和了解当中,希望这篇文章能够帮助到大家。

每天进步一丢丢