2024 年 1 月 20 日

JavaScript 动画

JavaScript 动画可以处理 CSS 做不到的事情。

例如,沿着复杂的路径移动,使用与贝塞尔曲线不同的时间函数,或在画布上进行动画。

使用 setInterval

动画可以实现为一系列帧 - 通常是对 HTML/CSS 属性的小幅更改。

例如,将 style.left0px 更改为 100px 会移动元素。如果我们在 setInterval 中增加它,以每秒 50 次的速度以 2px 的速度进行更改,那么它看起来很流畅。这与电影中的原理相同:每秒 24 帧足以使其看起来流畅。

伪代码可能如下所示

let timer = setInterval(function() {
  if (animation complete) clearInterval(timer);
  else increase style.left by 2px
}, 20); // change by 2px every 20ms, about 50 frames per second

更完整的动画示例

let start = Date.now(); // remember start time

let timer = setInterval(function() {
  // how much time passed from the start?
  let timePassed = Date.now() - start;

  if (timePassed >= 2000) {
    clearInterval(timer); // finish the animation after 2 seconds
    return;
  }

  // draw the animation at the moment timePassed
  draw(timePassed);

}, 20);

// as timePassed goes from 0 to 2000
// left gets values from 0px to 400px
function draw(timePassed) {
  train.style.left = timePassed / 5 + 'px';
}

点击查看演示

结果
index.html
<!DOCTYPE HTML>
<html>

<head>
  <style>
    #train {
      position: relative;
      cursor: pointer;
    }
  </style>
</head>

<body>

  <img id="train" src="https://js.cx/clipart/train.gif">


  <script>
    train.onclick = function() {
      let start = Date.now();

      let timer = setInterval(function() {
        let timePassed = Date.now() - start;

        train.style.left = timePassed / 5 + 'px';

        if (timePassed > 2000) clearInterval(timer);

      }, 20);
    }
  </script>


</body>

</html>

使用 requestAnimationFrame

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

如果我们分别运行它们,即使每个动画都使用 setInterval(..., 20),浏览器也需要比每 20ms 更频繁地重绘。

这是因为它们有不同的开始时间,所以“每 20ms”在不同的动画之间会有所不同。间隔没有对齐。因此,我们在 20ms 内会有几次独立的运行。

换句话说,这

setInterval(function() {
  animate1();
  animate2();
  animate3();
}, 20)

…比三个独立的调用更轻

setInterval(animate1, 20); // independent animations
setInterval(animate2, 20); // in different places of the script
setInterval(animate3, 20);

这些独立的重绘应该被分组在一起,以便浏览器更容易重绘,从而减少 CPU 负载并使动画看起来更流畅。

还有一点需要注意。有时 CPU 过载,或者有其他原因导致重绘频率降低(例如当浏览器标签处于隐藏状态时),因此我们不应该每 20ms 运行它。

但在 JavaScript 中,我们如何知道这一点呢?有一个规范 动画时序 提供了函数 requestAnimationFrame。它解决了所有这些问题,甚至更多。

语法

let requestId = requestAnimationFrame(callback)

这将 callback 函数安排在浏览器想要进行动画的最近时间运行。

如果我们在 callback 中对元素进行更改,那么这些更改将与其他 requestAnimationFrame 回调以及 CSS 动画一起分组。因此,将进行一次几何重新计算和重绘,而不是多次。

返回值 requestId 可用于取消调用

// cancel the scheduled execution of callback
cancelAnimationFrame(requestId);

callback 获取一个参数——页面加载开始以毫秒为单位经过的时间。此时间也可以通过调用 performance.now() 获得。

通常 callback 很快就会运行,除非 CPU 过载或笔记本电脑电池即将耗尽,或者有其他原因。

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

<script>
  let prev = performance.now();
  let times = 0;

  requestAnimationFrame(function measure(time) {
    document.body.insertAdjacentHTML("beforeEnd", Math.floor(time - prev) + " ");
    prev = time;

    if (times++ < 10) requestAnimationFrame(measure);
  })
</script>

结构化动画

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

