2022年了,真的懂requestAnimationFrame么?

13,828 阅读9分钟

requestAnimationFrame 是什么

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

上面这句话引自MDN

通俗点讲就是该API能以浏览器的显示频率来作为其动画动作的频率,比如浏览器每10ms刷新一次,动画回调也每10ms调用一次,这样就不会存在过度绘制的问题,动画不会掉帧,自然流畅。

基本语法

window.requestAnimationFrame(callback);
  • callback 下一次重绘之前更新动画帧所调用的函数(即上面所说的回调函数)。该回调函数会被传入DOMHighResTimeStamp参数,该参数与performance.now()的返回值相同,它表示requestAnimationFrame() 开始去执行回调函数的时刻。

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

基本应用

用法其实跟 setTimeout 完全一致,只不过当前的时间间隔是跟着系统的绘制频率走,是固定的

// 调用的是系统的时间间隔
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);
  }
}

var timer1 = window.requestAnimationFrame(step);

//取消回调函数
cancelAnimationFrame(timer1);

兼容性处理

firefox、chrome、ie10以上, requestAnimationFrame 的支持很好,但不兼容 IE9及以下浏览器

//简单的兼容性处理
window.requestAnimationFrame = (function() {
  return window.requestAnimationFrame ||
         window.webkitRequestAnimationFrame ||
         window.mozRequestAnimationFrame ||
         function(callback) {
          window.setTimeout(callback, 1000/60);
         }
})();

但是还是存在问题,并不是所有的设备的绘制时间间隔是1000/60ms,以及上面并没有cancel相关方法,所以,就有了下面这份更全面的兼容方法

(function() {
  var lastTime = 0;
  var vendors = ['webkit', 'moz'];
  //如果window.requestAnimationFrame为undefined先尝试浏览器前缀是否兼容
  for(var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) {
    window.requestAnimationFrame = window[vendors[x] + 'RequestAnimationFrame'];
    window.cancelAnimationFrame = window[vendors[x] + 'CancelAnimationFrame'] ||//webkit中此取消方法的名字变了
                                  window[vendors[x] + 'CancelRequestAnimationFrame'];
  }
  //如果仍然不兼容,则使用setTimeOut进行兼容操作
  if(!window.requestAnimationFrame) {
    window.requestAnimationFrame = function(callback, element) {
      var currTime = new Date().getTime();
      var timeToCall = Math.max(0, 16.7 - (currTime - lastTime));
      var id = window.setTimeout(function() {
        callback(currTime + timeToCall);
      }, timeToCall);
      lastTime = currTime + timeToCall;
      return id; 
    }
  }
 
  if(!window.cancelAnimationFrame) {
    window.cancelAnimationFrame = function(id) {
      clearTimeout(id);
    }
  }
})();

上述的代码是由Opera浏览器的技术师Erik Möller设计的,使得更好得兼容各种浏览器,但基本上他的代码就是判断使用4ms还是16ms的延迟,来最佳匹配60fps。

优点

  • 使得动画更加流畅,防止动画失帧 requestAnimationFrame 会把每一帧中的所有 DOM 操作集中起来,在一次重绘或回流中就完成,并且重绘或回流的时间间隔紧紧跟随浏览器的刷新频率

  • 资源节能(Cpu、内存等)

    1. 在隐藏或不可见的元素中,requestAnimationFrame 将不会进行重绘或回流,这当然就意味着更少的 CPU、GPU 和内存使用量
    2. requestAnimationFrame 是由浏览器专门为动画提供的 API,在运行时浏览器会自动优化方法的调用,并且如果页面不是激活状态下的话,动画会自动暂停,有效节省了 CPU 开销

注意点

::: warning

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

:::

  • 当你准备更新动画时你应该调用此方法。这将使浏览器在下一次重绘之前调用你传入给该方法的动画函数(即你的回调函数)。回调函数执行次数通常是每秒60次,但在大多数遵循W3C建议的浏览器中,回调函数执行次数通常与浏览器屏幕刷新次数相匹配。为了提高性能和电池寿命,因此在大多数浏览器里,当requestAnimationFrame() 运行在后台标签页或者隐藏的 里时,requestAnimationFrame() 会被暂停调用以提升性能和电池寿命。

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

requestAnimationFrame 和setInterval、setTimeout的联系

  • 因为 setTimeout 和 setInterval 是异步 api,必须需要等同步任务执行,还需要等待微任务完成以后,然后才会去执行当前这个回调函数。

  • 这里会存在一个问题,没有办法去精准地把时间定位到,哪怕你写成 16,它也没有办法,让时间精准定位到 16。时间间隔没有办法保证。

  • 与 setTimeout 相比,requestAnimationFrame 最大的优势是 由系统来决定回调函数的执行时机。 如果系统绘制率是 60Hz,那么回调函数就每16.7ms 被执行一次,如果绘制频率是75Hz,那么这个间隔时间就变成了 1000/75=13.3ms,也就是说它的时间间隔,是跟着系统的绘制频率走。

性能对比

  • 写个例子看下各种实现的性能指标情况
