欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

解析浏览器渲染的“一帧”——事件循环、帧动画、空闲回调

程序员文章站 2022-04-05 10:28:36
...

概述

一般浏览器的刷新率为60HZ,即1秒钟刷新60次。1000ms / 60hz = 16.6 ,大概每过16.6ms浏览器会渲染一帧画面。

在这段时间内,浏览器大体会做两件事:task与render。

task被称为宏任务,包括 setTimeout,setInterval,setImmediate,postMessage,requestAnimationFrame,I/O,DOM 事件 等。

render是指渲染页面。

eventLoop

宏任务事件循环:

  1. 将新产生的task插入不同task queue中。

    task按优先级被划分到不同的task queue中。

    比如:为了及时响应用户交互,浏览器会为鼠标键盘(Mouse、Key)事件所在task queue分配3/4优先权。

  2. 按优先级从某个task queue中选择一个task作为本次要执行的task

task执行过程中如果调用 Promise、MutationObserver、process.nextTick 会将其作为 微任务 保存在microTask queue中。

每当执行完task,在执行下一个task前,都需要检查 microTask queue,执行并清空里面的microTask。在当前的微任务没有执行完成时,是不会执行下一个宏任务的。

一个????:

setTimeout(() => console.log('timeout'));
Promise.resolve().then(() => {
    console.log('promise1');
    Promise.resolve().then(() => console.log('Promise2'));
});
console.log('global');

执行过程为:

  1. “全局作用域的代码执行”是第一个task
  2. 执行过程中调用setTimeout计时器线程会去处理计时,在计时结束后会将计时器回调加入task queue中。
  3. 调用Promise.resolve,产生microTask,插入microTask queue
  4. 打印global
  5. “全局作用域的代码执行”的task执行完毕,开始遍历清理microTask queue
  6. 打印promise1
  7. 调用Promise.resolve,产生microTask,插入当前microTask queue
  8. 继续遍历microTask queue,执行microTask打印promise2
  9. 开始第二个task,打印timeout

再来一个????:

setTimeout(_ => console.log(4))

new Promise(resolve => {
  resolve()
  console.log(1)
}).then(_ => {
  console.log(3)
})

console.log(2);

上述代码的执行顺序就是按照序号来输出的。

所有会进入microTask queue的异步都是指的事件回调中的那部分代码

也就是说new Promise在实例化的过程中所执行的代码都是同步进行的,而then中注册的回调才是异步执行的。

在同步代码执行完成后才回去检查是否有异步任务完成,并执行对应的回调,而微任务又会在宏任务之前执行。

一帧可能执行多个task

执行如下代码后,屏幕会先显示红色再显示黑色,还是直接显示黑色?

document.body.style.background = 'red';
setTimeout(function () {
    document.body.style.background = 'black';
})

答案是:不一定。

如果这2个task在同一帧中执行,则页面渲染一次,直接显示黑色(如下图情况一)。

如果这2个task被分在不同帧中执行,则每一帧页面会渲染一次,屏幕会先显示红色再显示黑色(如下图情况二)。

如果我们将setTimeout的延迟时间增大到17ms,那么基本可以确定这2个task会在不同帧执行,则“屏幕会先显示红色再显示黑色”的概率会大很多。

requestAnimationFrame

可以发现,task没有办法精准的控制执行时机。那么有什么办法可以保证代码在每一帧都执行呢?

答案是:使用 requestAnimationFrame(简称rAF)。

rAF会在每一帧render前被调用。一般被用来绘制动画,因为当动画代码执行完后接下来就进入render。动画效果可以最快被呈现。

如下代码执行结果是什么呢:

setTimeout(() => {
  console.log("setTimeout1");
  requestAnimationFrame(() => console.log("rAF1"));
})
setTimeout(() => {
  console.log("setTimeout2");
  requestAnimationFrame(() => console.log("rAF2"));
})

Promise.resolve().then(() => console.log('promise1'));
console.log('global');

// 大概率是1. global 2. promise1 3. setTimeout1 4. setTimeout2 5. rAF1 6. rAF2

setTimeout1setTimeout2作为2个task,使用默认延迟时间(不传延迟时间参数时,延迟的最小值和浏览器的刷新频率有关系,大概会有4ms延迟),那么大概率会在同一帧调用。

????rAF1rAF2则一定会在不同帧的render前调用。

所以,大概率我们会在同一帧先后调用setTimeout1setTimeout2rAF1,再在另一帧调用rAF2

requestIdleCallback

如果渲染完成后还有空闲时间,则requestIdleCallbackAPI会被调用。

掉帧与时间切片

如果taskA执行时间超过了16.6ms(比如taskA中有个很耗时的while循环)。

那么这一帧就没有时间render,页面直到下一帧render后才会更新。表现为页面卡顿一帧,或者说掉帧。

最好的办法是时间切片,把长时间task分割为几个短时间task。

这React15中,采用递归的不可中断方式构建虚拟DOM树

如果树层级很深,对应task的执行时间很长,就可能出现掉帧的情况。

为了解决掉帧造成的卡顿,React16将递归的构建方式改为可中断的遍历。React16就是基于requestIdleCallbackAPI,实现了自己的Fiber Reconciler。

5ms的执行时间划分task,每遍历完一个节点,就检查当前task是否已经执行了5ms

如果超过5ms,则中断本次task

通过将task执行时间切分为一个个小段,减少长时间task造成无法render的情况。这就是时间切片。

参考资料

【一位摸金校尉决定转行前端】

【微任务、宏任务与Event-Loop】