Published on

Web 动画

Authors

前言

刚从业那会,前司有一个叫 jsdo.it 的服务(2010 年上线,2019 年关闭服务),上面有非常多狂拽酷炫的 HTML5 可交互的 Demo,入职的学习任务就是在上面做的。当时印象最深的,就是站点上各种各样的 2D、3D 动画。“原来在网页上也能做到这么酷的效果!”。只可惜,在前司后来实际项目里做过最复杂的动画也不过是用 jQuery 去做 fadeIn / fadeOut 而已,毫无成就感,甚至有一点想笑。

可以从这个遗址一睹 jsdo.it 的风采:https://cx20.github.io/jsdo.it-archives/

再次和动画结缘,是很多年之后。开始做可视化工具库的时候,上层应用和框架要求底层渲染引擎自带渲染属性的动画管理;再后来是在支付宝业务线里做大型活动的推广页面以及年账单等 toC 的定期活动页面,有大量复杂的、定制的动画效果。经历过这些项目后,才大致把“如何在 Web 前端做动画效果”这件事差不多搞明白一些皮毛。本文会把前端常见常用的动画做一个大致的介绍,并且从底层实现一个动画管理库,尝试讲清楚 Web 前端动画这件事。

Web 前端动画概述

Web 前端动画是在 Web 应用或网站上的用户界面中,通过各种技术手段实现的动态效果。其目的是增强用户体验,提高用户界面交互性,吸引用户注意力,帮助传达信息等。Web 动画主要通过对一个或多个 UI 元素的位置、形状、透明度、颜色、大小等视觉元素的变化来实现。

从技术实现手段上看,Web 前端动画大致有以下几种代表性的实现类型:

DOM 动画

CSS

CSS 动画是目前我们能接触到的最简单、实现成本最低、在部分场景下性能最好的方案。举一个比较简单的例子。首先是 transition:

hover fadeIn

<div class="hid-box">
    <h1>CSS3 slide up</h1>
    <p>This is a quick demo of slide-up effect using CSS animation when hover the box. No JS required!</p>
  </div>
</div>
h1 {
  margin: 0
}
.box {
  height: 200px;
  width: 300px;
  overflow: hidden;
  border: 1px solid red;
  background: #ff0;
}
.hid-box {
  top: 100%;
  position: relative;
  transition: all .3s ease-out;
  background: #428bca;
  height: 100%;
}

.box:hover > .hid-box{
  top: 0;
}

然后是一个 animation 属性的定义:

gangham walk around

div {
  width: 225px; height: 400px;
  background-image: url(/static/images/gangham-sprite.png);
  animation: gangham 4s steps(23,start) infinite,
    movearound 12s steps(69,end) infinite alternate 44ms;
  animation-direction: normal, alternate;
}

@keyframes gangham {
  0% {background-position: 0 0}
  100% {background-position: -5175px 0}
}
@keyframes movearound {
  0% {transform: translatex(0)}
  100% {transform: translatex(600px);}
}

transition 和 animation 属性的区别,可以从其参数比较直观地区分:

transition: /* property name | duration | easing function | delay */
animation: /* @keyframes duration | easing-function | delay |
iteration-count | direction | fill-mode | play-state | name */

transition 指定 CSS 属性、属性从初始值到终止值变更的持续时间、插值函数、延迟启动时间,实现一个或者多个属性值的缓动过程。因此 transition 适合用于元素属性变化时的缓慢过渡。

而 animation,通过定义关键帧的属性、整体动画持续时间、插值函数、延迟启动时间、动画迭代次数、方向、动画执行前后应用的样式、初始播放状态、动画名称,来定义从关键帧起始状态到终止状态的动画迭代过程。因为可以手动制定多帧状态,animation 显然可以组织更复杂的、多步骤的动画。

需要注意的是,CSS 动画并不总是性能好的,但且仅当底层使用了 GPU 硬件加速,才能实现高性能的动画。这里性能优化的小技巧包括:

‒ 使用 transform(譬如 translate3d)和 opacity 属性,可以手动触发 GPU 加速 ‒ 尽量不要改变元素本身会影响布局和重绘的属性,如 width/height/top/left/box-shadow/border-radius ‒ animation 编写的时候,可以尽可能详细编写 keyframes 或者指定 steps 属性

小提示:定义了 CSS 动画之后,其实可以在 JavaScript 中监听相关事件,可供 JavaScript 中使用:

  • animationstart
  • animationend
  • animationinteration