function animate({timing, draw, duration}) {

  let start = performance.now();

  requestAnimationFrame(function animate(time) {
    // timeFraction goes from 0 to 1
    let timeFraction = (time - start) / duration;
    if (timeFraction > 1) timeFraction = 1;

    // calculate the current animation state
    let progress = timing(timeFraction)

    draw(progress); // draw it

    if (timeFraction < 1) {
      requestAnimationFrame(animate);
    }

  });
}

函数 animate 接受 3 个参数,这些参数本质上描述了动画

持续时间

动画的总时长。例如,1000

timing(timeFraction)

定时函数,类似于 CSS 属性 transition-timing-function,它获取经过的时间的比例(开始时为 0,结束时为 1),并返回动画完成度(类似于贝塞尔曲线上的 y)。

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

function linear(timeFraction) {
  return timeFraction;
}

其图形:

这就像 transition-timing-function: linear。下面展示了更多有趣的变体。

draw(progress)

该函数接受动画完成状态并绘制它。值 progress=0 表示动画开始状态,progress=1 表示结束状态。

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

它可以移动元素

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

…或者做任何其他事情,我们可以以任何方式动画化任何东西。

让我们使用我们的函数将元素的 width0 动画到 100%

点击元素以查看演示

结果
animate.js
index.html
function animate({duration, draw, timing}) {

  let start = performance.now();

  requestAnimationFrame(function animate(time) {
    let timeFraction = (time - start) / duration;
    if (timeFraction > 1) timeFraction = 1;

    let progress = timing(timeFraction)

    draw(progress);

    if (timeFraction < 1) {
      requestAnimationFrame(animate);
    }

  });
}
<!DOCTYPE HTML>
<html>

<head>
  <meta charset="utf-8">
  <style>
    progress {
      width: 5%;
    }
  </style>
  <script src="animate.js"></script>
</head>

<body>


  <progress id="elem"></progress>

  <script>
    elem.onclick = function() {
      animate({
        duration: 1000,
        timing: function(timeFraction) {
          return timeFraction;
        },
        draw: function(progress) {
          elem.style.width = progress * 100 + '%';
        }
      });
    };
  </script>


</body>

</html>

它的代码

animate({
  duration: 1000,
  timing(timeFraction) {
    return timeFraction;
  },
  draw(progress) {
    elem.style.width = progress * 100 + '%';
  }
});

与 CSS 动画不同,我们可以在此处创建任何定时函数和任何绘制函数。定时函数不受贝塞尔曲线的限制。draw 可以超越属性,创建新的元素,例如烟花动画或其他东西。

定时函数

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

让我们看看更多。我们将尝试使用不同的定时函数进行运动动画,以了解它们的工作原理。

n 次方

如果我们想加速动画,我们可以使用 progressn 次方。

例如,抛物线

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

图形

实际效果(点击激活)

…或者三次曲线,甚至更大的 n。增加幂会使其加速更快。

这是 progress5 次方的图形

实际效果

弧线

函数

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

图形

回弹:弓箭射击

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

与之前的函数不同,它依赖于一个额外的参数 x,即“弹性系数”。“拉弓弦”的距离由它定义。

代码

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

x = 1.5 的图形

对于动画,我们使用它与 x 的特定值。例如 x = 1.5

弹跳

想象一下我们正在掉一个球。它落下,然后弹跳几次,然后停止。

bounce 函数的功能相同,但顺序相反:“弹跳”立即开始。它为此使用了一些特殊的系数。

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

实际效果

弹性动画

还有一个“弹性”函数,它接受一个额外的参数 x 来表示“初始范围”。

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

x=1.5 的图形:

x=1.5 的实际效果

反转:ease*

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

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

easeOut

在“easeOut”模式下,timing 函数被放入一个包装器 timingEaseOut 中。

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

换句话说,我们有一个“转换”函数 makeEaseOut,它接受一个“常规”计时函数并返回其包装器。

// accepts a timing function, returns the transformed variant
function makeEaseOut(timing) {
  return function(timeFraction) {
    return 1 - timing(1 - timeFraction);
  }
}

例如,我们可以使用上面描述的 bounce 函数并应用它。

let bounceEaseOut = makeEaseOut(bounce);

然后弹跳将不会在开始,而是在动画结束时。看起来更好。

