JavaScript 动画可以处理 CSS 无法处理的事情。

例如,沿着具有与 Bezier 曲线不同的时序函数的复杂路径移动,或者实现画布上的动画。

使用 setInterval

从 HTML/CSS 的角度来看,动画是 style 属性的逐渐变化。例如,将 style.left0px 变化到 100px 可以移动元素。

如果我们用 setInterval 每秒做 50 次小变化,看起来会更流畅。电影也是这样的原理:每秒 24 帧或更多帧足以使其看起来流畅。

伪代码如下:

  1. let delay = 1000 / 50; // 每秒 50 帧
  2. let timer = setInterval(function() {
  3. if (animation complete) clearInterval(timer);
  4. else increase style.left
  5. }, delay)

更完整的动画示例:

  1. let start = Date.now(); // 保存开始时间
  2. let timer = setInterval(function() {
  3. // 距开始过了多长时间
  4. let timePassed = Date.now() - start;
  5. if (timePassed >= 2000) {
  6. clearInterval(timer); // 2 秒后结束动画
  7. return;
  8. }
  9. // 在 timePassed 时刻绘制动画
  10. draw(timePassed);
  11. }, 20);
  12. // 随着 timePassed 从 0 增加到 2000
  13. // 将 left 的值从 0px 增加到 400px
  14. function draw(timePassed) {
  15. train.style.left = timePassed / 5 + 'px';
  16. }

点击演示:

结果

index.html

  1. <!DOCTYPE HTML>
  2. <html>
  3. <head>
  4. <style>
  5. #train {
  6. position: relative;
  7. cursor: pointer;
  8. }
  9. </style>
  10. </head>
  11. <body>
  12. <img id="train" src="https://js.cx/clipart/train.gif">
  13. <script>
  14. train.onclick = function() {
  15. let start = Date.now();
  16. let timer = setInterval(function() {
  17. let timePassed = Date.now() - start;
  18. train.style.left = timePassed / 5 + 'px';
  19. if (timePassed > 2000) clearInterval(timer);
  20. }, 20);
  21. }
  22. </script>
  23. </body>
  24. </html>

使用 requestAnimationFrame

假设我们有几个同时运行的动画。

如果我们单独运行它们,每个都有自己的 setInterval(..., 20),那么浏览器必须以比 20ms 更频繁的速度重绘。

每个 setInterval20ms 触发一次,但它们相互独立,因此 20ms 内将有多个独立运行的重绘。

这几个独立的重绘应该组合在一起,以使浏览器更加容易处理。

换句话说,像下面这样:

  1. setInterval(function() {
  2. animate1();
  3. animate2();
  4. animate3();
  5. }, 20)

……比这样更好:

  1. setInterval(animate1, 20);
  2. setInterval(animate2, 20);
  3. setInterval(animate3, 20);

还有一件事需要记住。有时当 CPU 过载时,或者有其他原因需要降低重绘频率。例如,如果浏览器选项卡被隐藏,那么绘图完全没有意义。

有一个标准动画时序提供了 requestAnimationFrame 函数。

它解决了所有这些问题,甚至更多其它的问题。

语法:

  1. let requestId = requestAnimationFrame(callback);

这会让 callback 函数在浏览器每次重绘的最近时间运行。

如果我们对 callback 中的元素进行变化,这些变化将与其他 requestAnimationFrame 回调和 CSS 动画组合在一起。因此,只会有一次几何重新计算和重绘,而不是多次。

返回值 requestId 可用来取消回调:

  1. // 取消回调的周期执行
  2. cancelAnimationFrame(requestId);

callback 得到一个参数 —— 从页面加载开始经过的毫秒数。这个时间也可通过调用 performance.now() 得到。

通常 callback 很快就会运行,除非 CPU 过载或笔记本电量消耗殆尽,或者其他原因。

下面的代码显示了 requestAnimationFrame 的前 10 次运行之间的时间间隔。通常是 10-20ms:

  1. <script>
  2. let prev = performance.now();
  3. let times = 0;
  4. requestAnimationFrame(function measure(time) {
  5. document.body.insertAdjacentHTML("beforeEnd", Math.floor(time - prev) + " ");
  6. prev = time;
  7. if (times++ < 10) requestAnimationFrame(measure);
  8. });
  9. </script>

结构化动画