<div id="test" style="width: 0px; height: 12px; line-height: 12px; margin-bottom: 5px; background: rgb(185, 236, 243);"></div>
当前进度:<span id="progress">0%</span>
<button id="btn">开启</button>
<script>
const btn = document.getElementById('btn');
//使用 requestAnimationFrame 实现
btn.onclick = function() {
  var timer = requestAnimationFrame(function fn() {
    if (parseInt(test.style.width) < 300) {
      test.style.width = parseInt(test.style.width) + 3 + 'px';
      progress.innerHTML = parseInt(test.style.width) / 3 + '%';
      timer = requestAnimationFrame(fn);
    } else {
      cancelAnimationFrame(timer);
    }
  });
}
//使用 setInterval 实现
// btn.onclick = function() {
//   var timer = setInterval(function () {
//     if (parseInt(test.style.width) < 300) {
//       test.style.width = parseInt(test.style.width) + 3 + 'px';
//       progress.innerHTML = parseInt(test.style.width) / 3 + '%';
//     } else {
//       clearInterval(timer);
//     }
//   }, 17);
// }

//使用 setTimeout 实现
//btn.onclick = function() {
//     var timer = setTimeout(function fn() {
//     if (parseInt(test.style.width) < 300) {
//       test.style.width = parseInt(test.style.width) + 3 + 'px';
//       progress.innerHTML = parseInt(test.style.width) / 3 + '%';
//       timer = setTimeout(fn, 17);
//     } else {
//       clearTimeout(timer);
//     }
//   }, 17);
// }
</script>
  • 使用setTimeout和setInterval的帧率

image.png

  • 使用requestAnimationFrame的帧率

image.png

深入requestAnimationFrame 与 Event Loop

Event Loop(事件循环)是用来协调事件、用户交互、脚本、渲染、网络的一种浏览器内部机制。

  • Event Loop 在浏览器内也分几种:
    • window event loop
    • worker event loop
    • worklet event loop

我们这里主要讨论的是 window event loop。也就是浏览器一个渲染进程内主线程所控制的 Event Loop。

Event Loop的基本处理过程

  1. 在所选 task queue (taskQueue)中约定必须包含一个可运行任务。如果没有此类 task queue,则跳转至下面 microtasks 步骤。
  2. 让 taskQueue 中最老的 task (oldestTask) 变成第一个可执行任务,然后从 taskQueue 中删掉它。
  3. 将上面 oldestTask 设置为 event loop 中正在运行的 task。
  4. 执行 oldestTask。
  5. 将 event loop 中正在运行的 task 设置为 null。
  6. 执行 microtasks 检查点(也就是执行 microtasks 队列中的任务)。
  7. 设置 hasARenderingOpportunity 为 false。
  8. 更新渲染。
  9. 如果当前是 window event loop 且 task queues 里没有 task 且 microtask queue 是空的,同时渲染时机变量 hasARenderingOpportunity 为 false ,去执行 idle period(requestIdleCallback)。
  10. 返回到第一步。

大体上来说,event loop 就是不停地找 task queues 里是否有可执行的 task ,如果存在即将其推入到 call stack (执行栈)里执行,并且在合适的时机更新渲染。

下图3(源)是 event loop 在浏览器主线程上运行的一个清晰的流程:

在上面规范的说明中,渲染的流程是在执行 microtasks 队列之后,更进一步,再来看看渲染的处理过程。

