这篇文章主要关注的是资源加载之后的性能,因为大多数用户关注的不是应用如何加载而是具体的使用。所以要快速响应用户,尤其是无线端,我们有必要了解浏览器渲染性能。
RAIL 性能模型
首先一个需要思考的问题,怎样的网站是顺畅的?我们可能可以给一个大概的感觉,如:秒级响应等。其实,也可以给出一个很讨巧的答案:用户觉得顺畅的网站它就是顺畅的。因为几乎所有网站都希望将用户留在页面上,当然以用户为中心建立性能模型是必要的。下面是 Google 提出的一个以用户为中心的性能模型,里面的数据不是 Google 首创,有一些论文做类似研究(如:100ms 响应用户是一个很合适的时间等)。
上图是 RAIL 的具体含义,这里有一些关键性的数据指标:
- Respond:0 - 100ms,视窗一般需要在这个时间段响应用户,超过这个时间段,用户就会感觉到延时。
- Animation:0~16ms,屏幕每秒刷新60次,16ms 代表的是每一帧的时间。用户是非常关注动画的,当动画失帧很容易引起用户察觉。所以动画一般要控制在60FPS。
- Idle:最大化主进程的空闲时间,这样可以及时响应用户输入。
- Load:内容需要在1000ms 内加载出来,超过1000ms 会觉得加载缓慢。
应用要达到上面的性能模型需要从哪些方面入手呢?如果我们知道浏览器是如何渲染一个页面的,并且去优化渲染过程中的关键步骤,是不是就能事半功倍呢?
关键渲染路径
上图是浏览器渲染的关键路径,首先,让我们从浏览器解析一个页面开始吧。
- 转化: 浏览器从磁盘或网络读取 HTML 的原始字节,浏览器会将这段原始文件按照相应编码规范进行解码(现在一般为 utf-8)。
- 符号化:根据 W3C 标准转化为对应的符号(一般在尖括号内)。
- DOM 构建:HTML 解析器会解析其中的 tag 标签,生成 token ,遇到 CSS 或 JS 会发送相应请求。HTML 解析时阻塞主进程的,CSS 一般也是阻塞主进程的(媒体查询时例外),也就是说它们在解析过程中是无法做出响应的。而 JS 手动添加 async 后达到异步加载,根据 token 生成相应 DOM 树。
- 。
- CSSDOM 构建,添加 CSS 样式生成 CSSDOM 树。
- 渲染树构建,从 DOM 树的根节点开始,遍历每个可见的节点,给每个可见节点找到相应匹配的 CSSOM 规则,并应用这些规则,连带其内容及计算的样式。
- 样式计算,浏览器会将所有的相对位置转换成绝对位置等一系列的样式计算。
- 布局,浏览器将元素进行定位、布局。
- 绘制,绘制元素样式,颜色、背景、大小、边框等。
- 合成,将各层合成到一起、显示在屏幕上。
如果我们是做一个动画,一般会用 JS 更改相应样式,接着浏览器就会经历 JS 运行、样式计算、布局、绘制、合成等多个重要步骤(后面还会讲到这个步骤实际过程中可以更长或者更短)。那么要做的优化就是在这几个步骤中进行优化并且尽量去掉中间的耗时步骤。
优化JavaScript的执行
上图描述的四个场景都是有可能对响应用户输入或者动画造成影响的。函数的输入事件处理、不合时机的 JS 、长时间的 JS 运行以及垃圾回收。
函数的输入事件处理
首先,我们要知道的一个事实就是浏览器是由多个处理进程的:Compositor、Tile Worker、Main。当用户进行输入操作(滚动、点击等),如滚动时,Compositor 进程会接收到这个事件(实际它可以接受任何用户输入事件),如果可以的话,它将不会通知主进程,直接说:滚吧,牛宝宝。于是,页面就滚动了。当然,这其中包含更新层定位以及让 GPU 绘制帧,而主线程处于空闲状态。但是,事情往往并非如此。如果输入事件上绑定了 JS 处理事件的话,Compositor 进程就没办法主动跳过主进程了。
如上图,当 JS 处理事件过长时,输入事件的响应会一直处于阻塞状态,直到 JS 处理完成。当响应超过 100ms 时,用户就会感受到延时。所以当处理用户事件时,我们应该做到:
- 避免长时间的 JS 执行。
- 避免在处理中改变样式。因为样式改变会引起后面布局、绘制、合成等操作。
- 对用户输入进行消抖。
优化处理
其他优化:
- 使用 requestAnimationFrame,将 setTimeout 换成 requestAnimationFrame,因为 setTimeout 时间控制可能造成在一帧的中间,目前各浏览器对 requestAnimationFrame 的支持已经比较好了。
- 使用 Web Workers,将复杂计算的 JS 采用 Web Workers 进行处理。
- 减少垃圾回收,垃圾回收是一个容易被忽略的问题,因为垃圾回收的时间是不受控制的,它可能在一个动画的中途,阻塞动画的执行,更理想的情况是在循环中复用对象。
样式计算
添加或移除一个 DOM 元素、修改元素属性和样式类、应用动画效果等操作,都会引起 DOM 结构的改变,从而导致浏览器需要重新计算每个元素的样式、对页面或其一部分重新布局(多数情况下)。
计算样式的第一步是创建一套匹配的样式选择器,浏览器就是靠它们来对一个元素应用样式的。第二步是根据匹配的样式选择器来获取对应的具体样式规则,计算出最终具体有哪些样式是要应用在 DOM 元素上的。所以样式的优化也是这两步:
减小选择器的复杂性
如何减小选择器的复杂性?
.box:nth-last-child(-n+1) .title {
/* styles */
}
.final-box-title {
/* styles */
}
上面代码都是选择同一个元素,当元素很多时,第二个选择器的性能会明显优于第一个。BEM 规范有做类似事情,按照特性直接由一个选择器选择元素的性能往往会更优。
减少样式的计算量
因为元素的计算量和被改变的元素的数量成正比,所以你只需要注意一点,减少无效元素。
<div>
<div>
<p>多层无意义的标签</p>
</div>
</div>
像上面的例子,有时候创建了一些冗余的标签。当改变外层的样式时,冗余的标签也需要进行样式计算,浪费性能。
布局
浏览器计算 DOM 元素的几何信息的过程:元素大小和在页面中的位置。每个元素都有一个显式或隐式的大小信息,决定于其 CSS 属性的设置、或是元素本身内容的大小、或者是其父元素的大小。在 Blink/WebKit 内核的浏览器和 IE 中,这个过程称为 Layout。在基于 Gecko 的浏览器(比如 Firefox)中,这个过程称为 Reflow。
避免触发布局
目前,transform 和 opacity 只会引起合成,不会引起布局和重新绘制。整个流程中比较耗费性能的布局和绘制流程将直接跳过,性能显然是很好的。其他的 CSS 属性改变引起的流程会有所不同,有些属性也会跳过布局,具体可以查看 CSS Triggers。所以,优化的第一步就是尽可能避免触发布局。
使用Flexbox布局
Flexbox 布局方案性能会优于以前的布局方案,而且目前浏览器对 Flexbox 支持度相当高了:
避免强制同步布局事件
首先是执行 JS 脚本,然后是样式计算,然后是布局。但是,我们还可以强制浏览器在执行 JS 脚本之前先执行布局过程,这就是所谓的强制同步布局。在 JS 脚本运行的时候,它能获取到的元素样式属性值都是上一帧画面的,都是旧的值。因此,如果你想在这一帧开始的时候,读取一个元素的 height 属性,你可以会写出这样的 JS 代码:
function logBoxHeight() {
box.classList.add('super-big');
// Gets the height of the box in pixels
// and logs it out.
console.log(box.offsetHeight);
}
为了给你返回 box 的 height 属性值,浏览器必须首先应用 box 的属性修改(因为对其添加了 super-big 样式),接着执行布局过程。在这之后,浏览器才能返回正确的 height 属性值。这样就造成了同步布局事件,是非常消耗性能的。大多数情况下,你应该都不需要先修改然后再读取元素的样式属性值,使用上一帧的值就足够了。过早地同步执行样式计算和布局是潜在的页面性能的瓶颈之一。
function logBoxHeight() {
// Gets the height of the box in pixels
// and logs it out.
console.log(box.offsetHeight);
box.classList.add('super-big');
}
避免快速连续的布局
还有一种情况比强制同步布局更糟:连续快速的多次执行它。
function resizeAllParagraphsToMatchBlockWidth() {
// Puts the browser into a read-write-read-write cycle.
for (var i = 0; i < paragraphs.length; i++) {
paragraphs[i].style.width = box.offsetWidth + 'px';
}
}
上述代码对一组段落标签执行循环操作,设置 p 标签的width属性值,使其与 box 元素的宽度相同。看上去这段代码是没问题的,但问题在于,在每次循环中,都读取了 box 元素的一个样式属性值,然后立即使用该值来更新 p 元素的 widt h属性。在下一次循环中读取 box 元素 offsetwidth 属性的时候,浏览器必须先使得上一次循环中的样式更新操作生效,也就是执行布局过程,然后才能响应本次循环中的样式读取操作。布局过程将在每次循环中发生。优化代码:
// Read.
var width = box.offsetWidth;
function resizeAllParagraphsToMatchBlockWidth() {
for (var i = 0; i < paragraphs.length; i++) {
// Now write.
paragraphs[i].style.width = width + 'px';
}
}
如果你想确保编写的读写操作是安全的,你可以使用 FastDOM。它能帮你自动完成读写操作的批处理,还能避免意外地触发强制同步布局或快速连续的布局。
绘制
提升移动或渐变元素的绘制层
绘制并非总是在内存中的单层画面里完成的。实际上,浏览器在必要时将会把一帧画面绘制成多层画面,然后将这若干层画面合并成一张图片显示到屏幕上。通过渲染层提升可以减小绘制区域,我们可以用调试工具查看到绘制层:
在页面中新建一个渲染层最好的方式就是使用 will-change 属性,同时再与 transform 属性一起使用,就会创建一个新的组合层:
.element {
will-change: transform;
}
对于那些目前还不支持 will-change 属性、但支持创建渲染层的浏览器,可以使用一个 3D transform 属性来强制浏览器创建一个新的渲染层:
.element {
transform: translateZ(0);
}
注意: 别盲目创建渲染层,一定要分析其实际性能表现。因为创建渲染层是有代价的,每创建一个新的渲染层,就意味着新的内存分配和更复杂的层的管理。并且在移动端 GPU 和 CPU 的带宽有限制,创建的渲染层过多时,合成也会消耗跟多的时间。
仔细规划动画和简化绘制的复杂度
有时候,尽管把元素提升到了一个单独的渲染层,浏览器会把两个相邻区域的渲染任务合并在一起进行,这将导致整个屏幕区域都会被绘制。所以可以使用调试工具查看,仔细规划动画。
不同的 CSS 属性绘制的成本是不一样的,绘制一个阴影就比绘制边框更费时。当然,这个浏览器也在不停优化中,现在的耗时渲染属性随时都可能被改变,所以需要多关注一下。
合成
渲染层的合并,就是把页面中完成了绘制过程的部分合并成一层,然后显示在屏幕上。下面和合成相关的两点前面也有提到过。
使用transform/opacity实现动画效果
前面已经提到过 transform/opacity 的优势,应用了 transforms/opacity 属性的元素必须独占一个渲染层。为了对这个元素创建一个自有的渲染层,你必须提升该元素。
管理渲染层、避免过多数量的层
创建一个新的渲染层需要消耗额外的内存和管理资源。而在内存资源有限的设备上,由于过多的渲染层来带的开销而对页面渲染性能产生的影响,甚至远远超过了它在性能改善上带来的好处。由于每个渲染层的纹理都需要上传到 GPU 处理,因此我们还需要考虑 CPU 和 GPU 之间的带宽问题、以及有多大内存供 GPU 处理这些纹理的问题。
其他
- 关注趋势,今天很多的性能瓶颈很可能在将来都不再是问题。如之前关注的一项技术 Web Animations,是否能用 JS 达到原生动画效果。Houdini,你可以添加更多的 JS 代码到动画中而不用担心性能问题。
- 利用工具 Chrome DevTools
,上面的规则只是优化的方向,善于利用工具分析。移动端利用 inspector 也是非常方便的,并且还可以对数据进行保存,对比分析等。几乎一切需要的分析工具,DevTools 都有。 - 不要进行微优化,有时花上很短的带来的性能提升却很小,对于日常快速迭代的业务是没必要这样做的。
相关链接
- Paul Lewis 相关文章,Chrome 开发团队,旨在帮助开发者提高他们的应用和站点。
- Performance Calendar,从2009年到2015年,列出了每一年值得关注的优化方案。
- Performance,Google Developers 官方站点对性能优化的文章。
- webkit,webkit 的博客站点,介绍了比较底层的渲染等文章。