JavaScript

JavaScript 去操作 DOM 动画是更为常见、更灵活自由的。结合底层 requestAnimationFrame 函数,可以在最适合的时机推进动画执行。JavaScript 操作 DOM 做动画基本能覆盖所有 CSS 动画的场景,但如果一个动画比较简单,可以使用 CSS 动画的话,应尽可能使用 CSS 动画来完成,以尽可能利用浏览器自身的能力,以获得更好的性能和可维护性。

上述 CSS Animation(Gangham 背景图片切换 + 运动)的 JavaScript 版本实现如下:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>JavaScript Animation Example</title>
    <style>
        body {
            height: 100vh;
            margin: 0;
            background-color: #f0f0f0;
        }
        div {
            width: 225px;
            height: 400px;
            background-image: url(/static/images/gangham-sprite.png);
        }
    </style>
</head>
<body>
    <div id="animatedDiv"></div>
    <script>
        const div = document.getElementById('animatedDiv');
        const totalFrames = 22;
        const frameWidth = 225; // each frame's width
        const spriteWidthTotal = 4950; // total width of the sprite sheet
        const animationDuration = 4000; // duration in ms for gangham
        const moveDuration = 12000; // duration in ms for moveAround
        const moveDistance = 600; // distance to move horizontally

        let frame = 0;
        let positionX = 0;
        let direction = 1;

        function animateSprite() {
            // Animate the sprite
            frame = (frame + 1) % totalFrames;
            div.style.backgroundPosition = `-${frame * frameWidth}px 0`;
        }

        function animateMovement() {
            // Animate the div movement
            positionX += direction * (moveDistance / (moveDuration / (1000 / 60)));
            if (positionX > moveDistance || positionX < 0) {
                direction *= -1; // reverse direction
            }
            div.style.transform = `translateX(${positionX}px)`;
            requestAnimationFrame(animateMovement);
        }

        // Start the sprite animation
        setInterval(animateSprite, animationDuration / totalFrames);

        // Start the movement animation
        animateMovement();
    </script>
</body>
</html>

Gangham 这个动画是之前大火的《江南 Style》歌舞人物动作的卡通版,由两个动画组成,一个是元素背景在一个 Sprite 图不同帧之间反复切换;另一个是卡通人物左右平移往复运动。上述实现中,Sprite 图切换不同帧使用了 setInterval 来处理,每次调用 frame+1,换下一帧背景图;而左右移动使用了 requestAnimationFrame,控制 translateX 的偏移值,超出了画面(大于总宽度或者小于 0)及时向反方向调整。

可以看到,JavaScript DOM 动画和 CSS 动画明显的区别就是,CSS 是声明式的:给浏览器讲清楚,我要从哪些个状态变到哪些个状态,中间可能又经过哪些个状态,要不要延时执行、反复执行等等。而 JavaScript 则要理解动画本身,呈现出来的动效需要组合哪些 DOM 属性或者 DOM Style 属性,以怎样的变化规律去“动起来”。显然,JavaScript 做 DOM 动画,事无巨细都需要自己操作,自由度更大了,但“责任”也更大了。

绘图接口的动画

SVG

SVG 内置了一个 <animate> 标签,专用于某个节点元素的属性动画。下面是 GPT 给出的“实现红绿黄灯交替效果”的实例:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>SVG Traffic Light with Animation</title>
    <style>
        body {
            display: flex;
            justify-content: center;
            align-items: center;
            height: 100vh;
            margin: 0;
            background-color: #f0f0f0;
        }
    </style>
</head>
<body>
    <svg width="100" height="300" viewBox="0 0 100 300">
        <!-- Traffic light background -->
        <rect x="20" y="20" rx="10" ry="10" width="60" height="260" fill="black" />
        
        <!-- Red light -->
        <circle cx="50" cy="70" r="20" fill="red">
            <animate attributeName="opacity" values="1;0;0;0;1" keyTimes="0;0.4;0.5;0.9;1" dur="10s" repeatCount="indefinite" />
        </circle>
        
        <!-- Yellow light -->
        <circle cx="50" cy="150" r="20" fill="yellow">
            <animate attributeName="opacity" values="0;0;1;0;0;1;0;0" keyTimes="0;0.4;0.45;0.5;0.55;0.6;0.65;1" dur="10s" repeatCount="indefinite" />
        </circle>
        
        <!-- Green light -->
        <circle cx="50" cy="230" r="20" fill="green">
            <animate attributeName="opacity" values="0;1;0;0;0" keyTimes="0;0.4;0.5;0.9;1" dur="10s" repeatCount="indefinite" />
        </circle>
    </svg>
