浏览器渲染优化——打造60FPS的丝滑页面

2,337 阅读14分钟

想让网页变得丝滑,首先,我们需要一个标准来判断什么样的网页是丝滑的;其次,我们要准确的测量出网页的性能数据;最后,使用有效的方法让网页变得丝滑。

本篇文章将针对这三个方面进行详细的介绍。

1. RAIL

到底怎样的网页是丝滑的?我们需要一个标准来辅助判断我们的网页是否丝滑。

Chrome团队提出了一个以用户为中心的性能模型被称为RAIL,它为工程师提供一个目标,只要达到目标的网页,用户就会觉得很流畅;它将用户体验拆解为一些关键操作,例如:点击,加载等;并给这些操作规定一个目标,例如:点击一个按钮后,多长时间给反馈用户会觉得流畅。

RAIL将影响性能的行为划分为四个方面,分别是:Response响应、Animation动画、Idle空闲 与 Load加载。没错,RAIL这个名字来自于这四个单词的首字母,方便记忆。

1.1 响应Response

研究表明,100ms内对用户的输入操作进行响应,通常会被人类认为是立即响应。时间再长,操作与反应之间的连接就会中断,人们就会觉得它的操作有延迟。例如:当用户点击一个按钮,如果100ms内给出响应,那么用户就会觉得响应很及时,不会察觉到丝毫延迟感。

1.2 动画Animation

现如今大多数设备的屏幕刷新频率是60Hz,也就是每秒钟屏幕刷新60次;因此网页动画的运行速度只要达到60FPS,我们就会觉得动画很流畅。

FPSFramesPerSecond 指的画面每秒钟传输的帧数,60FPS指的是每秒钟60帧;换算下来每一帧差不多是16毫秒。

(1 秒 = 1000 毫秒) / 60 帧 = 16.66 毫秒/帧

但通常浏览器需要花费一些时间将每一帧的内容绘制到屏幕上(包括样式计算、布局、绘制、合成等工作),所以通常我们只有10毫秒来执行JS代码。

1.3 空闲Idle

为了更好的性能,通常我们会充分利用浏览器 空闲周期Idle Period 做一些低优先级的事情。例如:在空闲周期预请求一些接下来可能会用到的数据或上报分析数据等。

RAIL规定,空闲周期内运行的任务不得超过50ms,当然不止RAIL规定,W3C性能工作组的Longtasks标准也规定了超过50毫秒的任务属于长任务,那么50ms这个数字是怎么得来的呢?

浏览器是单线程的,这意味着同一时间主线程只能处理一个任务,如果一个任务执行时间过长,浏览器则无法执行其他任务,用户会感觉到浏览器被卡死了,因为他的输入得不到任何响应。

为了达到100ms内给出响应,将空闲周期执行的任务限制为50ms意味着,即使用户的输入行为发生在空闲任务刚开始执行,浏览器仍有剩余的50ms时间用来响应用户输入,而不会产生用户可察觉的延迟。如下图所示:

image

事实上,不论是空闲任务还是高优先级的其他任务,执行时间都不得超过50ms

1.4 加载Load

如果不能在1秒钟内加载网页并让用户看到内容,用户的注意力就会分散。用户会觉得他要做的事情被打断,如果10秒钟还打不开网页,用户会感到失望,会放弃他们想做的事,以后他们或许都不会再回来。

1.5 小结

通过RAIL,我们可以判断出我们的网页是否丝滑。RAIL从用户感知角度出发规定了一些指标,只要我们的网页符合标准,则我们的网页是丝滑的,用户会觉得我们的网页很流畅。

RAIL关键指标用户操作
响应(Response)小于100ms点击按钮。
动画(Animation)小于16ms滚动页面,拖动手指,播放动画等。
空闲(Idle)小于50ms用户没有与页面交互,但应该保证主线程足够处理下一个用户输入。
加载(Load)1000ms用户加载页面并看到内容。

2. 像素管道

像素管道是制作丝滑网页的灵魂,我们后面将要介绍的技术都与它有关。

image

上图就是像素管道,通常我们会使用JS修改一些样式,随后浏览器会进行样式计算,然后进行布局,绘制,最后将各个图层合并在一起完成整个渲染的流程,这期间的每一步都有可能导致页面卡顿。

注意,并不是所有的样式改动都需要经历这五个步骤。举例来说:如果在JS中修改了元素的几何属性(宽度、高度等),那么浏览器需要需要将这五个步骤都走一遍。但如果您只是修改了文字的颜色,则布局(Layout)是可以跳过去的,如下图所示:

image

除了最后的合成,前面四个步骤在不同的场景下都可以被跳过。例如:CSS动画就可以跳过JS运算,它不需要执行JS。

css-triggers给出了不同的CSS属性被更改后会触发像素管道的哪些步骤。

简单来说,像素管道经历的步骤越多,渲染时间就越长,单个步骤内可能也会因为某种原因而变得耗时很长;所以不管是步骤多还是单个步骤耗费的时间长,最终都会导致整体渲染时间变长。整体时间越长就越有可能超出RAIL所规定的指标。