现在我们可以在 requestAnimationFrame 基础上创建一个更通用的动画函数:

  1. function animate({timing, draw, duration}) {
  2. let start = performance.now();
  3. requestAnimationFrame(function animate(time) {
  4. // timeFraction 从 0 增加到 1
  5. let timeFraction = (time - start) / duration;
  6. if (timeFraction > 1) timeFraction = 1;
  7. // 计算当前动画状态
  8. let progress = timing(timeFraction);
  9. draw(progress); // 绘制
  10. if (timeFraction < 1) {
  11. requestAnimationFrame(animate);
  12. }
  13. });
  14. }

animate 函数接受 3 个描述动画的基本参数:

duration

动画总时间,比如 1000

timing(timeFraction)

时序函数,类似 CSS 属性 transition-timing-function,传入一个已过去的时间与总时间之比的小数(0 代表开始,1 代表结束),返回动画完成度(类似 Bezier 曲线中的 y)。

例如,线性函数意味着动画以相同的速度均匀地进行:

  1. function linear(timeFraction) {
  2. return timeFraction;
  3. }

图像如下:

JavaScript 动画 - 图1

它类似于 transition-timing-function: linear。后文有更多有趣的变体。

draw(progress)

获取动画完成状态并绘制的函数。值 progress = 0 表示开始动画状态,progress = 1 表示结束状态。

这是实际绘制动画的函数。

它可以移动元素:

  1. function draw(progress) {
  2. train.style.left = progress + 'px';
  3. }

……或者做任何其他事情,我们可以以任何方式为任何事物制作动画。

让我们使用我们的函数将元素的 width0 变化为 100%

点击演示元素:

结果

animate.js

index.html

  1. function animate({duration, draw, timing}) {
  2. let start = performance.now();
  3. requestAnimationFrame(function animate(time) {
  4. let timeFraction = (time - start) / duration;
  5. if (timeFraction > 1) timeFraction = 1;
  6. let progress = timing(timeFraction)
  7. draw(progress);
  8. if (timeFraction < 1) {
  9. requestAnimationFrame(animate);
  10. }
  11. });
  12. }
  1. <!DOCTYPE HTML>
  2. <html>
  3. <head>
  4. <meta charset="utf-8">
  5. <style>
  6. progress {
  7. width: 5%;
  8. }
  9. </style>
  10. <script src="animate.js"></script>
  11. </head>
  12. <body>
  13. <progress id="elem"></progress>
  14. <script>
  15. elem.onclick = function() {
  16. animate({
  17. duration: 1000,
  18. timing: function(timeFraction) {
  19. return timeFraction;
  20. },
  21. draw: function(progress) {
  22. elem.style.width = progress * 100 + '%';
  23. }
  24. });
  25. };
  26. </script>
  27. </body>
  28. </html>

它的代码如下:

  1. animate({
  2. duration: 1000,
  3. timing(timeFraction) {
  4. return timeFraction;
  5. },
  6. draw(progress) {
  7. elem.style.width = progress * 100 + '%';
  8. }
  9. });

与 CSS 动画不同,我们可以在这里设计任何时序函数和任何绘图函数。时序函数不受 Bezier 曲线的限制。并且 draw 不局限于操作 CSS 属性,还可以为类似烟花动画或其他动画创建新元素。

时序函数

上文我们看到了最简单的线性时序函数。

让我们看看更多。我们将尝试使用不同时序函数的移动动画来查看它们的工作原理。

n 次幂

如果我们想加速动画,我们可以让 progressn 次幂。

例如,抛物线:

  1. function quad(timeFraction) {
  2. return Math.pow(timeFraction, 2)
  3. }

图像如下:

JavaScript 动画 - 图2

看看实际效果(点击激活):

……或者三次曲线甚至使用更大的 n。增大幂会让动画加速得更快。

下面是 progress5 次幂的图像:

JavaScript 动画 - 图3

实际效果:

圆弧

函数:

  1. function circ(timeFraction) {
  2. return 1 - Math.sin(Math.acos(timeFraction));
  3. }

图像:

JavaScript 动画 - 图4

反弹:弓箭射击

此函数执行“弓箭射击”。首先,我们“拉弓弦”,然后“射击”。

与以前的函数不同,它取决于附加参数 x,即“弹性系数”。“拉弓弦”的距离由它定义。