</body>
</html>

整体来看,<animate> 标签和 CSS 的声明式动画定义类似,它一个标签对应一个元素属性的变化控制,可以多个标签共用,从而实现较为复杂的动画。

另一个用其他技术难以替代的 SVG 动画效果是 path 路径运动动画。采用 <animateMotion> 标签,可以实现元素沿着 path 运动的动画。譬如下面蓝色光点沿着心形路径运动的动画:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>SVG Path Animation Example</title>
    <style>
        body {
            display: flex;
            justify-content: center;
            align-items: center;
            height: 100vh;
            margin: 0;
            background-color: #f0f0f0;
        }
    </style>
</head>
<body>
    <svg width="500" height="500" viewBox="0 0 500 500">
        <!-- Define the path -->
        <path id="heartPath" fill="transparent" stroke="red" stroke-width="2"
              d="M 250,150
                 C 150,0, 0,100, 250,400
                 C 500,100, 350,0, 250,150
                 Z" />
        
        <!-- Circle that will move along the path -->
        <circle r="10" fill="blue">
            <animateMotion repeatCount="indefinite" dur="5s">
                <mpath href="#heartPath" />
            </animateMotion>
        </circle>
    </svg>
</body>
</html>

结合两者,以及 dasharray、dashoffset 等属性,可以做出 SVG 动画的经典光点路径动画:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>SVG Path Animation with Dasharray</title>
    <style>
        body {
            display: flex;
            justify-content: center;
            align-items: center;
            height: 100vh;
            margin: 0;
            background-color: #f0f0f0;
        }
        svg {
            width: 80%;
            height: 80%;
        }
    </style>
</head>
<body>
    <svg viewBox="0 0 1000 1000">
        <!-- Define the path -->
        <path id="motionPath" d="M100,500 Q500,100 900,500 T1700,500" fill="transparent" stroke="gray" stroke-width="2" />

        <!-- Moving dot along the path -->
        <circle id="movingDot" r="10" fill="red">
            <animateMotion repeatCount="indefinite" dur="5s">
                <mpath href="#motionPath" />
            </animateMotion>
        </circle>

        <!-- Light effect along the path -->
        <path id="lightPath" d="M100,500 Q500,100 900,500 T1700,500" fill="transparent" stroke="yellow" stroke-width="4" stroke-dasharray="15, 30">
            <animate attributeName="stroke-dashoffset" from="0" to="-180" dur="1s" repeatCount="indefinite" />
        </path>
    </svg>
</body>
</html>

这个动画如果要使用 JavaScript + Canvas 之类的技术栈来做到的话,需要自己去计算贝塞尔曲线切线方向,需要精细控制元素旋转状态和运动等,无论是复杂度还是性能,都很难达到 SVG 原生的效果。

Canvas2D

Canvas 上下文其实没有动画相关接口。但因为 Canvas 画布每一个像素点都可以自由控制,因此结合 JavaScript 可以做出更为复杂华丽的动画效果。事实上,要做到游戏级别的实时渲染和动画效果,也只有使用较为底层的 Canvas2D 或者 WebGL 接口才有可能实现。但其复杂度也是最高的。

相比 WebGL 实现高性能、大规模的动画效果,Canvas2D 在性能、渲染效果、代码可维护性上有一定的均衡效果,这使得它在做特定类型动画时是不可替代的。下面是一个使用 Canvas2D 画布用 JavaScript 实现的烟花模拟效果。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Canvas Fireworks</title>
  <style>
    body {
      margin: 0;
      overflow: hidden;
      background: black;
    }
    canvas {
      display: block;
    }
  </style>
</head>