举个简单的例子:网页动画的渲染若是达到60FPS,则动画不会丢帧。假设渲染管道的布局与绘制耗费了10ms,那么加上样式计算与合成的时间,则留给JS处理动画的时间就只有几毫秒,如果JS的执行超过了几毫秒那么该动画每一帧所耗费的时间就会超过16ms,这时候动画一定会丢帧,用户用肉眼就可以看到明显的卡顿。

当然,即便能保证每一帧的总耗时小于16ms,依然无法保证不会丢帧。关于这点后面我们会详细介绍。

3. 如何让动画更丝滑

动画需要达到60FPS才能变得丝滑,本节我们介绍如何让动画在不丢帧的情况下稳定保持在60FPS。

3.1 使用Chrome开发者工具测量动画性能

在评估动画性能时,通常需要逐帧评估像素管道的开销;使用 Chrome 开发者工具可以辅助我们进行精准的测量。

在Chrome开发者工具中,点击Performance面板,然后选中Screenshots复选框。如下图所示:

image

然后点击录制按钮,录制完毕后点击停止按钮就可以捕获当前页面的性能数据。如下图所示:

image

捕获出的结果如下图所示:

image

我们可以放大主线程从而精准的看到每一帧浏览器都执行了哪些任务以及每个任务耗费了多长时间。如下图所示:

image

从上图可以看到,浏览器每一帧渲染所执行的任务与前面我们介绍的像素管道是相同的。上图中因为是CSS动画,所以没有运行JS,但每一帧都需要计算样式、布局、绘制与合成。

3.2 如何让JS动画更丝滑

JS动画是使用定时器不停的执行JS,通过在JS中修改样式完成网页动画;若想保证动画流畅,从JS的执行到最终浏览器显示出画面,每一帧总耗时最多16ms,这样动画才能达到60FPS。

如图3-4所示,即便是在不执行JS的情况下,浏览器计算样式、布局、绘制等工作也是需要时间的,所以需要给浏览器预留出 充分的时间6ms 做这些事情,现在留给JS的执行时间就只有 10ms

image

一旦JS运行时间超过10ms,就很有可能导致这一帧的像素管道整体耗时超过16ms,从而无法达到60FPS,但你以为只要保证JS的运行时间小于10ms就一定能保证不丢帧?Naive~

3.2.1 使用requestAnimationFrame

即便你能保证每一帧的总耗时都小于16ms,也无法保证一定不会出现丢帧的情况,这取决于触发JS执行的方式。

假设使用 setTimeoutsetInterval 来触发JS执行并修改样式从而导致视觉变化;那么会有这样一种情况,因为setTimeoutsetInterval没有办法保证回调函数什么时候执行,它可能在每一帧的中间执行,也可能在每一帧的最后执行。所以会导致即便我们能保障每一帧的总耗时小于16ms,但是执行的时机如果在每一帧的中间或最后,最后的结果依然是没有办法每隔16ms让屏幕产生一次变化。如下图所示:

image

也就是说,即便我们能保证每一帧总体时间小于16ms,但如果使用定时器触发动画,那么由于定时器的触发时机不确定,所以还是会导致动画丢帧。现在整个Web只有一个API可以解决这个问题,那就是requestAnimationFrame,它可以保证回调函数稳定的在每一帧最开始触发。如下图所示:

image

3.2.2 避免FSL

FSLForced Synchronous Layouts被称为强制同步布局;前面介绍像素管道时说过,将一帧送到屏幕会通过如下顺序:

image

当下一次循环到来时浏览器还没进重排(因为一直处于 JS 阶段) ,为了获取正确的 width ,浏览器就不得不立刻重新 Layout 获取一个最新值,从而失去了 浏览器自身的批量更新的优化,这就叫 FSL强制同步布局

为什么叫强制呢,大多数浏览器通过队列化修改并批量执行来优化重排过程(就是上面说的异步布局),但是如果触发了强制同步布局 ,每经过一次循环,都会要求浏览器强制刷新队列并要求计划任务立刻执行,这就失去了浏览器对重排的优化。

js 执行过程中,所有涉及到 GUI 更新的操作都会被放置在一个队列,等待 js 线程空闲后,再执行渲染线程。但是如果在 js 执行过程中触发了重排,则会清空队列,强制挂起 js 线程,使用渲染线程做一次回流后再回到 js 线程,好计算出正确的布局相关值。因为回流、线程间通信会有一定的代价,所以才有所谓的 dom 操作性能问题。

image

通常我们一不小心就造成了FSL,请看下面代码:

box.classList.add('big');
const width = box.offsetWidth;

在 JavaScript 运行时,上一帧已经渲染好的所有布局值都是已知的,我们可以使用offsetWidth这样的语法获得值。但是这里第一句刚修改了样式,浏览器将渲染任务放置在渲染队列,这时还未渲染,这时候使用offsetWidth这样的语法读取元素的宽度,那么浏览器为了告诉我们正确的宽度值,触发了重排,强制挂起 js 线程,这就需要强制使用渲染线程布局,然后再回到 js 线程。