代码如下:

  1. function back(x, timeFraction) {
  2. return Math.pow(timeFraction, 2) * ((x + 1) * timeFraction - x);
  3. }

x = 1.5 时的图像:

JavaScript 动画 - 图5

在动画中我们使用特定的 x 值。下面是 x = 1.5 时的例子:

弹跳

想象一下,我们正在抛球。球落下之后,弹跳几次然后停下来。

bounce 函数也是如此,但顺序相反:“bouncing”立即启动。它使用了几个特殊的系数:

  1. function bounce(timeFraction) {
  2. for (let a = 0, b = 1, result; 1; a += b, b /= 2) {
  3. if (timeFraction >= (7 - 4 * a) / 11) {
  4. return -Math.pow((11 - 6 * a - 11 * timeFraction) / 4, 2) + Math.pow(b, 2)
  5. }
  6. }
  7. }

演示:

伸缩动画

另一个“伸缩”函数接受附加参数 x 作为“初始范围”。

  1. function elastic(x, timeFraction) {
  2. return Math.pow(2, 10 * (timeFraction - 1)) * Math.cos(20 * Math.PI * x / 3 * timeFraction)
  3. }

x=1.5 时的图像: JavaScript 动画 - 图6

x=1.5 时的演示

逆转:ease*

我们有一组时序函数。它们的直接应用称为“easeIn”。

有时我们需要以相反的顺序显示动画。这是通过“easeOut”变换完成的。

easeOut

在“easeOut”模式中,我们将 timing 函数封装到 timingEaseOut中:

  1. timingEaseOut(timeFraction) = 1 - timing(1 - timeFraction);

换句话说,我们有一个“变换”函数 makeEaseOut,它接受一个“常规”时序函数 timing 并返回一个封装器,里面封装了 timing 函数:

  1. // 接受时序函数,返回变换后的变体
  2. function makeEaseOut(timing) {
  3. return function(timeFraction) {
  4. return 1 - timing(1 - timeFraction);
  5. }
  6. }

例如,我们可以使用上面描述的 bounce 函数:

  1. let bounceEaseOut = makeEaseOut(bounce);

这样,弹跳不会在动画开始时执行,而是在动画结束时。这样看起来更好:

结果

style.css

index.html

  1. #brick {
  2. width: 40px;
  3. height: 20px;
  4. background: #EE6B47;
  5. position: relative;
  6. cursor: pointer;
  7. }
  8. #path {
  9. outline: 1px solid #E8C48E;
  10. width: 540px;
  11. height: 20px;
  12. }
  1. <!DOCTYPE HTML>
  2. <html>
  3. <head>
  4. <meta charset="utf-8">
  5. <link rel="stylesheet" href="style.css">
  6. <script src="https://js.cx/libs/animate.js"></script>
  7. </head>
  8. <body>
  9. <div id="path">
  10. <div id="brick"></div>
  11. </div>
  12. <script>
  13. function makeEaseOut(timing) {
  14. return function(timeFraction) {
  15. return 1 - timing(1 - timeFraction);
  16. }
  17. }
  18. function bounce(timeFraction) {
  19. for (let a = 0, b = 1, result; 1; a += b, b /= 2) {
  20. if (timeFraction >= (7 - 4 * a) / 11) {
  21. return -Math.pow((11 - 6 * a - 11 * timeFraction) / 4, 2) + Math.pow(b, 2)
  22. }
  23. }
  24. }
  25. let bounceEaseOut = makeEaseOut(bounce);
  26. brick.onclick = function() {
  27. animate({
  28. duration: 3000,
  29. timing: bounceEaseOut,
  30. draw: function(progress) {
  31. brick.style.left = progress * 500 + 'px';
  32. }
  33. });
  34. };
  35. </script>
  36. </body>
  37. </html>

在这里,我们可以看到变换如何改变函数的行为:

JavaScript 动画 - 图7

如果在开始时有动画效果,比如弹跳 —— 那么它将在最后显示。

上图中常规弹跳为红色,easeOut 弹跳为蓝色。

  • 常规弹跳 —— 物体在底部弹跳,然后突然跳到顶部。
  • easeOut 变换之后 —— 物体跳到顶部之后,在那里弹跳。

easeInOut

我们还可以在动画的开头和结尾都显示效果。该变换称为“easeInOut”。

