前端性能优化之浏览器渲染优化 —— 打造60FPS页面

5,918 阅读17分钟

前言

本文整理自:developers.google.cn高性能JavaScript,再加上了一些个人对其中提到的知识点的理解与补充。前端性能优化涉及很多方面,本文仅针对高性能渲染进行分析。

单个帧的渲染流程 —— 像素管道

目前,大多数设备的刷新率都是 60FPS,如果浏览器在交互的过程中能够时刻保持在 60FPS 左右,用户就不会感到卡顿,否则,就会影响用户的体验。

下图为浏览器运行的单个帧的渲染流水线,称为像素管道,如果其中的一个或多个环节执行时间过长就会导致卡顿。像素管道是作为开发者能够掌握的对帧性能有影响的部分,其他部分由浏览器掌握,我们无法控制。我们的目标就是就是尽快完成这些环节,以便达到 60FPS 的目标。

1616f495c739d479

  • JavaScript。通常来说,阻塞的发起都是来自于 JS ,这不是说不用 JS,而是要正确的使用 JS 。首先,JS 线程的运行本身就是阻塞 UI 线程的(暂不考虑 Web Worker)。从纯粹的数学角度而言,每帧的预算约为 16 毫秒(1000 毫秒 / 60 帧 = 16.66 毫秒/帧)。但因为浏览器需要花费时间将新帧绘制到屏幕上,只有 ~10 毫秒来执行 JS 代码,过长时间的同步 JS 代码肯定会导致超过 10ms 这个阈值,其次,频繁执行一些代码也会过长的占用每帧渲染的时间。此外,用 JS 去获取一些样式还会导致强制同步布局等(后面会有介绍)。
  • 样式计算 (Style)。此过程是根据匹配选择器(例如 .headline.nav > .nav__item)计算出哪些元素应用哪些 CSS 规则的过程,这个过程不仅包括计算层叠样式表中的权重来确定样式,也包括内联的样式,来计算每个元素的最终样式。
  • 布局 (Layout)。在知道对一个元素应用哪些规则之后,浏览器即可开始计算该元素要占据的空间大小及其在屏幕的位置。网页的布局模式意味着一个元素可能影响其他元素,一般来说如果修改了某个元素的大小或者位置,则需要检查其他所有元素并重排 (reflow) 整个页面。
  • 绘制 (Paint)。绘制是填充像素的过程。它涉及绘出文本、颜色、图像、边框和阴影,基本上包括元素的每个可视部分。绘制一般是在多个表面(通常称为层)上完成的,绘制包括两个步骤: 1) 创建绘图调用的列表, 2) 填充像素,后者也被称作栅格化。
  • 合成 (Composite)。由于页面的各部分可能被绘制到多个层上,因此它们需要按正确顺序绘制到屏幕上,才能正确地渲染页面。尤其对于与另一元素重叠的元素来说,这点特别重要,因为一个错误可能使一个元素错误地出现在另一个元素的上层。

采用更好的CSS方法进行优化

上节渲染管道的每个环节都有可能引起卡顿,所以要尽可能减少通过的管道步骤。

修改不同的样式属性会有以下几种不同的帧流程,在这里就直接贴 Google Developers 的图了:

1616f6f48ea26da3

我们可以看到 JS,Style 和 Composite 是不可避免的,因为需要 JS 来引发样式的改变,Style 来计算更改后最终的样式,Composite 来合成各个层最终进行显示。Layout 和 Paint 这两个步骤不一定会被触发,所以在优化的过程中,如果是需要频繁触发的改变,我们应该尽可能避免 Layout 和 Paint

尽量使用 transform 和 opacity 属性更改来实现动画

性能最佳的像素管道版本会避免 Layout 和 Paint:

16173607ac9537ea

为了实现此目标,需要坚持更改可以由合成器单独处理的属性。目前只有两个属性符合条件:transform 和 opacity。

想知道每种 CSS 属性的更改是否会触发 Layout,Paint,Composite,可以通过 csstriggers.com 查看。

减小选择器匹配的难度

通过上面的几种不同流程的管道图可以发现,只要是修改样式那么必不可少会经过 Style,计算样式的第一步是创建一组匹配选择器,这实质上是浏览器计算出给指定元素应用哪些类、伪选择器和 ID 。第二步是从对应的匹配选择器中获取所有样式规则,并计算出此元素的最终样式,简单的来说就是第一步先确定选择器都匹配哪些元素,第二步根据每个元素所匹配的选择器,通过权重计算出最终的样式。

对于要匹配相同的元素,.final-box-title.box:nth-last-child(-n+1) .title 明显复杂度要来的小得多,浏览器不需要去判断要查找的元素是不是最后一个元素即可根据类名快速找到 .final-box-title 对应的元素,相比复杂的选择器,简单地将选择器与元素匹配开销要小得多,而且嵌套过深的 CSS 选择器依赖了过多的类名,很容易在改动依赖的类名时不小心被影响到。

