渲染优化:重排重绘与硬件加速
首先回顾上文内容,当我们打开一个网页时,WebKit 渲染流程大概如下:
- HTML 解释器解析 HTML 文档生成 DOM 树。
- CSS 解释器解析生成 CSS 树(CSSOM)。
- 根据 DOM 树构建 RenderObject 树,并对 RenderObject 进行布局计算,并把结果保存到 RenderObject 中。
- 根据 RenderObject 树和 CSSOM 相关属性构建 RenderLayer 树,主要用于网页分层与渲染合成。
- 采用软件或硬件渲染。
而本文主要介绍的就是何时会发生布局计算、如何减少布局计算以及后续绘制动作(重排重绘)和如何开启 GPU 硬件加速。
重排重绘与布局计算(Webkit)
什么是重排和重绘:
- 重排(reflow):在浏览器初始化渲染完页面以后,当元素的几何属性(宽或高)发生变化时,浏览器需要重新计算元素的几何属性,同样其他元素的几何属性和位置也会因此受到影响。浏览器会使渲染树中受到影响的部分失效,并更新渲染树(RenderObject 树)。这个过程称为重排。
- 重绘(repaint):完成重排后,浏览器会重新绘制受影响的部分到屏幕,该过程称为重绘。
而布局计算就发生在 RenderObject 树的每个 RenderObject 对象上,属于重排的其中一个环节。
网页加载后,每当浏览器需要重新绘制新的一帧的时候,一般需要三个阶段:计算布局、绘图和合成。如果想减少每一帧时间,提高性能,自然需要着重减少这三个阶段的时间。
在页面初始化的整个渲染周期中,布局计算和绘制是最耗时间的两项,最后一步合成很快。而每次的布局计算后,一旦布局发生改变,后续的绘制操作也会接着进行。所以在开始了解重排重绘之前,我们先了解哪些情况下需要重新计算布局:
- 网页的可视区域(viewport)发生变化时都需要重新计算布局。
- 网页的动画会触发布局计算。如果动画需要改变元素的一些大小或尺寸,那么就需要重新进行布局计算。
- JavaScript 代码修改样式信息。
- 用户的交互也会触发布局计算,例如翻滚网页,会触发新区域布局的计算。
总体来说,只要样式发生变化,都需要进行重新计算。
布局计算根据其范围大致可以分为两类:第一类是对整个 RenderObject 树进行计算;第二类是对 RenderObject 树中某个子树的计算,常见于文本元素或者是overflow:auto
块的计算,这种情况一般是其子树布局的改变不会影响其周围元素的布局,因而不需要重新计算更大范围的布局。
布局计算是一个递归的过程,这是因为一个节点的大小通常需要先计算它的子女节点的位置、大小等信息才能被确定。布局计算是以包含块和盒子模型为基础的,元素的布局计算都依赖于块,例如div
通常就是一个块,而它们通常是在垂直方向上展开的(可理解为:页面更倾向于表现为在垂直方向上有滚动条而水平方向无滚动条)。
下图为布局计算过程描述:
在了解完重排重绘的概念以及布局计算的过程以后,下面我们看看重排重绘的代价究竟有多大。
重排和重绘的代价究竟多大
我们通过下面的例子可以看到多次访问修改 DOM 并进行重排重绘、多次访问 DOM、单次访问 DOM 的代价消耗:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>DOM访问与重绘重排</title>
</head>
<body>
<div id="container1"></div>
<div id="container2"></div>
<div id="container3"></div>
<script>
var times = 15000;
// code1 每次都访问并修改DOM+重排+重绘
console.time(1);
for(var i = 0; i < times; i++) {
document.getElementById('container1').innerHTML += 'a';
}
console.timeEnd(1);
// code2 多次访问 DOM,一次修改DOM+重排+重绘
console.time(2);
var str = '';
for(var i = 0; i < times; i++) {
var tmp = document.getElementById('container2').innerHTML;
str += 'a';
}
document.getElementById('container2').innerHTML = str;
console.timeEnd(2);
// code3 一次访问并修改DOM+重排+重绘
console.time(3);
var _str = '';
for(var i = 0; i < times; i++) {
_str += 'a';
}
document.getElementById('container3').innerHTML = _str;
console.timeEnd(3);
</script>
</body>
</html>
保存并运行上述代码,打开控制台就可以看到输出结果:
从上面的数据可以看出,多次访问DOM(数据2)对于多次访问DOM并重排重绘(数据1)来说,耗时根本不值一提。我们一直知道一个性能规则:避免频繁进行 DOM 操作。因为我们一直被告知,DOM 操作性能代价是很大的,而从数据也更可以看出,重排重绘的代价更是巨大。因此了解如何减少不必要的重排重绘,这对页面性能的提升是巨大的。
重排何时发生
我们都知道浏览器解析 HTML 文档是从上往下的,布局时有“文档流”的概念存在。HTML 布局使用的是Flow Based Layout
规则,也就是“流式布局”,当元素的几何尺寸发生变化时,与此元素相关的元素都将需要重新布局。如果元素是存在文档中的,那么很可能其下方的元素都会受到影响,这时候就需要从html
节点开始递归往下,依次进行布局计算。
很显然,每次重排,必然会导致重绘,那么,重排会在哪些情况下发生?
- 添加、删除、修改可见的 DOM 节点时。
- 移动 DOM 在 HTML 文档的位置。
- 元素几何尺寸、位置改变。
- 元素内容改变(例如:一个文本被另一个不同尺寸的图片替代)。
- 某些 CSS 属性发生变化(例如
display: none
)。 - 用户交互,例如滚动。
- 浏览器窗口尺寸改变。
要注意的是,display: none
会触发重排重绘,而visibility: hidden
只会触发重绘,因为没有发生位置变化。
关于滚屏,通常来说,如果在滚屏的时候,我们的页面上的所有的像素都会跟着滚动,那么性能上没什么问题,因为浏览器对于这种把全屏像素往上往下移的算法是很快。但是如果现在我们有一个fixed
的背景图,或是有些元素不跟着滚动、有些元素是动画,那么这个滚动的动作对于浏览器来说会是相当痛苦的一个过程,每帧都将需要进行重排。
也正是这个原因,很多没做好重排重绘优化的视觉差效果的网页滚动起来都不那么流畅,甚至一些只是单纯固定了背景图的网页,滚动起来也没有普通的网页流畅。
而我们平常拖动浏览器窗口边缘改变窗口大小的时候也会导致重排重绘的频繁发生,一些没做好优化的网页 UI 会反应迟钝,拖动会不流畅。
实际上这些问题都是可以被优化的,对上述问题的优化建议后文会提到。
最小化重排重绘
渲染树变化的队列化修改与刷新
思考下面代码:
var elem = document.getElementById('container1');
console.time(4);
elem.style.borderLeft = '1px'; // 重排重绘
elem.style.borderRight = '2px'; // 重排重绘
elem.style.color = 'blue'; // 重绘
elem.style.padding = '5px'; // 重排重绘
console.timeEnd(4); //(4: 0.303ms)
乍一看,元素的样式改变了四次,每次改变都会引起重排重绘,所以上述总共有三次重排重绘和一次重绘的过程。但我们肯定会想,这四个改变其实只进行一次重排重绘会更好。实际上,现代浏览器也早已经对此进行了优化,浏览器会先把四次修改操作保存起来,再批量进行一次重排重绘,并不需要进行四次。
浏览器内部有一个修改队列会用来存储修改操作,在合适的时候才会统一进行刷新。
那何时是“合适的时候”呢?如果用户的操作需要强制刷新队列并要求计划任务立即执行,则会立刻进行刷新,否则将等到本次事件循环结束时才统一进行队列刷新。
获取布局信息的操作会导致队列立即刷新,因为如果我们的程序需要这些值,那么浏览器需要返回最新的值就必须立即执行一次重排重绘:
- offsetTop, offsetLeft, offsetWidth, offsetHeight
- scrollTop, scrollLeft, scrollWidth, scrollHeight
- clientTop, clientLeft, clientWidth, clientHeight
- getComputedStyle() (currentStyle in IE)
上面那段代码浏览器是会做优化的,只需要一次重排重绘。而如果现在我们将上述代码稍作修改,重排重绘次数就可能完全不一样:
var elem = document.getElementById('container1');
console.time(5);
elem.style.borderLeft = '1px';
elem.offsetWidth; // 访问元素宽度,触发重排重绘
elem.style.borderRight = '2px';
elem.offsetWidth; // 访问元素宽度,触发重排重绘
elem.style.color = 'blue';
elem.style.padding = '5px';
elem.offsetWidth; // 访问元素宽度,触发重排重绘
console.timeEnd(5); //(5: 11.944ms)
时间从0.303ms
变成了11.944ms
,虽然两段代码没有遵循单一变量的测试原则(多了三个语句),但两次测试的结果差距大的几乎可以把这个因素的影响比例消除。
上面的代码中,我们在布局信息改变时穿插着查询布局信息,这个操作打断了刷新队列的缓存操作,浏览器必须立即进行重排重绘,这个操作是非常耗时的。所以我们尽量不要在布局信息改变时做查询。
虽然上述代码存在的问题看起来非常明显,我们可能觉得了解完以后就不会犯这个毛病了。但实际开发中我们的代码可能也会存在这个问题,而且很有可能恰恰是因为隐藏得太深以至我们没有发现。因为实际开发中,我们在不同的 DOM 操作中可能会夹杂很多的中间语句,并不像示例代码中这么简洁直接,在这么多代码中,我们在修改布局信息时不小心穿插了查询布局信息的代码也不足为奇。
下面列举一下会触发重排重绘的一些 CSS 属性。
会触发重排重绘的一些 CSS 属性
触发重排:
盒子模型相关属性:
- width
- height
- padding
- margin
- display
- border-width
- border
- min-height
定位属性及浮动:
- top
- bottom
- left
- right
- position
- float
- clear
改变节点内部文字结构:
- text-align
- overflow-y
- font-weight
- overflow
- font-family
- line-height
- vertival-align
- white-space
- font-size
触发重绘:
修改时只触发重绘的属性:
- color
- border-style
- border-radius
- visibility
- text-decoration
- background
- background-image
- background-position
- background-repeat
- background-size
- outline-color
- outline
- outline-style
- outline-width
- box-shadow
上面触发重绘的属性没有
opacity
。前面说过渲染新帧主要有三个步骤:布局计算、渲染和合成。而实际上透明度发生改变后,GPU 在绘画时只是通过降低之前已经画好的纹理的 alpha 值来达到效果即可,影响的是合成环节,而不需要进行整体的重绘。
最小化重排重绘的最佳实践
1. 用样式类名进行样式修改操作
不要一条一条地修改 DOM 的样式。与其这样,还不如预先在 CSS 定义好样式类,然后修改 DOM 的 className。
// bad
elem.style.left = '10px';
elem.style.top = '10px';
// good
elem.className += ' move';
// good,或者是一次修改 cssText
elem.style.cssText += '; left: 10px; top: 10px;';
实际开发中,如果是多个关联样式需要修改,最好的做法是通过切换 class 来实现,这也是最方便的方式并且也是解耦的。如果只是单纯想显隐元素,可以直接修改 DOM 内联样式。
2. 把 DOM 离线后修改
display: none
的元素是不在渲染树中的,所以我们很容易可以想到,如果我们操作一个 DOM 时,将其display
设成none
即可避免多次的重排重绘了,这是最简单的方式。
不过显而易见的是,这种离线方式,如果操作时间过长,页面是会造成闪动的。
针对闪动问题,解决方案有两个,一个是先将 DOM 节点clone
到内存中,修改完毕后再跟原节点替换;另一个是利用fragment
,这个方案一般用于动态添加 DOM 片段时使用。
// javascript
var fragment = document.createDocumentFragment();
var li = document.createElement('li');
li.innerHTML = 'apple';
fragment.appendChild(li);
var li = document.createElement('li');
li.innerHTML = 'watermelon';
fragment.appendChild(li);
document.getElementById('fruit').appendChild(fragment);
// jQuert
var fragment = $.buildFragment();
var li = $('<li></li>');
li.html('apple');
fragment.append(li);
var li = $('<li></li>');
li.html('watermelon');
fragment.append(li);
$('#fruit').append(fragment);
3. 不要把 DOM 结点的属性值放在一个循环里当成循环里的变量,这会导致大量地访问这个结点的属性。
var i = 100;
// bad
while ( i-- ) {
console.log( elem.offsetWidth );
}
// good
var _offsetWidth = elem.offsetWidth;
while ( i-- ) {
console.log( _offsetWidth );
}
4. 少使用 table 布局,因为很小的一个改动往往会造成整个 table 的重新布局。
5. 让动画元素脱离文档流
一般来说,重排只影响渲染树中的一小部分,但也可能影响很大的部分,甚至整个渲染树。浏览器所需要重排的次数越少,应用程序的响应速度就越快。
6. float 属性谨慎使用,因为 float 元素的布局极其容易被影响。
7. 别使用 CSS 类名做状态标记
如果在网页中使用 CSS 的类来对节点做状态标记,当这些节点的状态标记类修改时,将会触发节点重新进行布局计算。所以在节点上使用 CSS 类来做状态标记代价是比较昂贵的。
8. CSS 层级嵌套关系避免过深,并使用高效的书写方式。
受 CSS 层级嵌套影响的环节是渲染树构建和重排。
CSS 选择器解释实际上是从右往左的,意思是,当我们书写#wrap div span
这样的选择器时,浏览器首先要找到所有的span
元素,再递进判断父元素是否符合条件,并不是先寻找 id 为 wrap
的元素。所以根据此点,我们要避免 CSS 层级关系嵌套过深。
而第二点,使用高效的书写方式。结合第一点的查找规则,我们可以多使用具体类名或者 id 来代替标签名,这样的书写方式更为高效。
8. 某些 DOM 事件回调函数,使用限流策略。
这是上文提到的一个问题:改变浏览器窗口大小频繁触发重排重绘,导致 UI 迟钝。
因为在我们监听resize
或者scroll
事件的时候,一旦用户拖动窗口,或滚动滚动条时,将会十分频繁的触发回调函数。
稍有经验的开发者,都会在这些回调函数上使用setTimeout
限制操作频率,大概思想就是:设一个操作时间阀值,例如 300 ms,如果本次触发回调时,上一次设定的定时器还存在,那么就不执行回调操作,并重设 300 ms的定时器。这样一来,就可以将回调函数调用频率限制在最快 300ms 一次。
underscore
和loadsh
都为此类操作包装了一个限流函数.debounce()
,可以直接调用,非常方便:
_.debounce(calculateLayout, 300);
渲染分层与硬件加速
上文《WebKit 渲染流程基础及分层加速》有介绍到渲染的分层机制和硬件加速,我们在实际开发中其实可以利用渲染分层的机制进行减少重排重绘范围,以此加速渲染。
前面留下一个问题仍未给出解答:背景被设为fixed
的网页,或是一些视觉差效果的网页,我们可以如何进行渲染优化?
首先要明确知道,之所以固定背景的网页比普通背景的网页更卡的原因:是因为普通背景的网页滚动时,实现整屏像素位移对浏览器来说是比较简单的;而对于那些固定背景的网页,我们每次滚动时,因为背景层和内容层不是同步移动的,接下来我们看到的每一帧浏览器都需要进行重排重绘以达到我们看到的效果,这样一来滚动操作流畅度自然会降低。
渲染新帧主要有三个步骤:布局计算、渲染和合成,前面说过,浏览器实现透明度改变,不一定需要重新渲染页面,可能只需要改变 GPU 中已有纹理的 alpha 值就能显示出透明度改变的效果,即只需要进行合成的步骤。而相对于布局计算和渲染的耗时来说,合成步骤的耗时是非常小的。
渲染分层和硬件加速的思想实际上就是把帧渲染的工作更多的放在合成这一步骤上。
具体来说,针对一些固定背景的网页,我们可以将内容层独立出来生成一个 3D 上下文,在内核层面来看,就是为其独立创建一个 RenderLayer 层,此时背景层所在的 RenderLayer 层和内容层所在的 RenderLayer 层就相互独立了,这时候再滚动页面,固定的背景层是不需要进行重排重绘工作的。而对于内容层,浏览器也可以使用整屏像素移动的算法进行滚动。最后渲染新帧的时候,浏览器只需要把这两个 RenderLayer 层叠加合成出来即可。这样一来,整个页面的性能就自然而然得到改善了。
这种创建 RenderLayer 层加速渲染的方式,俗称触发GPU加速
。
一般来说,使用创建 3D 上下文的 CSS 属性就能触发 GPU 加速,因为传统的 CPU 渲染是不支持 3D 绘图的。
常被用来触发 GPU 加速的 CSS 属性:translateZ(0)
、translate3D(0,0,0)
、scaleZ(0)
等等。
例如:
.cube {
-webkit-transform: translateZ(0);
-moz-transform: translateZ(0);
-ms-transform: translateZ(0);
-o-transform: translateZ(0);
transform: translateZ(0);
/* Other transform properties here */
}
或
.cube {
-webkit-transform: translate3d(0, 0, 0);
-moz-transform: translate3d(0, 0, 0);
-ms-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
/* Other transform properties here */
}
用上面其中一个属性,就能创建 3D 上下文,创建新的渲染曾,触发 GPU 加速。
但是如果拥有 3D 上下文的元素太多(6个以上),在进行 transform 或 animate 的时候,Chrome 和 Safari 中会有频繁闪烁或抖动页面的情况出现,例如当你有多个position: absolute;
元素添加-webkit-transform:transition3d(0,0,0);
开启GPU硬件加速之后,会有几个元素凭空消失。最好的解决方式是减少 3D 上下文元素的数量, 次之可增加如下 CSS 代码:
// 通过隐藏元素旋转的背面(backface-visibility),以及设置一定的视图查看距离(perspective)来解决此问题
.cube {
-webkit-backface-visibility: hidden;
-moz-backface-visibility: hidden;
-ms-backface-visibility: hidden;
backface-visibility: hidden;
-webkit-perspective: 1000;
-moz-perspective: 1000;
-ms-perspective: 1000;
perspective: 1000;
/* Other transform properties here */
}
可用 transform 变换代替 top 与 left 进行动画
在我们使用绝对定位的 top 和 left 来修改节点的位置实现动画的时候,经常会感觉动画不太流畅(在手机端上更为明显),因为 left 和 top 会触发重布局计算,修改时的代价相当大。
这时候我们就可以利用上面提到的translate3D
来代替 top 和 left 进行动画,这个属性不会触发其他元素的重布局,可以明显提高动画流畅度。
可用的加速属性
动画给予了页面丰富的视觉体验。我们应该尽力避免使用会触发重布局和重绘的属性,以免失帧。由于GPU的参与,现在用来做动画的最好属性是如下几个:
- opacity
- translate
- rotate
- scale
will-change:更高端的启用 GPU 加速的方式
使用 CSS3 的will-change
属性。will-change
属于 web 标准属性,虽然目前还是草案阶段,但出现已经有些时日了,兼容性这块 Chrome/Safari/FireFox/Opera 目前已经支持(查看 caniuse )。
这个属性的作用很单纯,就是“增强页面渲染性能”。
我们前面说的,3D transform 会启用 GPU 加速,例如 translate3D , scaleZ 之类,但是呢,这些属性业界往往称之为hack
加速法。我们实际上不需要 z 轴的变化,但是还是假模假样地声明了,目的是欺骗浏览器。
而 CSS3 的 will-change
属性则天生为此设计,主动向浏览器声明“我要改变了”。
当我们通过某些行为(点击、移动或滚动)触发页面进行大面积绘制的时候,浏览器往往是没有准备的,只能被动使用 CPU 去计算与重绘,由于没有事先准备,浏览器空闲程度没有保证,渲染效率自然可能受到影响,掉帧、卡顿。而will-change
则用来在真正的行为触发之前提前通知浏览器,使得浏览器更加从容的面对即将到来的渲染。
MDN 上该属性语法值如下:
/* 关键字值 */
will-change: auto;
will-change: scroll-position;
will-change: contents;
will-change: transform; /* <custom-ident>示例 */
will-change: opacity; /* <custom-ident>示例 */
will-change: left, top; /* 两个<animateable-feature>示例 */
/* 全局值 */
will-change: inherit;
will-change: initial;
will-change: unset;
使用举例:
.front::before {
content: '';
position: fixed; // 代替background-attachment
width: 100%;
height: 100%;
top: 0;
left: 0;
background-color: white;
background: url(/img/front/mm.jpg) no-repeat center center;
background-size: cover;
will-change: transform; // 创建新的渲染层
z-index: -1;
}
更具体详细的介绍不在本文范畴,想详细了解此属性的可移步 张鑫旭:使用CSS3 will-change提高页面滚动、动画等渲染性能。
结语:避免大量使用 GPU 加速
通过开启 GPU 硬件加速虽然可以提升动画渲染性能或解决一些棘手问题,但使用仍需谨慎,使用前一定要进行严谨的测试,因为 GPU 加速会大量占用浏览网页用户的系统资源,增加内存使用,创建过多图层也可能会导致页面崩溃,尤其是在移动端,肆无忌惮的开启 GPU 硬件加速会导致大量消耗设备电量,降低电池寿命等问题。