给定时序函数,我们按下面的方式计算动画状态:

  1. if (timeFraction <= 0.5) { // 动画前半部分
  2. return timing(2 * timeFraction) / 2;
  3. } else { // 动画后半部分
  4. return (2 - timing(2 * (1 - timeFraction))) / 2;
  5. }

封装器代码:

  1. function makeEaseInOut(timing) {
  2. return function(timeFraction) {
  3. if (timeFraction < .5)
  4. return timing(2 * timeFraction) / 2;
  5. else
  6. return (2 - timing(2 * (1 - timeFraction))) / 2;
  7. }
  8. }
  9. bounceEaseInOut = makeEaseInOut(bounce);

bounceEaseInOut 演示如下:

结果

style.css

index.html

  1. #brick {
  2. width: 40px;
  3. height: 20px;
  4. background: #EE6B47;
  5. position: relative;
  6. cursor: pointer;
  7. }
  8. #path {
  9. outline: 1px solid #E8C48E;
  10. width: 540px;
  11. height: 20px;
  12. }
  1. <!DOCTYPE HTML>
  2. <html>
  3. <head>
  4. <meta charset="utf-8">
  5. <link rel="stylesheet" href="style.css">
  6. <script src="https://js.cx/libs/animate.js"></script>
  7. </head>
  8. <body>
  9. <div id="path">
  10. <div id="brick"></div>
  11. </div>
  12. <script>
  13. function makeEaseInOut(timing) {
  14. return function(timeFraction) {
  15. if (timeFraction < .5)
  16. return timing(2 * timeFraction) / 2;
  17. else
  18. return (2 - timing(2 * (1 - timeFraction))) / 2;
  19. }
  20. }
  21. function bounce(timeFraction) {
  22. for (let a = 0, b = 1, result; 1; a += b, b /= 2) {
  23. if (timeFraction >= (7 - 4 * a) / 11) {
  24. return -Math.pow((11 - 6 * a - 11 * timeFraction) / 4, 2) + Math.pow(b, 2)
  25. }
  26. }
  27. }
  28. let bounceEaseInOut = makeEaseInOut(bounce);
  29. brick.onclick = function() {
  30. animate({
  31. duration: 3000,
  32. timing: bounceEaseInOut,
  33. draw: function(progress) {
  34. brick.style.left = progress * 500 + 'px';
  35. }
  36. });
  37. };
  38. </script>
  39. </body>
  40. </html>

“easeInOut” 变换将两个图像连接成一个:动画的前半部分为“easeIn”(常规),后半部分为“easeOut”(反向)。

如果我们比较 circ 时序函数的 easeIneaseOuteaseInOut 的图像,就可以清楚地看到效果:

JavaScript 动画 - 图8

  • 红色是 circeaseIn)的常规变体。
  • 绿色 —— easeOut
  • 蓝色 —— easeInOut

正如我们所看到的,动画前半部分的图形是缩小的“easeIn”,后半部分是缩小的“easeOut”。结果是动画以相同的效果开始和结束。

更有趣的 “draw”

除了移动元素,我们还可以做其他事情。我们所需要的只是写出合适的 draw

这是动画形式的“弹跳”文字输入:

结果

style.css

index.html

  1. textarea {
  2. display: block;
  3. border: 1px solid #BBB;
  4. color: #444;
  5. font-size: 110%;
  6. }
  7. button {
  8. margin-top: 10px;
  9. }
  1. <!DOCTYPE HTML>
  2. <html>
  3. <head>
  4. <meta charset="utf-8">
  5. <link rel="stylesheet" href="style.css">
  6. <script src="https://js.cx/libs/animate.js"></script>
  7. </head>
  8. <body>
  9. <textarea id="textExample" rows="5" cols="60">He took his vorpal sword in hand:
  10. Long time the manxome foe he sought—
  11. So rested he by the Tumtum tree,
  12. And stood awhile in thought.
  13. </textarea>
  14. <button onclick="animateText(textExample)">Run the animated typing!</button>
  15. <script>
  16. function animateText(textArea) {
  17. let text = textArea.value;
  18. let to = text.length,
  19. from = 0;
  20. animate({
  21. duration: 5000,
  22. timing: bounce,
  23. draw: function(progress) {
  24. let result = (to - from) * progress + from;
  25. textArea.value = text.substr(0, Math.ceil(result))
  26. }
  27. });
  28. }
  29. function bounce(timeFraction) {
  30. for (let a = 0, b = 1, result; 1; a += b, b /= 2) {
  31. if (timeFraction >= (7 - 4 * a) / 11) {
  32. return -Math.pow((11 - 6 * a - 11 * timeFraction) / 4, 2) + Math.pow(b, 2)
  33. }
  34. }
  35. }
  36. </script>
  37. </body>
  38. </html>