结果
style.css
index.html
#brick {
  width: 40px;
  height: 20px;
  background: #EE6B47;
  position: relative;
  cursor: pointer;
}

#path {
  outline: 1px solid #E8C48E;
  width: 540px;
  height: 20px;
}
<!DOCTYPE HTML>
<html>

<head>
  <meta charset="utf-8">
  <link rel="stylesheet" href="style.css">
  <script src="https://js.cx/libs/animate.js"></script>
</head>

<body>


  <div id="path">
    <div id="brick"></div>
  </div>

  <script>
    function makeEaseOut(timing) {
      return function(timeFraction) {
        return 1 - timing(1 - timeFraction);
      }
    }

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

    let bounceEaseOut = makeEaseOut(bounce);

    brick.onclick = function() {
      animate({
        duration: 3000,
        timing: bounceEaseOut,
        draw: function(progress) {
          brick.style.left = progress * 500 + 'px';
        }
      });
    };
  </script>


</body>

</html>

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

如果动画效果在开始时出现,比如弹跳,它将在结束时显示。

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

  • 常规弹跳 - 物体在底部弹跳,然后在结束时突然跳到顶部。
  • easeOut 之后 - 它首先跳到顶部,然后在那里弹跳。

easeInOut

我们也可以在动画的开始和结束时显示效果。转换称为“easeInOut”。

给定计时函数,我们这样计算动画状态。

if (timeFraction <= 0.5) { // first half of the animation
  return timing(2 * timeFraction) / 2;
} else { // second half of the animation
  return (2 - timing(2 * (1 - timeFraction))) / 2;
}

包装器代码

function makeEaseInOut(timing) {
  return function(timeFraction) {
    if (timeFraction < .5)
      return timing(2 * timeFraction) / 2;
    else
      return (2 - timing(2 * (1 - timeFraction))) / 2;
  }
}

bounceEaseInOut = makeEaseInOut(bounce);

实际效果,bounceEaseInOut

结果
style.css
index.html
#brick {
  width: 40px;
  height: 20px;
  background: #EE6B47;
  position: relative;
  cursor: pointer;
}

#path {
  outline: 1px solid #E8C48E;
  width: 540px;
  height: 20px;
}
<!DOCTYPE HTML>
<html>

<head>
  <meta charset="utf-8">
  <link rel="stylesheet" href="style.css">
  <script src="https://js.cx/libs/animate.js"></script>
</head>

<body>


  <div id="path">
    <div id="brick"></div>
  </div>

  <script>
    function makeEaseInOut(timing) {
      return function(timeFraction) {
        if (timeFraction < .5)
          return timing(2 * timeFraction) / 2;
        else
          return (2 - timing(2 * (1 - timeFraction))) / 2;
      }
    }


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

    let bounceEaseInOut = makeEaseInOut(bounce);

    brick.onclick = function() {
      animate({
        duration: 3000,
        timing: bounceEaseInOut,
        draw: function(progress) {
          brick.style.left = progress * 500 + 'px';
        }
      });
    };
  </script>


</body>

</html>

“easeInOut”转换将两个图形合并为一个:动画前半部分的 easeIn(常规)和动画后半部分的 easeOut(反转)。

如果我们比较 circ 计时函数的 easeIneaseOuteaseInOut 图形,效果很明显。

  • 红色circ 的常规变体 (easeIn)。
  • 绿色easeOut
  • 蓝色easeInOut

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

更有趣的“绘制”

我们可以做一些其他的事情,而不是移动元素。我们只需要编写正确的 draw

这是动画的“弹跳”文本输入

结果
style.css
index.html
textarea {
  display: block;
  border: 1px solid #BBB;
  color: #444;
  font-size: 110%;
}

button {
  margin-top: 10px;
}
<!DOCTYPE HTML>
<html>

<head>
  <meta charset="utf-8">
  <link rel="stylesheet" href="style.css">
  <script src="https://js.cx/libs/animate.js"></script>
</head>

<body>


  <textarea id="textExample" rows="5" cols="60">He took his vorpal sword in hand:
Long time the manxome foe he sought—
So rested he by the Tumtum tree,
And stood awhile in thought.
  </textarea>

  <button onclick="animateText(textExample)">Run the animated typing!</button>

  <script>
    function animateText(textArea) {
      let text = textArea.value;
      let to = text.length,
        from = 0;

      animate({
        duration: 5000,
        timing: bounce,
        draw: function(progress) {
          let result = (to - from) * progress + from;
          textArea.value = text.slice(0, Math.ceil(result))
        }
      });
    }


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


</body>

</html>

总结

对于 CSS 无法很好地处理的动画,或者需要严格控制的动画,JavaScript 可以提供帮助。JavaScript 动画应通过 requestAnimationFrame 实现。该内置方法允许设置回调函数,以便在浏览器准备重新绘制时运行。通常这很快,但确切时间取决于浏览器。

当页面处于后台时,根本不会进行重新绘制,因此回调函数不会运行:动画将被暂停,不会消耗资源。这很好。

这是用于设置大多数动画的辅助 animate 函数

function animate({timing, draw, duration}) {

  let start = performance.now();

  requestAnimationFrame(function animate(time) {
    // timeFraction goes from 0 to 1
    let timeFraction = (time - start) / duration;
    if (timeFraction > 1) timeFraction = 1;

    // calculate the current animation state
    let progress = timing(timeFraction);

    draw(progress); // draw it

    if (timeFraction < 1) {
      requestAnimationFrame(animate);
    }

  });
}

选项

  • duration – 动画总时间(以毫秒为单位)。
  • timing – 用于计算动画进度的函数。获取从 0 到 1 的时间分数,返回动画进度,通常从 0 到 1。
  • draw – 用于绘制动画的函数。

当然我们可以改进它,添加更多花哨的功能,但 JavaScript 动画并非每天都使用。它们用于做一些有趣且非标准的事情。因此,您需要在需要时添加所需的功能。

JavaScript 动画可以使用任何计时函数。我们涵盖了许多示例和转换,使它们更加通用。与 CSS 不同,我们在这里不受限于贝塞尔曲线。

draw 也是如此:我们可以对任何东西进行动画处理,而不仅仅是 CSS 属性。

任务

重要性:5

制作一个弹跳球。点击查看它应该是什么样子

打开一个用于该任务的沙盒。

为了实现弹跳效果,我们可以使用 CSS 属性 topposition:absolute 来控制球体在具有 position:relative 属性的场地内的位置。

场地的底部坐标为 field.clientHeight。CSS 属性 top 指的是球体的上边缘。因此,它应该从 0field.clientHeight - ball.clientHeight,即球体上边缘的最终最低位置。

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

以下是动画的最终代码

let to = field.clientHeight - ball.clientHeight;

animate({
  duration: 2000,
  timing: makeEaseOut(bounce),
  draw(progress) {
    ball.style.top = to * progress + 'px'
  }
});

在沙盒中打开解决方案。

重要性:5

让球体向右弹跳,就像这样

编写动画代码。向左的距离为 100px

以之前任务 弹跳球动画 的解决方案作为源代码。

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

水平坐标的变化遵循不同的规律:它不会“弹跳”,而是逐渐增加,将球体向右移动。

我们可以为此再编写一个 animate 函数。

作为时间函数,我们可以使用 linear,但类似 makeEaseOut(quad) 的效果看起来更好。

代码

let height = field.clientHeight - ball.clientHeight;
let width = 100;

// animate top (bouncing)
animate({
  duration: 2000,
  timing: makeEaseOut(bounce),
  draw: function(progress) {
    ball.style.top = height * progress + 'px'
  }
});

// animate left (moving to the right)
animate({
  duration: 2000,
  timing: makeEaseOut(quad),
  draw: function(progress) {
    ball.style.left = width * progress + "px"
  }
});

在沙盒中打开解决方案。

教程地图

评论

在评论之前请阅读…
  • 如果您有任何改进建议,请 提交 GitHub 问题 或拉取请求,而不是评论。
  • 如果您无法理解文章中的某些内容,请详细说明。
  • 要插入少量代码,请使用 <code> 标签,对于多行代码,请将其包裹在 <pre> 标签中,对于超过 10 行的代码,请使用沙盒(plnkrjsbincodepen…)