实现高性能版的 setInterval

1,056 阅读4分钟

为什么要说原生的 setInterval 性能比较低呢?这就要来说说 setInterval 的缺点了:

  1. 当页面被隐藏或最小化时,setInterval的回调函数仍然在后台执行,这就浪费了电脑的性能。
  2. setInterval每次将回调函数推入异步队列前,会检查异步队列中是否有该定时器的代码实例,如果存在,则不会添加本次回调函数,所以某次回调函数可能会被跳过。

所以在非必要的情况下,应该尽量避免直接使用 setInterval

今天本文要讲解的就是利用 window.requestAnimationFrame来实现高性能版的 setInterval

requestAnimationFrame 是什么?

requestAnimationFrame 会在浏览器下次重绘之前调用指定的回调函数,并且将毫秒级的时间值传入到回调函数中。所以它的优点也很明显了:

  1. 执行频率是跟浏览器刷新频率保持同步的,不会卡顿、丢帧。

    • 如果电脑的刷新频率为60HZ,requestAnimationFrame的回调函数就是在 1 / 60 ≈ 16.7ms 执行一次;
    • 如果电脑的刷新频率为90HZ,requestAnimationFrame的回调函数就是在 1 / 90 ≈ 11.1ms 执行一次;
  2. 节省CPU资源

    当页面被隐藏或最小化时,requestAnimationFrame 则会停止刷新动画,当页面恢复可见状态时,动画就从上次停止的地方继续执行。

  3. 高频率函数节流

    resize, scroll 等高频率事件中,为了防止屏幕在一个刷新间隔内发生多次函数执行,requestAnimationFrame 可保证在每个刷新间隔内函数只被执行一次。

利用 requestAnimationFrame 实现 setInterval

有的朋友可能会问,那用 setTimeout 来实现 setInterval 的性能会不会好一点呢?会好一点,但是比 requestAnimationFrame 差一点,因为对于 setTimeout 来说:

  1. setTimeout 任务被放入异步队列,只有当主线程任务执行完后才会执行队列中的任务,因此实际执行时间总是比设定时间要晚。
  2. settimeout 设置的时间间隔不一定与屏幕刷新间隔时间相同,会引起丢帧。

所以,性能最好的是用requestAnimationFrame来实现setInterval

实现思路:

  1. 首先肯定需要递归调用 requestAnimationFrame,这样才达到 setInterval 不断调用回调函数的效果。
  2. 通过一个变量来保存每次递归调用累积的时间,当这个变量大于等于设置的时间间隔时,就执行回调函数。

代码实现:

function setIntervalUsingRAF(callback, interval) {
  let startTime = performance.now(); // 获取当前毫秒级的时间
  let elapsedTime = 0; // 保存累积的时间

  function loop(currentTime) {
    const deltaTime = currentTime - startTime;
    elapsedTime += deltaTime;

    if (elapsedTime >= interval) {
      // 大于设置的时间间隔,就执行回调函数,并且重置 elapsedTime 变量
      callback();
      elapsedTime = 0;
    }

    startTime = currentTime;
    requestAnimationFrame(loop); // 递归调用 requestAnimationFrame
  }

  requestAnimationFrame(loop);
}

我们还缺少一个取消定时器的功能。

在添加这个功能之前,我们需要了解 requestAnimationFrame 的两个特点:

  1. requestAnimationFrame 的回调函数是异步执行的,举个例子:

    window.requestAnimationFrame(() => {
      console.log(1);
    });
    console.log(2);
    

    打印顺序是:2,1。

  2. 不能在 requestAnimationFrame 的回调函数里取消本次的执行,只能取消下一次的执行,举个例子:

    const rafId = window.requestAnimationFrame(() => {
      cancelAnimationFrame(rafId);
      console.log(1);
    });
    console.log(2);
    

    依然会输出 2,1。想要取消本次的执行,只能在回调函数外部执行 cancelAnimationFrame(rafId),比如:

    const rafId = window.requestAnimationFrame(() => {
      console.log(1);
    });
    cancelAnimationFrame(rafId);
    console.log(2);
    

    此时只会输出 2。

    因为 rafId 代表的是本次 requestAnimationFrame 回调函数的执行,那本次回调函数已经执行了,还怎么在回调函数里面取消呢?

添加取消定时器

由于 requestAnimationFrame 是异步执行回调函数的,所以我们可以将递归调用 requestAnimationFrame 的代码放到最前面:

function setIntervalUsingRAF(callback, interval) {
  // ...省略代码
  function loop(currentTime) {
    requestAnimationFrame(loop);

    // ...省略代码
  }
  // ...省略代码 
}

我们还需要一个变量来保存每次调用 requestAnimationFrame 函数的返回值 ID:

function setIntervalUsingRAF(callback, interval) {
  // ...省略代码
  let rafId;
  function loop(currentTime) {
    rafId = requestAnimationFrame(loop);

    // ...省略代码
  }
  rafId = requestAnimationFrame(loop);
}

最后返回一个函数来取消定时器的功能:

function setIntervalUsingRAF(callback, interval) {
  // ...省略代码
  let rafId;
  function loop(currentTime) {
    rafId = requestAnimationFrame(loop);

    // ...省略代码
  }
  rafId = requestAnimationFrame(loop);
  return function cancalLoop() {
    cancelAnimationFrame(rafId);
  };
}

这时候如果在回调函数中执行返回的取消函数,那么取消的是下一次的执行。比如:

let count = 1;
const cancalSetInterval = setIntervalUsingRAF(() => {
  if (count > 3) {
    cancalSetInterval();
  }
  console.log(count);
  count++;
}, 1000);
// 1
// 2
// 3
// 4

由于取消的是下一次的执行,就会多输出一次(4)。

完整代码

function setIntervalUsingRAF(callback, interval) {
  let startTime = performance.now(); // 获取当前毫秒级的时间
  let elapsedTime = 0; // 保存累积的时间
  let rafId; // 保存每次执行 requestAnimationFrame 的 ID
  function loop(currentTime) {
    // 由于 requestAnimationFrame 是异步执行回调函数的
    // 所以递归调用 requestAnimationFrame 可以放到最前面
    rafId = requestAnimationFrame(loop);

    const deltaTime = currentTime - startTime;
    elapsedTime += deltaTime;

    if (elapsedTime >= interval) {
      // 大于设置的时间间隔,就执行回调函数,并且重置 elapsedTime 变量
      callback();
      elapsedTime = 0;
    }

    startTime = currentTime;
  }
  rafId = requestAnimationFrame(loop);
  // 返回取消定时器的函数
  return function cancalLoop() {
    cancelAnimationFrame(rafId);
  };
}