<body>
  <canvas id="canvas"></canvas>
  <script>
    const canvas = document.getElementById('canvas');
    const ctx = canvas.getContext('2d');
    canvas.width = window.innerWidth;
    canvas.height = window.innerHeight;

    class Firework {
      constructor(x, y, targetX, targetY) {
        this.x = x;
        this.y = y;
        this.targetX = targetX;
        this.targetY = targetY;
        this.distanceToTarget = Math.sqrt((targetX - x) * (targetX - x) + (targetY - y) * (targetY - y));
        this.distanceTraveled = 0;
        this.coordinates = [];
        this.coordinateCount = 3;
        while (this.coordinateCount--) {
          this.coordinates.push([this.x, this.y]);
        }
        this.angle = Math.atan2(targetY - y, targetX - x);
        this.speed = 2;
        this.acceleration = 1.05;
        this.brightness = Math.random() * 50 + 50;
        this.targetRadius = 1;
      }

      update(index) {
        this.coordinates.pop();
        this.coordinates.unshift([this.x, this.y]);

        if (this.targetRadius < 8) {
          this.targetRadius += 0.3;
        } else {
          this.targetRadius = 1;
        }

        this.speed *= this.acceleration;

        const vx = Math.cos(this.angle) * this.speed;
        const vy = Math.sin(this.angle) * this.speed;

        this.distanceTraveled = Math.sqrt((this.x + vx - this.x) * (this.x + vx - this.x) + (this.y + vy - this.y) * (this.y + vy - this.y));

        if (this.distanceTraveled >= this.distanceToTarget) {
          fireworks.splice(index, 1);
          createParticles(this.targetX, this.targetY);
        } else {
          this.x += vx;
          this.y += vy;
        }
      }

      draw() {
        ctx.beginPath();
        ctx.moveTo(this.coordinates[this.coordinates.length - 1][0], this.coordinates[this.coordinates.length - 1][1]);
        ctx.lineTo(this.x, this.y);
        ctx.strokeStyle = `hsl(${hue}, 100%, ${this.brightness}%)`;
        ctx.stroke();

        ctx.beginPath();
        ctx.arc(this.targetX, this.targetY, this.targetRadius, 0, Math.PI * 2);
        ctx.stroke();
      }
    }

    class Particle {
      constructor(x, y) {
        this.x = x;
        this.y = y;
        this.coordinates = [];
        this.coordinateCount = 5;
        while (this.coordinateCount--) {
          this.coordinates.push([this.x, this.y]);
        }
        this.angle = Math.random() * Math.PI * 2;
        this.speed = Math.random() * 10 + 1;
        this.friction = 0.95;
        this.gravity = 1;
        this.hue = Math.random() * 360;
        this.brightness = Math.random() * 80 + 20;
        this.alpha = 1;
        this.decay = Math.random() * 0.03 + 0.01;
      }

      update(index) {
        this.coordinates.pop();
        this.coordinates.unshift([this.x, this.y]);

        this.speed *= this.friction;
        this.x += Math.cos(this.angle) * this.speed;
        this.y += Math.sin(this.angle) * this.speed + this.gravity;
        this.alpha -= this.decay;

        if (this.alpha <= this.decay) {
          particles.splice(index, 1);
        }
      }

      draw() {
        ctx.beginPath();
        ctx.moveTo(this.coordinates[this.coordinates.length - 1][0], this.coordinates[this.coordinates.length - 1][1]);
        ctx.lineTo(this.x, this.y);
        ctx.strokeStyle = `hsla(${this.hue}, 100%, ${this.brightness}%, ${this.alpha})`;
        ctx.stroke();
      }
    }

    let fireworks = [];
    let particles = [];
    let hue = 120;

    function createParticles(x, y) {
      let particleCount = 30;
      while (particleCount--) {
        particles.push(new Particle(x, y));
      }
    }

    function loop() {
      requestAnimationFrame(loop);

      ctx.globalCompositeOperation = 'destination-out';
      ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
      ctx.fillRect(0, 0, canvas.width, canvas.height);
      ctx.globalCompositeOperation = 'lighter';

      let i = fireworks.length;
      while (i--) {
        fireworks[i].draw();
        fireworks[i].update(i);
      }

      let j = particles.length;
      while (j--) {
        particles[j].draw();
        particles[j].update(j);
      }

      if (Math.random() < 0.05) {
        fireworks.push(new Firework(canvas.width / 2, canvas.height, Math.random() * canvas.width, Math.random() * canvas.height / 2));
      }
    }

    canvas.addEventListener('click', (e) => {
      const x = canvas.width / 2;
      const y = canvas.height;
      const targetX = e.clientX;
      const targetY = e.clientY;
      fireworks.push(new Firework(x, y, targetX, targetY));
    });

    loop();
  </script>
</body>
</html>