这里推荐使用 BEM(块、元素、修饰符) 编码规则简化选择器规则,该方法实际上纳入了上述选择器匹配的性能优势,因为它建议所有元素都有单个类,并且在需要层次结构时也纳入了类的名称。

提升元素到新的层

有一种能有效减小 Layout 和 Paint 的方法是将元素提升,像 Photoshop 中层的概念一样,样式也有层的概念,不同的层根据不同顺序叠加起来,通过 Composite 最终显示出来。在每个层中对这个层进行 Layout 或者 Paint 是不会影响其他层的,一般会根据整个页面的语义将页面分为几个层。

但是不要滥用层,将每个元素都单独提升到一层, Composite 这个环节有两步,Update Layer TreeComposite Layer Tree,前者负责计算页面中有多少个层,哪些层应该出现并应该按什么顺序叠加起来,后者负责将 layers 合成到屏幕上。层越多,这两个步骤花的时间越长,同时也会占用更多的内存,所以要在适当的地方提升元素而不是对所有元素都进行提升。

提升元素还有一个好处就是会将动画从CPU转移到GPU来完成,来实现硬件加速。

提升元素的两个方法:

.moving-element {
  will-change: transform;
}
.moving-element {
  transform: translateZ(0);
}

有些浏览器对 will-change 的支持还不够好,所以一般两个都写上。

参考:How (not) to trigger a layout in WebKit

使用 flexbox 而不是较早的布局模型

经过测试,flex 布局在现代浏览器上相比早期的浮动或者定位布局性能更好,而且到现在 flex 布局已经很好的得到了浏览器的支持(IE10-手动再见 👋)。

16174d34c5f80f97

尽量避免 Layout

强制同步重排 - FSL (forced synchronous layout)

再来看下单个帧的流程图

1616f495c739d479 1

如果我们在 js 中这样写

let boxes = document.getElmentsByClassName('.box')
for(let i = 0; i < boxes.length; i++) {
    let width = document.getElementById('table')
    boxes[i].style.color = 'red'
}   

这种情况下,这一帧相比上一帧没有布局没有发生改变,那么直接用旧的 Layout 去赋值 width 就可以,也不需要对页面进行重排。

但是如果这样写:

let boxes = document.getElmentsByClassName('.box')
for(let i = 0; i < boxes.length; i++) {
    let width = document.getElementById('table').width
    boxes[i].style.width = width
}   

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

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

什么操作会触发强制同步布局 呢,这个 gist 里列出了对应的操作。

避免强制同步布局

  1. 使用 requestAnimationFrame(后面有介绍),将获取 width 的操作推迟到下一帧,在经过浏览器正常的 Layout 之后,下一帧可以直接拿到 Layout 值。

    requestAnimationFrame(logBoxHeight);
    
    function logBoxHeight() {
      box.classList.add('super-big');
      // Gets the height of the box in pixels
      // and logs it out.
      console.log(box.offsetHeight);
    }
  2. 缓存不动变量,对上面的那个强制同步布局的例子,避免在循环中进行可能会导致强制同步布局的操作

    let boxes = document.getElmentsByClassName('.box')
    let width = document.getElementById('table').width
    for(let i = 0; i < boxes.length; i++) {
        boxes[i].style.width = width
    }   

FLIP策略

在做某些动画时,有可能会有连续触发 Layout 步骤的属性,如下图的动画

16170729c7987fc4

如果凭直觉来做,很可能就是 click 之后加上一个类似于

.element.expanded {
    height: 100%;
    left: 0;
    position: absolute;
    transition: top 200ms ease-in , height 200ms ease-in 50ms;
    top: 0;
    width: 100%;
    z-index: 3;
} 

这样的类。但是,可以看到下图中用 Chrome devTools 打开显示 Paint 区域的功能,发现重绘的区域很大,并且肯定伴随着重排,帧数也很低,出现了卡顿的现象。

这时候,就用 transform 来代替对 width 和 height 的改变。

接下来,介绍一下 Paul Lewis 发明的 FLIP 思想,所谓 FLIP 就是 F (first) L (last) I (invert) P (play)。

  • First: 在整个动画过程中元素的起始状态
  • Last: 在整个动画过程中元素的终止状态
  • Invert: 这一步是关键,通过 First 和 Last 计算出来的状态,得到一个从 Last 到 First 的变化倍率(比如大小或位置,是的,是从 Last 到 First),然后让元素具有终止状态的 class 及刚刚计算出来的 invert state 的 transform 属性,他们两个相抵消,元素在视觉上还是没有任何变化。举个例子,比如我们想让一个元素向右移动 10px,再放大两倍,那么这个计算出来的相反的 transfrom 属性就应该是 transform: translateX(-10px) scale(0.5),再给他一个 left: 10px; width: 200px; height: 200px;(假设原来是 left: 0; width: 100px; height: 100px;),这两个属性视觉效果上抵消,好像元素从来没有改变过。
  • Play: 给元素添加一个 transition 效果,再移除元素的 transform 属性,因为此时元素已经是终止状态了,所以就会 transition 到 0,整个过程只有 transform ,可以轻松达到 60FPS。

