一文带你了解 requestAnimationFrame

2,840 阅读7分钟

在Web应用中,实现动画效果的方法比较多,Javascript 中可以通过定时器 setTimeout 来实现,css3 可以使用 transition 和 animation 来实现,html5 中的 canvas 也可以实现。除此之外,html5 还提供一个专门用于请求动画的API,那就是 requestAnimationFrame,顾名思义就是请求动画帧

简介

window.requestAnimationFrame() 会告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行

若你想在浏览器下次重绘之前继续更新下一帧动画,那么回调函数自身必须再次调用window.requestAnimationFrame()

简单来说,如果只是单纯的调用了一次 window.requestAnimationFrame(callback) 函数,浏览器在下一次重绘之前调用 callback 函数之后,这个流程就算正式结束了,如果你还想在下一次浏览器重绘之前再次调用回调函数更新动画,那么就需要在回调函数内部递归调用 window.requestAnimationFrame 函数

用法

回调函数会被传入 DOMHighResTimeStamp 参数,DOMHighResTimeStamp 指当前被 requestAnimationFrame() 排序的回调函数被触发的时间。在同一个帧中的多个回调函数,它们每一个都会接受到一个相同的时间戳,即使在计算上一个回调函数的工作负载期间已经消耗了一些时间。该时间戳是一个十进制数,单位毫秒,最小精度为1ms(1000μs)

window.requestAnimationFrame(function (timeStamp) {
  console.log(timeStamp);
  window.requestAnimationFrame(arguments.callee);
})

下图左边为我们自己打印的屏幕刷新间隔(60Hz),右边为 requestAnimationFrame 中回调函数执行时打印出来的时间戳,发现每一次回调函数均在不同的时间间隔内被执行

请确保总是使用第一个参数(或其它获得当前时间的方法)计算每次调用之间的时间间隔,否则动画在高刷新率的屏幕中会运行得更快。因为人眼对图像会有短暂的记忆,对应60Hz的屏幕刷新率差不多就够了。对于刷新率较高的设备,其实没有必要每次浏览器刷新都更新图像

下面是 MDN 官网给的示例:2s中内匀速移动一个元素

const element = document.getElementById('some-element-you-want-to-animate');
let start;
function step(timestamp) {
  if (start === undefined)
    start = timestamp;
  const elapsed = timestamp - start;
  //这里使用`Math.min()`确保元素刚好停在200px的位置。
  element.style.transform = 'translateX(' + Math.min(0.1 * elapsed, 200) + 'px)';
  if (elapsed < 2000) { // 在两秒后停止动画
    window.requestAnimationFrame(step);
  }
}
window.requestAnimationFrame(step);

补充: window.requestAnimationFrame() 的返回值是一个 long 整数,请求 ID ,是回调列表中唯一的标识。是个非零值,没别的意义。你可以传这个值给 window.cancelAnimationFrame() 以取消回调函数。

setTimeout

在很多时候,我们都是依赖 setTimeout 来实现动画——通过设置一个间隔时间动态的改变图像的位置,从而达到动画的效果。但我们会发现,利用 setTimetout 实现的动画在某些低端机上会出现卡顿、抖动的现象。 这种现象的产生有两个原因:

  • setTimeout 的执行时间并不是确定的。在 Javascript 中, setTimeout 任务被放进了异步队列中,只有当主线程上的任务执行完以后,才会去检查该队列里的任务是否需要开始执行,因此 setTimeout 的实际执行时间一般要比其设定的时间晚一些。
  const start = Date.now()
  setTimeout(() => {
    console.log(Date.now() - start); // 1007
  }, 1000)
  • 刷新频率受屏幕分辨率和屏幕尺寸的影响,因此不同设备的屏幕刷新频率可能会不同,而 setTimeout 只能设置一个固定的时间间隔,这个时间不一定和屏幕的刷新时间相同。

以上两种情况都会导致 setTimeout 的执行步调和屏幕的刷新步调不一致,从而引起丢帧现象。 那为什么步调不一致就会引起丢帧呢?

首先要明白,setTimeout 的执行只是在内存中对图像属性进行改变,这个变化必须要等到屏幕下次刷新时才会被更新到屏幕上。如果两者的步调不一致,就可能会导致中间某一帧的操作被跨越过去,而直接更新下一帧的图像。假设屏幕每隔16.7ms刷新一次,而 setTimeout 每隔10ms设置图像向左移动1px, 就会出现如下绘制过程:

  • 第0ms: 屏幕未刷新,等待中,setTimeout 也未执行,等待中
  • 第10ms: 屏幕未刷新,等待中,setTimeout 开始执行并设置图像属性 left=1px
  • 第16.7ms: 屏幕开始刷新,屏幕上的图像向左移动了1px, setTimeout 未执行,继续等待中
  • 第20ms: 屏幕未刷新,等待中,setTimeout 开始执行并设置 left=2px
  • 第30ms: 屏幕未刷新,等待中,setTimeout 开始执行并设置 left=3px
  • 第33.4ms:屏幕开始刷新,屏幕上的图像向左移动了3px, setTimeout 未执行,继续等待中

从上面的绘制过程中可以看出,屏幕没有更新 left=2px 的那一帧画面,图像直接从1px的位置跳到了3px的的位置,这就是丢帧现象,这种现象会引起动画卡顿,从而给用户造成不好的体验

requestAnimationFrame 的优势

回调函数执行次数通常是每秒60次,但在大多数遵循 W3C 建议的浏览器中,回调函数执行次数通常与浏览器屏幕刷新次数相匹配。为了提高性能和电池寿命,因此在大多数浏览器里,当requestAnimationFrame() 运行在后台标签页或者隐藏的 ](https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/iframe) 里时,requestAnimationFrame() 会被暂停调用以提升性能和电池寿命