所以正确的做法是先获取宽度,然后再更改样式:

const width = box.offsetWidth;
box.classList.add('big');

这样,先获取宽度,此时渲染队列并没有需更新的样式,直接返回上一帧的布局值;然后再修改样式,渲染任务添加至渲染队列,js 执行结束后再进行渲染。

单个FSL对性能的影响可能不大,但如果触发了布局抖动,则影响会变得非常大。看下面代码:

const container = document.querySelector('.container');
const boxes = document.querySelectorAll('p');

for (var i = 0; i < boxes.length; i++) {
  // Read a layout property
  const newWidth = container.offsetWidth;
    
  // Then invalidate layouts with writes.
  boxes[i].style.width = newWidth + 'px';
}

上面代码的作用是批量修改N个P元素的宽度;在循环中我们先获取容器元素的宽度,随后设置了P元素的样式。这会导致浏览器去布局,然后计算样式。每次更改样式,都会导致刚刚执行的布局失效,因为我们又改了新的样式,所以下一轮循环读取宽度时,浏览器又要执行一次布局,如此反复直到循环结束。在循环期间,浏览器不停地执行无效布局,这被称为 布局抖动Layout Thrashing;这种错误导致的性能问题非常高。

如果我们不小心触发了FSL,Chrome开发者工具会给出红色的线提示,如下图所示:

image

同时任务的右上角会有红色的三角形表示,我们可以放大任务进一步查看,如下图所示:

image

为了避免布局抖动,我们可以将读取元素宽度的代码放到循环的外面。代码如下:

const container = document.querySelector('.container');
const boxes = document.querySelectorAll('p');

// Read a layout property
const newWidth = container.offsetWidth;

for (var i = 0; i < boxes.length; i++) {    
    // Then invalidate layouts with writes.
    boxes[i].style.width = newWidth + 'px';
}
image

优化后这一帧的总时间用了4.7ms,而优化前的是101ms。

image

优化后比优化前,每帧所耗费的时间快了21.7倍,数字非常惊人。

3.3 如何让CSS动画更丝滑

CSS动画通常使用@keyframetransition结合样式的变动来实现视觉变化的效果。我们同样可以通过减少像素管道的步骤和每个步骤所耗费的时间让CSS动画更流畅。

本节介绍的CSS动画的优化方式同样适用于JS动画,但上一节介绍的JS动画优化方法不适用于CSS动画,它们是包含关系。

绘制Paint通常需要花费很长时间,我们可以通过Chrome开发者工具来观察正在绘制的区域。打开开发者工具,按下键盘上的 Esc 键。在出现的面板中,切换到“rendering”标签,然后选中“Paint flashing”。如下图所示:

image

开启绘制闪烁Paint flashing后,每当页面发生绘制时,我们都可以在屏幕上看到绘制发生区有绿色在闪烁。如下图所示:

image

当我们看到我们认为不应该绘制的区域时,我们应该进一步研究并取消绘制区域。

如何才能避免绘制的发生呢?答案是:图层。

事实上浏览器在渲染页面时,可以将页面分为很多个图层,有点类似于PhotoShop,一张图片在PotoShop中是由多个图层组合而成,而浏览器最终显示的页面实际也是由多个图层构成的。如下图所示:

image

分了图层,您会发现没有任何闪烁发生,因为浏览器没有进行绘制。如果您查看Layers面板,你会看到这样的场景,如下图所示:

image

当我们使用Performance面板捕获性能数据时会发现绘制Paint已经不见了。如下图所示:

image

创建图层的最佳方式是使用will-change,但某些不支持这个属性的浏览器可以使用3D 变形(transform: translateZ(0))来强制创建一个新层。

在Chrome开发者工具“rendering”标签中,选中“Layer borders”。可以看到页面中有哪些合成层。合成层会使用橘黄色的边框,如下图所示:

image

为了减少绘制,可以通过新增图层,但是图层的管理也是需要成本的,所以要避免滥用,通常需要具体情况具体分析,做出合适的选择。

前面我的Demo都是修改元素的left属性让方块移动,这避免不了需要进行布局操作,最佳的方法是使用transform属性,这个属性是由合成器单独处理的,所以使用这个属性可以避免布局与绘制。

总结

RAIL可以帮助我们判断什么样的网页是丝滑的,而开发者工具可以让我们进一步准确的捕获出网页的性能数据。

JS动画要保证预留出6ms的时间给浏览器处理像素管道,而自身执行时间应该小于10ms来保证整体运行速度小于16ms。但触发动画的时机也很重要,定时器无法稳定的触发动画,所以我们需要使用requestAnimationFrame触发JS动画。同时我们应该避免一切FSL,它对性能的影响非常大。

CSS动画我们可以通过降低绘制区域并且使transform属性来完成动画,同时我们需要管理好图层,因为绘制和图层管理都需要成本,通常我们需要根据具体情况进行权衡并做出最好的选择。

更详细的渲染原理请参考 chrome 浏览器渲染原理

参考