代码短小精悍,定义烟花类和粒子类,烟花管理起始角度和发射速度,确定最终位置后在该位置绘制烟花爆炸散开的粒子效果(如果我带着 ChatGPT 穿越回到刚进前司那一年,那我一定是 jsdo.it 上的王者)。

WebGL

WebGL 如何做动画,这个话题就复杂得多了。先看两个 demo:

starfall

https://codepen.io/shubniggurath/pen/QVrJjM

这是典型的 WebGL 中实现渲染动画的例子,片段着色器中根据 UV 坐标和随机缩放比例生成星星;然后通过柔化函数模拟星星光晕;并且定义多层星星设置单独的旋转速度和缩放速度,模拟更复杂的深度效果;两个渲染目标做后处理,把上一帧结果作为当前帧的输入,模拟拖尾和模糊的效果,看起来是连贯的“动画”。这个 Demo 是充分利用 WebGL 渲染管线和视觉暂留现象实现的动画。

flying carrot

https://codepen.io/noeldelgado/pen/PxwKPW

萝卜飞行器这个 demo,则是很类同 JavaScript DOM 动画的形式。只不过在 JavaScript 管理动画的单帧中所做的事情不是操作 DOM 元素属性,而是切换 3D 模型贴图、对 Three.js 场景对象做旋转、往复运动等等。代码结构很简单,借用 GSAP 的 TweenMax 库,在相关类实例的 animate 函数中去更新模型和贴图状态。譬如云的水平、垂直方向运动;飞行器和兔子飞行员模型各部分的运动等等。

各类绘图库/框架辅助库/专业动画库等

PixiJS

https://pixijs.com/8.x/examples/sprite/animated-sprite-jet

PixiJS 可以认为是一个采用 GL 接口实现的 2D 渲染引擎,它主要为了让 Web 环境下的 2D 交互应用能尽可能利用硬件加速的能力,使用 WebGL 渲染 2D 内容。PixiJS 可以用来开发高质量的 2D 游戏,类似设计思路的引擎,譬如 Cocos2d-JS,通常都会维护自己实例生命周期中的“ticker”,可以在一个 draw call 中做一些场景视图更新等操作,这也是这类引擎实现底层动画的共性。

其他有一些渲染管线设计思路雷同,但尝试兼容更多渲染端(SVG、Canvas、WebGL、VML)的库(Two.js、CreateJS 等)的实现,往往在性能和可交互性上和 PixiJS、Cocos2D-JS 差距较远,不必要花太大精力去熟悉研究。从成熟度、社区生态、生产环境的普及落地等方面考虑,PixiJS 目前都是独一档的。

vs p5js

相对而言,p5 设计的初衷更面向“processing”的用户群,偏科研、教育、艺术表达等。生产环境使用还是更推荐使用 PixiJS。这几年很多大厂在做线上营销相关互动内容时大量采用 PixiJS,生产环境验证也很充分。

D3

SVG 之上的绘图封装,包括在数据可视化领域的工具库里,D3 是绝对无法绕过去的。它完全没有所谓“render”的概念,仅仅是“数据驱动文档模型”,对 SVG 的抽象封装仅限于增加选中、属性维护等辅助性的 API,对 Canvas 的支持更是一句框架内的 Context2D 代码都欠奉,完全靠用户自己组织渲染。

但就这么个库,在学术界,尤其是偏图可视化领域等,其地位至今无人可以撼动。极致简单的 API,始终一致的设计理念,整个库看起来颇有点道法自然,返璞归真的美感。它的动画封装也简单而强大:

‒ 简单动画类同 CSS,transition 接口直接定义当前属性状态到终止状态的动画 ‒ timer 接口提供时间轴的管理能力,仅提供每一帧执行某个逻辑的能力,外加重跑、停止等辅助函数 ‒ ease 接口提供丰富的插值函数