CPU节能

使用 setTimeout 实现的动画,当页面被隐藏或最小化时,setTimeout 仍然在后台执行动画任务,由于此时页面处于不可见或不可用状态,刷新动画是没有意义的,完全是浪费 CPU 资源。而requestAnimationFrame 则完全不同,当页面处理未激活的状态下,比如说缩小隐藏、切换页面等等,该页面的屏幕刷新任务也会被系统暂停,因此跟着系统步伐走的 requestAnimationFrame 也会停止渲染,当页面被重新激活时,动画才会从上次停留的地方继续执行,这种做法有效节省了 CPU 开销

注意:在笔者查阅有关 requestAnimationFrame 的文章时,经常会看到有文章说到请求动画帧可以用来做函数节流,在高频率事件(resizescroll 等)中,可以有效的防止在一个刷新间隔内执行多次函数。但在查阅了具体的资料和自己实践过之后发现,scrollresize 的过程其实就是浏览器自身的帧帧渲染,也就是说对应的事件在一个刷新间隔中只会执行一次,而就算使用了请求动画帧也是同样的效果。这样一来,使用 requestAnimationFrame 其实并没有起到优化的效果,并不适合做节流,还是更加适合在动画设计中使用

兼容性问题

由于 requestAnimationFrame 目前还存在兼容性问题,而且不同的浏览器还需要带不同的前缀。因此需要通过优雅降级的方式对 requestAnimationFrame 进行封装,优先使用高级特性,然后再根据不同浏览器的情况进行回退,直至只能使用 setTimeout 的情况。下面的代码就是有人在 github 上提供的 polyfill,详细介绍请参考 github代码

Polyfill 是一块代码(通常是 Web 上的 JavaScript),用来为旧浏览器提供它没有原生支持的较新的功能

if (!Date.now)
  Date.now = function () {
    return new Date().getTime();
  };
(function () {
  'use strict';
  var vendors = ['webkit', 'moz'];
  for (var i = 0; i < vendors.length && !window.requestAnimationFrame; ++i) {
    var vp = vendors[i];
    window.requestAnimationFrame = window[vp + 'RequestAnimationFrame'];
    window.cancelAnimationFrame = (window[vp + 'CancelAnimationFrame'] || window[vp +
                                                                                 'CancelRequestAnimationFrame']);
  }
  if (/iP(ad|hone|od).*OS 6/.test(window.navigator.userAgent) // iOS6 is buggy || !window.requestAnimationFrame ||
      !window.cancelAnimationFrame) {
    var lastTime = 0;
    window.requestAnimationFrame = function (callback) {
      var
      now = Date.now();
      var nextTime = Math.max(lastTime + 16, now);
      return setTimeout(function () {
        callback(lastTime = nextTime);
      }, nextTime - now);
    };
    window.cancelAnimationFrame = clearTimeout;
  }
}());

面试题

熟悉事件循环的朋友都知道 js 的异步任务分为了宏任务和微任务,但我们今天的主题 requestAnimationFrame 很特殊,我们不能很决定的说它是宏任务还是微任务,但我们可以将它理解为介于微任务和宏任务之间的任务

我们知道,每次执行宏任务之前都会先检查一下微任务队列是否有微任务存在,如果存在则会先执行微任务,等到所有的微任务执行完了之后才会去执行宏任务,但其实,执行每个宏任务之前,浏览器还会判断是否快要进行绘制了,如果是则会执行 requestAnimationFrame 的回调函数,如果还没到要重新绘制的时候则直接跳过 requestAnimationFrame 去执行宏任务

浏览器的绘制和屏幕的刷新率有关,一般为 60HZ,所以我们并不能确定 requestAnimationFrame 和其它宏任务之间的执行顺序,但能确定的是,它一定在微任务执行之后才会执行,下面看几道面试题:

  1. 请求动画帧和 setTimeout 结合,输出下列的打印结果:
  setTimeout(() => {
    console.log(1);
  });
  requestAnimationFrame(() => {
    console.log(2);
  });
  setTimeout(() => {
    console.log(4);
  });
  Promise.resolve(3).then((res) => {
    console.log(res);
  });

答案可能是 3 -> 1 -> 2 -> 43 -> 2 ->1 -> 43 -> 1 -> 4 -> 2 中的任意一个

  • Promise.then 中的回调函数属于微任务,微任务队列的优先级最高,下次事件循环的时候会最先执行
  • 第一和第三个 setTimeout 对应的回调会按照顺序放到宏任务队列中,可以保证1一定是在4之前打印
  • 但由于每次执行宏任务之前不仅要检查有无微任务,还需要检查浏览器是否准备重绘,所以 requestAnimationFrame 执行的时机和其它的宏任务并不能确定
  1. React 中与 useEffect 结合,输出下列的打印结果:
function App() {
  requestAnimationFrame(() => {
    console.log(1);
  });

  useEffect(() => {
    console.log(2);
  }, []);

  return <div></div>;
}

答案同样不是唯一的,打印结果为 1 -> 22 -> 1useEffect 中的回调函数可以看成是一个特殊的宏任务,跟上题中的意思差不多,既然是宏任务,每次执行前就有可能会执行 requestAnimationFrame 的回调,当然也可能不执行,所以具体的顺序并不能确认

参考

blog.csdn.net/VhWfR2u02Q/…

知乎某文章

developer.mozilla.org/zh-CN/docs/…