用代码来表示就是这样,直接贴一下原作者的代码,已经很详细了:

// Get the first position.
var first = el.getBoundingClientRect();

// Now set the element to the last position.
el.classList.add('totes-at-the-end');

// Read again. This forces a sync
// layout, so be careful.
// 这里会触发强制同步,不过只有一帧,这是完全可以接受的
var last = el.getBoundingClientRect();

// You can do this for other computed
// styles as well, if needed. Just be
// sure to stick to compositor-only
// props like transform and opacity
// where possible.
var invert = first.top - last.top;

// Invert.
el.style.transform =
    `translateY(${invert}px)`;

// Wait for the next frame so we
// know all the style changes have
// taken hold.
// 要用rAF,不用的话el.style.transform = `translateY(${invert}px)`; 和
// 必须放到下一帧触发transfrom
// el.style.transform = '';就在一帧中同步执行了,就不会用动画效果了,
requestAnimationFrame(function() {

  // Switch on animations.
  el.classList.add('animate-on-transforms');

  // GO GO GOOOOOO!
  el.style.transform = '';
});

// Capture the end with transitionend
// 结束后要el.classList.remove('animate-on-transforms')
el.addEventListener('transitionend',
    tidyUpAnimations);

161710fa9b392c51

实际上,FLIP 是将复杂的计算放在了一开始(包括一次强制同步),根据 RAIL 规则,触发后 100ms 的反应时间是可以接受的,所以在 100ms 内完成为止的计算,之后的动画用 transform 来达到 60FPS。

附上一个自己写的小 demo,大家可以感受一下。

参考:

高性能 JavaScript

昂贵的 DOM 操作

其实,JS 的执行速度是很快的,尤其是发展到了现在这个阶段,像V8这样的浏览器内核性能已经十分强悍了,真正慢的是操作 DOM。浏览请通常会将 DOM 和 JS 独立实现,DOM 是个与语言无关的 API,但是在浏览器中的接口却是用 JS 来实现的,这意味着通过 JS 去访问另一个模块实现提供的 API 时,会造成很大的开销,这就是造成操作 DOM 慢的原因。

小心 live HTMLCollection

使用 document.getElementsByName(), document.getElementsByClassName(), document.getElementsByTagName()时,返回值是一个实时的的 HTMLCollection,也就是所谓的 live,这些函数返回的集合是以一种 “假定实时态”,这意味着底层文档对象更新时,它也会自动更新,所以每次你获取这个集合中的信息时,这个集合都会重复执行查询的过程。所以,在不需要满足实时更新的情况下,推荐使用document.querySelectorAll(),它将返回一个非 live 的静态列表。

批量修改 DOM

在 JS 同步代码中操作(比如添加、删除或者修改尺寸等)DOM 会让浏览器进行重排,包括

  • 添加或删除可见的 DOM 元素
  • 元素位置改变
  • 元素尺寸改变(包括:外边距、内边距、边框厚度、宽度、高度等属性改变)
  • 内容改变,例如:文字改变或图片被另一个不同尺寸的图片替代。
  • 页面渲染器初始化。
  • 浏览器窗口尺寸改变。

解决方法是让 DOM 脱离文档流再对其进行操作,所有操作完成后添加进文档流,这样可以将重排及重绘的次数降低到一次或两次(脱离文档流及回归文档流的时候),以下方法可以让元素脱离文档流:

  • 隐藏元素 —— display: none;
  • 使用 DocumentFragment(推荐,只有一次 reflow)
  • 将原始元素拷贝到一个脱离文档的节点中,修改这个副本,完成后再�替换掉原始元素。

事件委托

利用事件冒泡的机制来处理子元素事件的绑定,将子元素的 DOM 事件,交由它们的父元素来进行处理,可以有效降低页面的开销 —— 由于事件的冒泡特性,只需要给父组件添加一个监听事件,就能够捕获到冒泡自子元素的事件,再通过 e.target 来获取真正被操作的子元素。

避免微优化

现在浏览器都有 JIT(just in time)即时编译的引擎,所以会在运行中编译代码

附上一段 知乎 上对 JIT 带来的优化的解释:

动态编译之于静态编译,缺点是它需要即时编译代码,但是有一个优点---编译器可以获得静态编译期所没有的信息。比如:通过运行时的profiling可以知道哪些函数是被大量使用的。在哪些execution path上哪些函数的参数一直都没有变,等等。不要小看这些信息,当即时编译器了解这些信息之后可以在短时间内编译出比静态编译器更优质的二进制码。举例来说,一般程序也遵循90-10原则,即运行时的90%里计算机是在处理其中10%的代码,寻找到这些执行热点代码进行深度优化能得到比静态编译更好的性能(因为已知更多信息量)。

所以我们没有必要再去手工的做一些优化,比如在 for 循环中缓存 length,或者像 《高性能JavaScript》 (这已经是2010年的书了,好多结论都是拿 IE 来说的)中介绍的 for (var i=items.length; i--; ) 来减少每次迭代经过的步骤,我们无法知道这样的代码在经过 JIT 后,是否会带来任何好处,甚至是否会给 JIT 带来一个负面效果,并且这样做肯定会在一定程度上降低代码的可读性。

举个例子,Redux中,在执行 subscribe 的函数时,用的是 for (let i = 0; i < listeners.length; i++)listeners.length本身是可以缓存的(不存在运行过程中 length 改变的情况),但是作者给出的理由是 V8 足够智能来做更好的优化,具体可以看我写的 通过GitHub Blame深入分析Redux源码

Web Worker

Web Worker 还暂时没研究过,按照MDN的解释

Web Workers is a simple means for web content to run scripts in background threads. The worker thread can perform tasks without interfering with the user interface. In addition, they can perform I/O using XMLHttpRequest (although the responseXML and channel attributes are always null). Once created, a worker can send messages to the JavaScript code that created it by posting messages to an event handler specified by that code (and vice versa.) This article provides a detailed introduction to using web workers.

Web Worker 是提供一种在主线程之外的多线程能力,我们可以将耗时的、阻塞的js操作放在 Web Worker 中,PWA 也是基于 Web Worker 来实现,并已经成为了前端的未来趋势之一。

使用 requestAnimationFrame

在某个单个帧中,有可能发生这种情况,在某一帧中会被多次触发某个事件(比如 scroll),这个事件又会频繁的触发样式的修改,导致可能需要多次 Layout 或者 Paint,这是一种浪费,过于频繁的 Layout 和 Paint 会造成卡顿,而且实际上一帧中并不需要重复 Layout 或者 Paint 那么多次。

这个时候就可以用到 rAF 了,先放上一段 MDN 上对 rAF 的解释:

window.requestAnimationFrame() 方法告诉浏览器您希望执行动画并请求浏览器在下一次重绘之前调用指定的函数来更新动画。该方法使用一个回调函数作为参数,这个回调函数会在浏览器重绘之前调用。

简单来说,rAF 的作用就是将传给 rAF 的回调函数,安排在下一帧的一开始执行。这样就能保证这个回调函数最先执行,并且在rAF自带节流效果。

rAF 的一般调用方法为:

let scheduledAnimationFrame = false;

function readAndUpdatePage(){
    doSomething()
    scheduledAnimationFrame = false;
}

function onScroll (evt) {

  // Store the scroll value for later.
  lastScrollY = window.scrollY;

  // Prevent multiple rAF callbacks.
  if (scheduledAnimationFrame)
    return;

  scheduledAnimationFrame = true;
  requestAnimationFrame(readAndUpdatePage);
}

window.addEventListener('scroll', onScroll);

在调用 rAF 时,有一点切记,不要在 rAF 的回调函数中先修改样式,再查询样式,这样就失去了 rAF 的作用。

举个例子:

function logBoxHeight() {

  box.classList.add('super-big'); // 1
  // Gets the height of the box in pixels
  // and logs it out.
  console.log(box.offsetHeight); // 2
}

JS 连续执行,rAF 还没能等到下一帧在同一个流水线里被触发强制同步布局了,解决方法也很简单:将 1 和 2 换一下即可,直接用上一帧的样式,再去修改样式。

内存管理

JavaScript 是自动管理内存的,浏览器引擎会自动 GC,作为开发者我们无需去操心内存(只需要别泄漏内存)。但是 GC 同样是需要消耗时间的(可以从 Chrome devTools 里的 Performance 里看到,GC 需要一段很短的时间),如果数据结构使用不当,造成了内存泄漏或者导致频繁的 GC,也是会对页面流畅度造成影响的。

下面是一些写出 GC 友好的代码的教程:

总结

前端性能优化是个大话题,渲染部分的内容也远不止文章中写出的这些,就拿 Composite 来说,就有 无线性能优化:Composite | Taobao FED | 淘宝前端团队) 这样深入的文章,先在这里挖个坑,以后遇到可以补充的再继续更新,欢迎留言 👏