要使用 D3 完成一个复杂动画,代码量会相对比较高。但掌握了这些简单而极致的接口之后,你对动画的理解会更深,做出来的动画效果上限也更高。下面是一个使用到 D3 动画相关的 API 的 Demo:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>D3 Bubble Chart Animation Example</title>
    <style>
        body { font-family: sans-serif; }
        .bubble { fill: steelblue; opacity: 0.7; stroke: #fff; stroke-width: 1px; }
    </style>
</head>
<body>
    <svg width="800" height="600"></svg>
    <script src="https://d3js.org/d3.v7.min.js"></script>
    <script>
        const width = 800;
        const height = 600;
        const svg = d3.select("svg")
            .attr("width", width)
            .attr("height", height);

        const numBubbles = 50;
        let data = d3.range(numBubbles).map(() => ({
            x: Math.random() * width,
            y: Math.random() * height,
            r: Math.random() * 30 + 10
        }));

        const xScale = d3.scaleLinear().domain([0, width]).range([0, width]);
        const yScale = d3.scaleLinear().domain([0, height]).range([0, height]);
        const rScale = d3.scaleSqrt().domain([10, 40]).range([10, 40]);

        const bubbles = svg.selectAll(".bubble")
            .data(data)
            .enter().append("circle")
            .attr("class", "bubble")
            .attr("cx", d => xScale(d.x))
            .attr("cy", d => yScale(d.y))
            .attr("r", d => rScale(d.r));

        function updateData() {
            data.forEach(d => {
                d.x = Math.random() * width;
                d.y = Math.random() * height;
                d.r = Math.random() * 30 + 10;
            });
        }

        function animateBubbles() {
            updateData();
            bubbles.data(data)
                .transition()
                .duration(2000)
                .ease(d3.easeElasticOut)
                .attr("cx", d => xScale(d.x))
                .attr("cy", d => yScale(d.y))
                .attr("r", d => rScale(d.r));

            d3.timeout(animateBubbles, 3000);
        }

        animateBubbles();
    </script>
</body>
</html>

Snap.svg

http://snapsvg.io/demos/#game

Snap.svg 是专门处理 SVG 文档的库,也可以用来制作基于 SVG 的交互动画。Snap.svg 的作者是前一代 SVG 操作库 Raphael 的作者,在 Snap.svg 中他抛弃了对 IE6 等环境的支持,新增了蒙版、裁切、图案、渐变、图层分组等功能,并借鉴了 jQuery 的 API 设计,使得 Snap.svg 也可以对现有的 SVG 文档进行维护更新等。

Snap.svg 中的动画实现非常简单,类似 jQuery,定义一个 Animation 类并用其维护 SVG 属性变化和相关插值函数等。目前这种编码方式已经和前端主流范式差异比较大,并且 Snap.svg 对动画的支持也不太充分和成熟,社区生态资源也相对差一些,不太建议在生产项目使用。

React-Motion

https://chenglou.me/react-motion/demos/demo4-photo-gallery/

React-Motion 适合用来开发“开箱即用”的 React 技术栈动画,我们不必详细理解要做动画的两个 React 组件状态之间的变化逻辑,显式交给 React-Motion 即可。通常用来做效果非常平滑的状态过渡、瀑布流动画等等。

GSAP

GSAP 基本上可以算是最专业的 JavaScript 生态动画管理库。

https://labs.noomoagency.com/

这个例子,就是通过 GSAP 提供的 ScrollTrigger 等 API,结合 Timeline 管理实现的全站交互和动画效果。作为一个前端动画库,GSAP 实现了生态插件收费,实现了按席位按项目收费,关键是还能活下来...足见其专业度。

它是与底层渲染实现无关的,基础功能就是 Tween、Timeline、Easing function 这些,外加 CSS 相关的属性维护等。在此基础上增加了很多 helper 和插件体系,甚至也有和生态的集成(譬如 React 的 useGSAP 接口)。非常全面,性能表现也很卓绝,真可以“Animate Anything”。

Lottie (Bodymovin)

https://airbnb.io/lottie/#/ 这个库的思路是打通动画的生产端,取 Adobe After Effects 制作导出的动画,让其可以在跨端的环境下执行。因此它也提供多端(不只是 JavaScript)的运行环境。这件事核心在于,基于 Bodymovin 插件,将 After Effects 动画用一个 JSON 文件表达清楚了。基于此,就可以实现多端的渲染和动画还原。大厂里基于 PixiJS 做 Lottie 文件渲染的项目比比皆是,做互动、做营销的前端团队多多少少都在这件事上薅过 KPI 的。

Skia(CanvasKit)

所有 Web 环境绘图相关的话题,我认为最终都绕不开 Skia。因为它是 Chrome 浏览器的图形渲染引擎,我们看到的网页,经过 DOM 解析、CSS 布局处理之后,最终就是用 Skia 绘制出来的。包括我们用的 Android 操作系统的 UI、图形内容,也都是使用 Skia 绘制的。最近刚被 Google 抛弃的 Flutter,其底层也是使用 Skia 做跨平台应用的 UI 绘制。

Skia 引擎也导出了一个 Skottie 模块可以用于 Lottie 动画的渲染。

https://skia.org/docs/user/modules/skottie/

https://skottie.skia.org/00e850cdbed7304985eaefe98a4e8a9c?h=800&w=800

实现一个 Web 前端动画管理库

这里,我们特指动画的控制层,包括 ticks 控制,动画播放暂停取消,插值函数等。而不涉及到特定技术栈的具体优化,譬如怎么做 Path 路径动画、粒子动画等。整体思路和 GASP 类似,但简化了很多,只保留最基础的 Timeline、Tween、EasingFunction 模块。

实体设计和类图

contains
uses
Tween
+string id
+string property
+number startValue
+number endValue
+number duration
+number delay
+string easing
+start()
+pause()
+stop()
Timeline
+string id
+string name
+number startTime
+number endTime
+addTween(Tween tween)
+removeTween(Tween tween)
+start()
+pause()
+stop()
EasingFunction
+string name
+string type
+function easingFunction(t: number) : number

代码实现

    // Easing functions
    const linear = (t) => t;
    const easeInQuad = (t) => t * t;
    const easeOutQuad = (t) => t * (2 - t);
    const easeInOutQuad = (t) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t);
    const easeInCubic = (t) => t * t * t;
    const easeOutCubic = (t) => (--t) * t * t + 1;
    const easeInOutCubic = (t) => (t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1);

    // Tween class
    class Tween {
      constructor(properties, { duration, easing = linear, onUpdate, onComplete, loop = false }) {
        this.properties = properties;
        this.duration = duration;
        this.easing = easing;
        this.onUpdate = onUpdate;
        this.onComplete = onComplete;
        this.loop = loop;
        this.startTime = 0;
        this.requestId = null;
        this.paused = false;
        this.pauseTime = 0;
        this.reverse = false;
      }

      start() {
        this.startTime = performance.now();
        this.requestId = requestAnimationFrame(this.animate.bind(this));
      }

      animate(currentTime) {
        if (this.paused) return;
        const elapsed = currentTime - this.startTime;
        const t = Math.min(elapsed / this.duration, 1); // Normalized time (0 to 1)
        const easedT = this.easing(t);
        const values = {};

        for (const key in this.properties) {
          const [startValue, endValue] = this.properties[key];
          if (this.reverse) {
            values[key] = endValue + (startValue - endValue) * easedT;
          } else {
            values[key] = startValue + (endValue - startValue) * easedT;
          }
        }

        if (this.onUpdate) {
          this.onUpdate(values);
        }

        if (t < 1) {
          this.requestId = requestAnimationFrame(this.animate.bind(this));
        } else {
          if (this.loop) {
            this.reverse = !this.reverse;
            this.startTime = performance.now();
            this.requestId = requestAnimationFrame(this.animate.bind(this));
          } else {
            if (this.onComplete) {
              this.onComplete();
            }
            if (this.requestId) {
              cancelAnimationFrame(this.requestId);
            }
          }
        }
      }

      pause() {
        if (this.requestId) {
          this.paused = true;
          this.pauseTime = performance.now();
          cancelAnimationFrame(this.requestId);
          this.requestId = null;
        }
      }

      resume() {
        if (this.paused) {
          this.paused = false;
          const pauseDuration = performance.now() - this.pauseTime;
          this.startTime += pauseDuration;
          this.requestId = requestAnimationFrame(this.animate.bind(this));
        }
      }

      stop() {
        if (this.requestId) {
          cancelAnimationFrame(this.requestId);
          this.requestId = null;
        }
      }
    }

    // Timeline class
    class Timeline {
      constructor() {
        this.tweens = [];
        this.currentTweenIndex = 0;
      }

      add(tween) {
        this.tweens.push(tween);
      }

      start() {
        this.currentTweenIndex = 0;
        this.runNextTween();
      }

      runNextTween() {
        if (this.currentTweenIndex < this.tweens.length) {
          const currentTween = this.tweens[this.currentTweenIndex];
          currentTween.start();
          this.currentTweenIndex++;
          this.runNextTween();
        }
      }
    }

看效果,看与未进行封装、纯 JavaScript 实现的对比