😆日拱一卒:为什么SetTimeout不适合做动画?

606 阅读5分钟

下面展示分别展示在setTimeout和requestAnimationFrame中制作执行动画的方法(代码在后面)

Settimeout

打开控制台,然后找这个performance,上面的图是settimeout在性能面板执行出来的情况下,可以看到一帧可能被触发两次定时器,也有可能一帧的时候一个定时器都不触发

image.png

在这种情况下两个帧只执行了一次settimeout,就会出现卡顿的效果,导致动画不流畅,用户感觉的卡顿感呢或者是有跳跃、有卡顿。

settimeout现在已经不适合用做动画了啊。正确做法,是使用下边的API

requestAnimationFrame

image.png

当使用 requestAnimationFrame 做动画时,其回调函数的时间间隔并不是固定的。我们不能假设每次回调都会严格按照 16.6 毫秒的间隔触发,因为帧率可能会波动。

帧率的不稳定会导致回调函数的触发时机也随之变化。如果帧率不稳定,比如忽快忽慢,回调的间隔时间也会随之变化。但是requestAnimationFrame 它能够确保每次触发时都绘制一次画面,保证动画的每一帧都可见,并且每一帧只绘制一次。

requestAnimationFrame 的触发间隔取决于帧率,虽然帧率可能不稳定,但它能够最大程度地协调帧率与绘制频率,确保动画的平滑性和准确性。

简单来说就是会根据帧率的变化去执行,帧率变化执行动画的时机也就会变化,一帧执行一次动画。

那既然帧率也是不稳定的,应该如何去执行一个动画

先上一下代码案例


```js
<template>
  <div>
    <!-- setTimeout 小球 -->
    <div 
      class="ball" 
      :style="{ transform: `translateX(${positionTimeout}px)` }"
    ></div>
    <button @click="startAnimationTimeout">Start Animation (setTimeout)</button>

    <!-- requestAnimationFrame 小球 -->
    <div 
      class="ball" 
      :style="{ transform: `translateX(${positionRaf}px)` }"
    ></div>
    <button @click="startAnimationRaf">Start Animation (requestAnimationFrame)</button>
  </div>
</template>

<script>
export default {
  data() {
    return {
      positionTimeout: 0, // setTimeout 小球的当前位置
      positionRaf: 0, // requestAnimationFrame 小球的当前位置
      animationId: null // 用于控制 requestAnimationFrame 动画的 ID
    };
  },
  methods: {
    // 使用 setTimeout 实现动画
    startAnimationTimeout() {
      this.positionTimeout = 0; // 重置位置
      const step = () => {
        if (this.positionTimeout < 300) {
          this.positionTimeout += 5; // 每次移动 5px
          setTimeout(step, 16); // 每 16ms 执行一次
        }
      };
      step();
    },
    // 使用 requestAnimationFrame 实现动画
    startAnimationRaf() {
      this.positionRaf = 0; // 重置位置
      const step = () => {
        if (this.positionRaf < 300) {
          this.positionRaf += 5; // 每帧移动 5px
          this.animationId = requestAnimationFrame(step); // 请求下一帧
        } else {
          cancelAnimationFrame(this.animationId); // 动画结束后停止
        }
      };
      step();
    }
  }
};
</script>

<style>
/* 小球样式 */
.ball {
  width: 50px;
  height: 50px;
  background-color: red;
  border-radius: 50%;
  position: absolute;
  top: 100px;
  margin-bottom: 20px;
  transition: transform 0.1s linear;
}
/* 第二个小球 */
.ball:nth-child(3) {
  background-color: blue;
  top: 200px;
}
button {
  margin-top: 120px;
  margin-left: 10px;
}
</style>

利用api + 速度

我们需要确定一个运动的速度,比如小球的运动每秒移动多少像素,在动画开始时,记录一个时间点,作为基准时间,在每一帧更新时,计算从起始时间到当前时间的时间差,通过公式:

当前位置 = 起始位置 + 速度 × 时间差

计算出物体当前应该的位置,将计算得出的当前位置更新到动画元素上,从而实现平滑的运动

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>小球动画示例</title>
  <style>
    /* 容器样式 */
    #container {
      width: 400px;
      height: 200px;
      border: 1px solid black;
      position: relative;
      overflow: hidden;
    }
    /* 小球样式 */
    #ball {
      width: 20px;
      height: 20px;
      background-color: red;
      border-radius: 50%;
      position: absolute;
      top: 90px; /* 垂直居中 */
      left: 0; /* 初始位置 */
    }
  </style>
</head>
<body>
  <!-- 容器 -->
  <div id="container">
    <div id="ball"></div>
  </div>
  <!-- 按钮 -->
  <button id="start">开始动画</button>

  <script>
    const ball = document.getElementById('ball'); // 获取小球元素
    const startButton = document.getElementById('start'); // 获取按钮元素
    const containerWidth = 400; // 容器宽度
    const ballWidth = 20; // 小球宽度
    const speed = 100; // 小球每秒移动的像素
    let startTime = null; // 动画的开始时间
    let animationId = null; // 动画的 ID,方便停止动画

    // 动画函数
    function animate(timestamp) {
      if (!startTime) {
        startTime = timestamp; // 初始化开始时间
      }

      // 计算经过的时间(单位:秒)
      const elapsedTime = (timestamp - startTime) / 1000;

      // 计算小球的新位置
      const currentPosition = Math.min(elapsedTime * speed, containerWidth - ballWidth);

      // 更新小球的位置
      ball.style.left = `${currentPosition}px`;

      // 如果小球未到达终点,继续下一帧动画
      if (currentPosition < containerWidth - ballWidth) {
        animationId = requestAnimationFrame(animate);
      } else {
        cancelAnimationFrame(animationId); // 停止动画
      }
    }

    // 点击按钮启动动画
    startButton.addEventListener('click', () => {
      startTime = null; // 重置开始时间
      ball.style.left = '0px'; // 重置小球位置
      cancelAnimationFrame(animationId); // 停止之前的动画
      animationId = requestAnimationFrame(animate); // 启动新的动画
    });
  </script>
</body>
</html>

总结

setTimeout 的定时不是完全精确的,受到以下因素影响:

  • 浏览器性能:如果浏览器忙于处理其他任务(如渲染或事件响应),setTimeout 的回调会被延迟执行。

  • 系统计时器限制:浏览器通常有最小的时间间隔(通常是 4ms 或 10ms),在实际运行中可能不稳定。

  • 累积误差:在一系列动画帧中,由于时间间隔的不准确,会导致动画速度变化不一致。

  • 高效动画通常使用 requestAnimationFrame,它会在浏览器的 下一帧渲染周期 中执行。为了排除帧率不稳定的情况,常用速度的方法一起制作动画。

如果觉得有趣或有收获,请关注我的更新,给个喜欢和分享。您的支持是我写作的最大动力!

往期好文推荐