渲染帧

我们通常看的视频其实都是一张张图片播放的,图片播放的频率越快(帧数越高)就会看起来越流畅。浏览器的渲染其实也是一样的。现代浏览器通常以 60HZ刷新率(即每秒60帧,每帧约16.67ms)为目标进行渲染。也就是每隔 16.67ms 渲染一张图片,那如果浏览器发生卡顿(主线程被 long task 长时间占据)就会导致浏览器没法每隔 16.67ms 渲染一张图片。就会导致视觉上看起来是卡卡的。如下所示

从上面能看出刚开始还是比较流程,后面点击了 longtask 之后,开始卡顿。整个的代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const box = document.getElementById('box');
let translateX = 0
let direction = 1;

// 模拟一个长时间运行的任务
function longTask() {
const start = Date.now();
while (Date.now() - start < 200) {
// 模拟耗时操作
}
}

// 使用 requestAnimationFrame 进行动画
function animate() {
translateX += direction * 4;
if (translateX > 200) {
direction = -1
} else if (translateX <= 0) {
direction = 1
}
box.style.transform = `translateX(${translateX}px)`;
requestAnimationFrame(animate);
}
// 开始动画
requestAnimationFrame(animate);

从上面的代码可以看出,点击 longtask 之后,进行了 5 次 200ms 的死循环,导致主线程被占用 200ms。

划重点 那么为什么主线程占用会导致动画卡顿呢
首先我们先要知道一个渲染帧里面浏览器做了什么事情?

  1. execute js(执行js)
  2. calculate style(样式计算)
  3. Layout(布局)
  4. Paint(绘制)
  5. Layerize(分层)
  6. Commit(提交绘制)

我们众所周知的 requestAnimationFrame的回调函数就发生在 1 和 2 的中间。

在 longtask 介入之前我们看一下Chrome Performance 的表现

从上图可以看出在 16.6ms 的渲染帧里面在前面极短的时间就完成了一个渲染帧第 1 步到 6 步的所有事情。因为此时第一步并没有js代码执行,主线程是空闲的。所以此时动画就会特别流畅。

倘若在一个 16.6ms 的渲染帧里面,执行 js 需要花费的时间就不止 16.6ms怎么办?
那么很遗憾当前这个渲染帧里面的绘制就会被错过,也就是通常说的掉帧,看起来动画就不连贯。

当 longtask 介入之后,主线程开启了一个时间长达 200ms 的长任务,导致了 200ms 内 requestAnimateFrame 得不到执行,所以动画就会一直卡在那里。

所以想要保证高质量且流畅的用户体验,主线程不要执行长任务,可以使用 requestIdleCallback 在当前渲染帧,执行完成第6步之后,如果还有剩余时间则该方法注入的回调函数将被执行。我们比较耗时的任务可以在里面拆分成子任务执行。

顺便提一下,React 18 中的可中断渲染就是为了解决React对比和渲染组件树时间过长(JS 在一个渲染帧里面执行太久)从而导致页面无响应或卡顿的问题,后续文章再详细展开讲一下。