blog
blog copied to clipboard
渲染性能分析(下)
上篇我们大致分析了在处理JavaScript阶段和Style阶段需要注意的问题,这篇我们就来看下在Layout、Paint、Composite阶段以及处理用户行为的时候,应该关注的问题所在。
避免大型的复杂的布局和布局限制
Layout阶段浏览器将计算元素的大小,在页面中的位置,其他元素的影响等等,与样式计算(Style calculation)类似,基本限制因素如下:
- 需要Layout的元素数量
- Layout的复杂度
TL;DR
- Layout适用于整个文档流
- DOM的数量直接影响Layout的性能消耗,尽量避免触发Layout
- 避免强制同步修改Layout,造成反复Layout。即读取style的值然后修改style
尽可能的避免触发Layout
当更改样式时,浏览器会去检查需不需重新计算触发Layout,一般来说修改元素的几何属性(geometric properties)例如:宽高,布局定位都会触发Layout
.box {
width: 200px;
height: 200px;
}
// 改变元素宽高 触发Layout
.box-expanded: {
width: 300px;
height: 300px;
}
Layout是作用于全局整个文档流的,所以如果有大量的元素需要处理,就会消耗很长时间去计算这些元素的大小和定位。 如果无法避免触发Layout,可以通过Performance查看Layout阶段的耗时是否是影响性能的瓶颈。

在Performance中我们可以清楚的看到Layout阶段消耗的时间,以及涉及的节点数(如图为314个元素) https://csstriggers.com/ 列出了一些CSS属性会触发渲染的哪个阶段,可以作为对照参考。 另外使用flexbox布局要比传统的通过float或者相对定位绝对定位实现布局更快。
避免出现强制同步布局
正常情况下渲染步骤是先执行JavaScript,然后是style calculation 然后触发Layout。但是有种情况是触发Layout的时间点早于JavaScript的执行,这种情况叫强制同步布局(forced synchoronous layout)
要明确的是在JavaScript运行时,前一帧的布局属性值都是已知的。举个例子来说如果你想在帧(frame)开始前获取某个元素的高度,就可以这样写:
requestAnimtionFrame(logBoxHeight);
function logBoxHeight(){
console.log(element.offsetHeight);
}
但是如果你先改变的元素的样式然后在获取元素高就会出问题
function logBoxHeight(){
element.classList.add('big');
console.log(element.offsetHeight);
}
现在的情况就变成这样,由于添加了新的class后要输入元素的offsetHeight,浏览器必须先重新进行布局计算才能拿到正确的offsetHeight的值,这完全是没必要的,而且这个例子中通常情况下都是不需要先去设置样式再去取属性值的,直接使用最后一帧的属性值完全足够了。所以一般情况下最好是先去读取需要的属性值,然后再做更改。
function logBoxHeight(){
console.log(element.offsetHeight);
element.classList.add('big');
}
还有一种更糟糕的情况是反复不断的强制同步触发layout。看下面的代码
function resizeAllParagraphsToMatchBlockWidth(){
// 让浏览器陷入读写循环
for(let i = 0; i < paragraphs.length; i++){
paragraphs[i].style.width = element.offsetHeight + 'px';
}
}
打眼一看好像没什么问题,其实这种问题很常见每次迭代都会去读取element.offsetHeight属性,然后用它去更新paragraph的width属性。解决办法也很常见就是读取一次做一个缓存。
const width = element.offsetHeight;
function resizeAllparagraphsToMatchBlockWidth(){
for(let i = 0; i < paragraphs.length;i++){
paragraphs[i].style.width = width + 'px';
}
}
简化Paint复杂度,减少Paint的面积
Paint是一个填充像素(pixels)的过程,最终这些像素会通过合成器合成到屏幕。这个阶段通常是渲染元素整个过程中最消耗时间的阶段,所以要尽可能的避免
TL;DR
- 除了transform和opacity属性改变其他任何属性都会触发Paint
- 因为Paint在整个渲染过程中是最消耗时间和性能的,所以尽可能的避免触发
- 利用Chrome DevTools来观察Paint阶段,并尽可能的降低减小对性能的消耗
- 可以通过提升图层来减少Paint的面积大小
如果触发Layout肯定触发Paint,因为改变元素的几何属性(宽高等)意味着需要重新布局定位。当然修改一些非几何属性例如:background text-color,shadow这些也会触发paint,只不过不会触发layout所以整个渲染过程就会跳过Layout阶段。