总结

JavaScript 动画应该通过 requestAnimationFrame 实现。该内置方法允许设置回调函数,以便在浏览器准备重绘时运行。那通常很快,但确切的时间取决于浏览器。

当页面在后台时,根本没有重绘,因此回调将不会运行:动画将被暂停并且不会消耗资源。那很棒。

这是设置大多数动画的 helper 函数 animate

  1. function animate({timing, draw, duration}) {
  2. let start = performance.now();
  3. requestAnimationFrame(function animate(time) {
  4. // timeFraction 从 0 增加到 1
  5. let timeFraction = (time - start) / duration;
  6. if (timeFraction > 1) timeFraction = 1;
  7. // 计算当前动画状态
  8. let progress = timing(timeFraction);
  9. draw(progress); // 绘制
  10. if (timeFraction < 1) {
  11. requestAnimationFrame(animate);
  12. }
  13. });
  14. }

参数:

  • duration —— 动画运行的总毫秒数。
  • timing —— 计算动画进度的函数。获取从 0 到 1 的小数时间,返回动画进度,通常也是从 0 到 1。
  • draw —— 绘制动画的函数。

当然我们可以改进它,增加更多花里胡哨的东西,但 JavaScript 动画不是经常用到。它们用于做一些有趣和不标准的事情。因此,您大可在必要时再添加所需的功能。

JavaScript 动画可以使用任何时序函数。我们介绍了很多例子和变换,使它们更加通用。与 CSS 不同,我们不仅限于 Bezier 曲线。

draw 也是如此:我们可以将任何东西动画化,而不仅仅是 CSS 属性。

任务

为弹跳的球设置动画

重要程度: 5

做一个弹跳的球。点击查看应有的效果:

打开一个任务沙箱。

解决方案

为了达到反弹效果,我们可以在带有 position:relative 属性的区域内,给小球使用 topposition:absolute CSS 属性。

field 区域的底部坐标是 field.clientHeighttop 属性给出了球顶部的坐标,在最底部时达到 field.clientHeight - ball.clientHeight

因此,我们将 top0 变化到 field.clientHeight - ball.clientHeight 来设置动画。

现在为了获得“弹跳”效果,我们可以在 easeOut 模式下使用时序函数 bounce

这是动画的最终代码:

  1. let to = field.clientHeight - ball.clientHeight;
  2. animate({
  3. duration: 2000,
  4. timing: makeEaseOut(bounce),
  5. draw(progress) {
  6. ball.style.top = to * progress + 'px'
  7. }
  8. });

使用沙箱打开解决方案。

设置动画使球向右移动

重要程度: 5

让球向右移动。像这样:

编写动画代码。终止时球到左侧的距离是 100px

从前一个任务 为弹跳的球设置动画 的答案开始。

解决方案

在任务 为弹跳的球设置动画 中,我们只有一个需要添加动画的属性。现在多了一个 elem.style.left

水平坐标由另一个定律改变:它不会“反弹”,而是逐渐增加使球逐渐向右移动。

我们可以为它多写一个 animate

至于时序函数,我们可以使用 linear,但像 makeEaseOut(quad) 这样的函数看起来要好得多。

代码:

  1. let height = field.clientHeight - ball.clientHeight;
  2. let width = 100;
  3. // 设置 top 动画(弹跳)
  4. animate({
  5. duration: 2000,
  6. timing: makeEaseOut(bounce),
  7. draw: function(progress) {
  8. ball.style.top = height * progress + 'px'
  9. }
  10. });
  11. // 设置 left 动画(向右移动)
  12. animate({
  13. duration: 2000,
  14. timing: makeEaseOut(quad),
  15. draw: function(progress) {
  16. ball.style.left = width * progress + "px"
  17. }
  18. });

使用沙箱打开解决方案。