更新渲染过程

  1. 遍历当前浏览上下文中所有的 document ,必须按在列表中找到的顺序处理每个 document 。
  2. 渲染时机(Rendering opportunities):如果当前浏览上下文中没有到渲染时机则将所有 docs 删除,取消渲染(此处是 否存在渲染时机由浏览器自行判断,根据硬件刷新率限制、页面性能或页面是否在后台等因素)。
  3. 如果当前文档不为空,设置 hasARenderingOpportunity 为 true 。
  4. 不必要的渲染(Unnecessary rendering):如果浏览器认为更新文档的浏览上下文的呈现不会产生可见效果且文档的 animation frame callbacks 是空的,则取消渲染。(终于看见 requestAnimationFrame 的身影了
  5. 从 docs 中删除浏览器认为出于其他原因最好跳过更新渲染的文档。
  6. 如果文档的浏览上下文是顶级浏览上下文,则刷新该文档的自动对焦候选对象。
  7. 处理 resize 事件,传入一个 performance.now() 时间戳。
  8. 处理 scroll 事件,传入一个 performance.now() 时间戳。
  9. 处理媒体查询,传入一个 performance.now() 时间戳。
  10. 运行 CSS 动画,传入一个 performance.now() 时间戳。
  11. 处理全屏事件,传入一个 performance.now() 时间戳。
  12. 执行 requestAnimationFrame 回调,传入一个 performance.now() 时间戳。
  13. 执行 intersectionObserver 回调,传入一个 performance.now() 时间戳。
  14. 对每个 document 进行绘制。
  15. 更新 ui 并呈现。

流程基本如下图所示

至此,requestAnimationFrame 的回调时机就清楚了,它会在 style/layout/paint 之前调用。

浏览器渲染有个渲染时机(Rendering opportunity)的问题,也就是浏览器会根据当前的浏览上下文判断是否进行渲染,它会尽量高效,只有必要的时候才进行渲染,如果没有界面的改变,就不会渲染。按照规范里说的一样,因为考虑到硬件的刷新频率限制、页面性能以及页面是否存在后台等等因素,有可能执行完 setTimeout 这个 task 之后,发现还没到渲染时机,所以 setTimeout 回调了几次之后才进行渲染

requestAnimationFrame的应用场景

  • 大数据渲染

在大数据渲染过程中,比如表格的渲染,如果不进行一些性能策略处理,就会出现 UI 冻结现象,用户体验极差。有个场景,将后台返回的十万条记录插入到表格中,如果一次性在循环中生成 DOM 元素,会导致页面卡顿5s左右。这时候我们就可以用 requestAnimationFrame 进行分步渲染,确定最好的时间间隔,使得页面加载过程中很流畅。

var total = 100000;
var size = 100;
var count = total / size;
var done = 0;
var ul = document.getElementById('list');

function addItems() {
    var li = null;
    var fg = document.createDocumentFragment();

    for (var i = 0; i < size; i++) {
        li = document.createElement('li');
        li.innerText = 'item ' + (done * size + i);
        fg.appendChild(li);
    }

    ul.appendChild(fg);
    done++;

    if (done < count) {
        requestAnimationFrame(addItems);
    }
};
requestAnimationFrame(addItems);
  • 实现动画

css3实现使得性能和流畅度都得到了很大的提升,但同时局限性也挺大比如不是所有的属性都能参与动画,动画过程不能完全控制,动画缓动效果太小等等。

刚好相反的是setTimeout和setInterval能达成更多的可控性质的自有帧动画,但是由于刷新时间和定时器时间不同会出现掉帧现象,定时器时间设的越短掉帧时间越严重,而且性能牺牲很严重

然而 requestAnimationFrame 的出现让我们有了除了这两种我们常用的方案之外的另一种更优的选择

屏幕绘制频率知识

即图像在屏幕上更新的速度,也即屏幕上的图像每秒钟出现的次数,它的单位是赫兹(Hz)。 对于一般笔记本电脑,这个频率大概是60Hz, 可以在桌面上 右键 > 屏幕分辨率 > 高级设置 > 监视器 中查看和设置。这个值的设定受屏幕分辨率、屏幕尺寸和显卡的影响,原则上设置成让眼睛看着舒适的值都行。

市面上常见的显示器有两种,即 CRT和 LCD, CRT 是一种使用阴极射线管(Cathode Ray Tube)的显示器,LCD 就是我们常说的液晶显示器( Liquid Crystal Display)。 CRT 是一种使用阴极射线管的显示器,屏幕上的图形图像是由一个个因电子束击打而发光的荧光点组成,由于显像管内荧光粉受到电子束击打后发光的时间很短,所以电子束必须不断击打荧光粉使其持续发光。电子束每秒击打荧光粉的次数就是屏幕绘制频率。

而对于 LCD 来说,则不存在绘制频率的问题,因为 LCD 中每个像素都在持续不断地发光,直到不发光的电压改变并被送到控制器中,所以 LCD 不会有电子束击打荧光粉而引起的闪烁现象。

因此,当你对着电脑屏幕什么也不做的情况下,显示器也会以每秒60次的频率正在不断的更新屏幕上的图像。为什么你感觉不到这个变化? 那是因为人的眼睛有视觉停留效应,即前一副画面留在大脑的印象还没消失,紧接着后一副画面就跟上来了,这中间只间隔了16.7ms(1000/60≈16.7), 所以会让你误以为屏幕上的图像是静止不动的。而屏幕给你的这种感觉是对的,试想一下,如果刷新频率变成1次/秒,屏幕上的图像就会出现严重的闪烁,这样就很容易引起眼睛疲劳、酸痛和头晕目眩等症状。

  • CSS 动画原理

根据上面的原理我们知道,你眼前所看到图像正在以每秒 60 次的频率绘制,由于频率很高,所以你感觉不到它在绘制。而 动画本质就是要让人眼看到图像被绘制而引起变化的视觉效果,这个变化要以连贯的、平滑的方式进行过渡。 那怎么样才能做到这种效果呢?

60Hz 的屏幕每 16.7ms 绘制一次,如果在屏幕每次绘制前,将元素的位置向左移动一个像素,即1px,这样一来,屏幕每次绘制出来的图像位置都比前一个要差1px,你就会看到图像在移动;而由于人眼的视觉停留效应,当前位置的图像停留在大脑的印象还没消失,紧接着图像又被移到了下一个位置,这样你所看到的效果就是,图像在流畅的移动。这就是视觉效果上形成的动画。

作者建立了一个全栈大前端交流群,喜欢讨论热点技术,入群请加微信:mokinzhao

参考

requestAnimationFrame理解与实践

requestAnimationFrame 执行机制探索