利用Chrome DevTools来观察渲染过程中最消耗性能的部分,可以看到如下图绿色部分表示的是需要被重绘的区域。

可以使用will-change属性或者类似的hack手段让浏览器创建一个新的图层来减少需要被Paint的区域。关于will-change的详细内容可以看这篇文章【关于will-change属性你需要知道的事】此处不在赘述。
尽可能简化Paint的过程,在Paint阶段有的步骤是非常消耗性能的,比如任何涉及到模糊(blur)的过程(例如:shadow属性),就CSS而言这些属性之间看上去没什么性能上的差异,但实际在Paint阶段是区别还是很明显的。
Composite
composite阶段是将Paint过程中的内容汇集起来显示在屏幕上。
这个过程中主要有两个影响页面性能的关键因素:一个是需要整合的合成层(compoaitor layers)数量,另一个是用于动画的相关属性
TL;DR
- 使用will-change或translateZ属性做硬件加速
- 避免创建过多图层(layer),图层会占用内存
- 对于动画的操作使用transform和opacity做变更
渲染过程中最好的情况是避免触发Layout和Paint只需要合成(compositing)阶段处理变更。要做到这一点只需要一直使用只通过合成器处理的属性即可。(只有transform和opacity属性可以做到)
Postion transform: translate(npx,npx);
Scale transform: scale(n);
Rotation transform: rotate(ndeg);
Skew transform: skew(X|Y)(ndeg);
Matrix transform: matrix(3d)(...);
Opacity opacity: 0 <= n <= 1
使用transform和opacity的注意点是对应元素要在自身的合成图层,如果没有自身图层就要创建一个图层。这里涉及到创建图层和硬件加速的内容可以参考【关于will-change属性你需要知道的事】
通过提升或创建图层有助于性能的提升这个技巧诱惑力是大的,所以有可能就会写出如下代码:
* {
will-change: transform;
transform: translateZ(0);
}
如同在 【关于will-change属性你需要知道的事】里提到的这种做法非但不能带来性能上的提升,反而会占用过多系统资源,对CPU和GPU都会带来额外的负担。
最后和我们之前提到的类似Chrome DevTools提供了供开发者查看页面图层的工具,可以看到当前页面上有多少层级,每个层级的大小、渲染的次数以及合成的原因等等,我们可以通过这些信息去分析和做对应的优化。

对输入处理程序做防抖
处理用户输入也是潜在的可能会影响性能的因素,因为其可能会阻塞其他内容的加载并且导致不必要的布局(layout)工作
TL;DR
- 避免时间过长运行处理输入程序,其会阻塞页面滚动;
- 不要在处理输入的程序中修改样式;
- 对输入处理程序做防抖,在下一帧的requestAnimationFrame回调中存储事件值和样式的更改
避免运行时间过长的处理程序
页面交互最快的情况是,当用户与页面交互时,页面的合成器线程接受用户的触摸输入并将内容四处移动。这个过程不需要与主线程通信,而是直接提交给GPU处理。所以不需要等待主线程对JS的处理、以及布局(layout)、绘制(paint)等操作完成。

但是,如果附加了输入处理程序(如touchstart,touchmove,或者touchend)后,合成器线程必须等待该处理程序执行完毕,因为有可能调用了preventDefault()来阻止触摸滚动事件的发生。即使没有调用preventDefault(),合成器也必须等待其执行完毕,这样用户的滚动操作就被阻止就可能导致帧丢失从而引起卡顿。
总而言之,你应该确保运行的所有输入处理程序都快速执行,并允许合成器执行其工作。
避免在输入处理程序中改变样式
输入处理程序被安排在requestAnimtionFrame回调之前运行。如果在这个处理程序中做样式上的修改,那么在requestAnimationFrame开始处有需要更改的样式处理,这会触发强制同步布局。

输入处理程序做防抖
上面两个问题的解决方案是相同的:你应该对下一个requestAnimationFrame的回调中做样式更改的情况做防抖处理。
function onScroll(evt){
lastScrollY = window.scrollY;
if(scheduleAnimationFrame)
retun;
scheduleAnimationFrame = true;
requestAnimationFrame(readAndUpdatePage);
}
window.addEventListener('scroll',onScroll);
这样做还有一个好处,就是保持输入处理程序的轻量,因为这样就不会阻塞比如滚动等其他操作。