乐趣区

渲染性能分析下

上篇我们大致分析了在处理 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);

这样做还有一个好处,就是保持输入处理程序的轻量,因为这样就不会阻塞比如滚动等其他操作。

原文:渲染性能分析(下)

退出移动版