STAY HUNGRY , STAY FOOLISH.

求知若饥,虚心若愚。

       浏览:

重排与重绘

什么是重排与重绘

浏览器加载完用户请求的页面之后会解析生成两个内部数据结构—-DOM树和渲染树。

browser

图1:浏览器显示页面抽象图

DOM树是页面文档的结构表示,当DOM树构建完成时,浏览器开始构建渲染树。
DOM树中的每一个可见的节点(包括visibility=hidden的元素)在渲染树中至少存在一个对应的节点(display值为none的元素在渲染树中没有对应的节点)。所以渲染树是文档的可视化表示,这棵树是为了正确的绘制文档内容而建立。
渲染树中的元素可称之为渲染对象。每个渲染对象用一个和该节点的CSS盒模型相对应的矩形区域来表示,它包含诸如宽、高和位置之类的几何信息。盒模型的类型受该节点相关的display样式属性的影响。
一个渲染对象知道怎么布局及绘制自己和它的子元素。
当DOM的变化影响了元素的几何属性(宽或者高等等),浏览器需要重新计算元素的几何属性,同样其他元素的几何属性和位置也有可能会因此受到影响。
浏览器会使渲染树中受到影响的部分失效,并重新构造渲染树,这个过程称为“重排”。完成重排后,浏览器会重新绘制受到影响的部分到屏幕,该过程称为“重绘”。
由于浏览器的流布局,对渲染树的计算通常只需要遍历一次就可以完成。
并不是所有的DOM变化都会影响几何属性,比如改变一个元素的背景色并不会影响元素的宽和高,这种情况下只会发生重绘。


现代浏览器渲染原理

现代浏览器大都利用了GPU来加速网页渲染。GPU是专用于图形渲染的芯片,它很擅长做如下事情:

  1. 绘制位图到屏幕上
  2. 对图片进行处理,例如:修改位置、旋转或缩放等等

Webkit为核心的浏览器渲染

浏览器在渲染一个页面时,会做如下的一些工作:

  1. 将DOM分割为多个图层(每个图层上有一个或多个节点)
  2. 根据样式计算每个图层中节点的最终样式
  3. 根据样式结果对每个节点生成图形和位置
  4. 绘制每个节点并填充到图层位图中
  5. 将图层位图数据传给GPU
  6. GPU组合多个图层到页面上生成最终屏幕图像

图层分割依据

Webkit类浏览器满足以下任意情况就会创建图层:

  • 利用CSS3进行3D变换(translate3d(x,y,z))
  • 拥有3D(WebGL)上下文或加速的2D上下文<canvas>节点
  • 使用加速视频解码的<video>节点
  • 混合插件(如Flash)
  • 对自己的opacity做CSS动画或使用一个动画变换的元素
  • 拥有硬件加速CSS过滤器的元素
  • 元素拥有一个子元素,且该子元素在自己的层里
  • 元素在一个兄弟元素上面渲染(z-index比兄弟元素高),且该兄弟元素在自己的图层里面

如果图层中某个元素需要重绘,那么整个图层都需要重绘。所以图层DOM节点越少,重排和重绘的性能越好。
比如一个图层包含很多节点,其中有个gif图,gif图的每一帧都会重绘整个图层的其他节点,然后生成最终的图层位图。这时候最好强制gif图片位于自己的一个图层中去,绝大部分浏览器自己会为CSS3动画的节点创建新的图层。

强制元素属于自己的图层

利用translateZ(0)或者translate3d(0,0,0)开启硬件加速可以使一个元素属于自己的一个层。当有了单独的层之后,此元素的重排、重绘操作将只需要更新自己,不用影响到别人。可以看做是局部更新,所以开启了硬件加速的动画会变得流畅很多。

图层和CSS3动画

简化一下上述过程,每一帧动画浏览器可能需要做如下工作:

  1. 计算节点样式
  2. 为每个节点生成图形和位置(重排)
  3. 为每个节点填充到图层位图中(重绘)
  4. 组合图层到页面上(重组)
    如果我们需要使得动画的性能提高,需要做的就是减少浏览器在动画运行时所需要做的工作。最好的情况是,改变的属性仅仅影响图层的组合,变换(transform)和透明度(opacity)就属于这种情况。
    现代浏览器如Chrome、Firefox、Safari和Opera都对变换和透明度采用了硬件加速。
    综合上面所述得知现代浏览器在使用CSS3动画时,以下四种情形绘制的效率较高,分别是:
  • 改变大小(scale3d)
  • 改变位置(translate3d)
  • 旋转(rotate3d)
  • 改变透明度(opacity)

控制图层的数量

在做页面时需要控制图层的数量,因为层的创建和更新都会消耗内存。控制图层重绘的次数是为了减少位图更新的次数。每次位图的更新,线程就需要提交新的位图给GPU,频繁地更新位图也会拖慢GPU的效率。


不当的重排和重绘代价

不当的重排和重绘代价有多大?看看下面这个例子就知道了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
var times=2000;

//codeA times次(重排+重绘)
console.time(1);
for(var i=0;i<times;i++){
document.getElementById('myDiv1').innerHTML+='a';
}
console.timeEnd(1);

//codeB 一次(重排+重绘)+times次访问DOM
console.time(2);
var s='';
for(var i=0;i<times;i++){
var tmp=document.getElementById('myDiv2').innerHTML;
s+='a';
}
document.getElementById('myDiv2').innerHTML=s;
console.timeEnd(2);

//codeC 一次(重排+重绘)
console.time(3);
var s1='';
for(var i=0;i<times;i++){
s1+='a';
}
document.getElementById('myDiv3').innerHTML=s1;
console.timeEnd(3);

//三次输出时间
//1: 16603.308ms
//2: 16.307ms
//3: 11.244ms

从上面的例子可以看出一次改变DOM内容和多次改变DOM内容性能相差了千倍,这是因为每次改变DOM内容都会引起浏览器渲染引擎的重排和重绘,而Cordova App编程消耗的很多性能瓶颈也正是在这里。

从上面还能计算出每次重排与重绘的时间约为8.3ms((16603-11)/2000)。从而可以算出CodeC 2000次字符串拼接用了大约3ms。
总的来说,多次访问DOM对于重排和重绘来说,耗时不值得一提,JS本身的一些计算更不值得一提。


重排重绘何时发生

总的来说,每次DOM元素的几何属性或者内容改变时将会导致重排和重绘同时发生;每次DOM元素的非几何样式发生改变时会重绘,大致有以下几种情况:

  • 添加、删除可见DOM元素
  • 修改可见DOM元素的颜色属性(一般仅仅导致重绘)
  • 元素位置改变
  • 元素尺寸改变
  • 页面渲染初始化(这个无法避免)
  • 浏览器窗口尺寸改变

上面这些改变有的会引起全局重排与重绘,有的只会引起局部或者叫增量的重排与重绘。有的只会引起重绘,有的会引起重排与重绘都发生,下面具体看看会引起重排和重绘的CSS属性。


触发重排的属性

1.盒子模型相关属性会触发重排:

1
2
3
4
5
6
7
8
.model{
padding:;
width:;
height:;
border:;
margin:;
display:;
}

定位属性及浮动也会触发重排:

1
2
3
4
5
6
7
8
9
.model{
position:;
top:;
left:;
bottom:;
right:;
float:;
clear:;
}

改变节点内部文字样式也会触发重排:

1
2
3
4
5
6
7
8
9
10
11
.model{
font-size:;
font-family:;
font-weight:;
line-height:;
overflow:;
vertical-align:;
text-align:;
text-overflow:;
white-space:;
}

可以看到,它们的特点就是可能修改整个节点的大小或位置,所以会触发重排。


触发重绘的属性

修改时只触发重绘的属性有:

1
2
3
4
5
6
7
8
9
10
.model{
color:;
border:;
visibility:;
text-decoration:;
text-shadow:;
background:;
outline:;
box-shadow:;
}

渲染树变化的排队和刷新

请看下面代码:

1
2
3
4
var el=document.getElementById('myDiv');
el.style.width='200px';
el.style.height='100px';
el.style.borderWidth='10px';

元素的几何样式改变了3次,每次改变都会引起重排和重绘,所以总共有4次重排重绘的过程。浏览器会通过队列缓存来合并3次为一次修改来优化重排与重绘,但是,有时候你可能会不知不觉强制刷新队列并要求任务立即执行。

获取布局属性信息的操作会导致队列刷新,例如:

1
2
3
4
offsetTop、offsetLeft、offsetWidth、offsetHeight,
clientTop、clientLeft、clientWidth、clientHeight,
scrollTop、scrollLeft、scrollWidth、scrollHeight,
getComputedStyle()

将上面的代码稍加修改:

1
2
3
4
5
var el=document.getElementById('myDiv');
el.style.width='200px';
el.style.height='100px';
var ot=el.offsetTop;//获取offsetTop
el.style.borderWidth='10px';

因为offsetTop属性需要返回最新的布局信息,因此浏览器不得不清空渲染队列立马触发重排以返回正确的值(就算渲染队列中的布局改变信息和我们要获取的没有关系),所以上面的代码,前两次的操作会缓存在渲染队列中等待处理,但是一旦offsetTop属性被请求了,队列就会立即执行,所以会有2次重排与重绘,所以尽量不要在布局信息改变时做查询。


重排重绘优化

1.集中修改样式
我们还是看上面的这段代码:

1
2
3
4
var el=document.getElementById('myDiv');
el.style.width='200px';
el.style.height='100px';
el.style.borderWidth='10px';

3个样式属性被改变,每一个都会影响元素的几何结构。虽然现代浏览器都做了优化,只会引起一次重排,但是像上文一样,如果一个即时的CSS几何属性被请求,那么就会强制刷新队列,而且这段代码4次访问DOM,一个很显然的优化策略就是把它们的操作合成一次,这样只会重排和重绘DOM一次:

1
2
3
4
5
var el=document.getElementById('myDiv');
/*第一种优化:使用cssText*/
el.style.cssText='width:200px;height:100px;border-width:10px;';
/*第二种优化:使用css class*/
el.className ='myDivClass';

2.缓存元素几何属性值
对于元素几何属性值,如果需要多次访问则可以在一次访问时先储存到变量中,之后都使用该变量,这样可以避免每次读取属性时无意间造成浏览器的渲染。

1
2
var width=el.offsetWidth;
var scrollLeft=el.scrollLeft;

3.fragment元素的应用
看如下代码,考虑一个问题:

1
2
3
4
<ul id='framework'>
<li>appframework</li>
<li>ionic</li>
</ul>

如果要添加内容为jquery-mobile、sencha-touch两个选项,可以用如下代码实现:

1
2
3
4
5
6
7
8
var liWrap=document.getElementById('framework');
var li=document.createElement('li');
li.innerHTML='jquery-mobile';
liWrap.appendChild(li);

var li2=document.createElement('li');
li2.innerHTML='sencha-touch';
liWrap.appendChild(li2);

但是很显然,上面的代码会导致重排重绘两次,怎么优化?
隐藏的元素不在渲染树中,我们可以先把id为framework的ul元素隐藏(display=none),然后添加li元素,最后再显示,但是实际操作中可能会出现闪动,所以不推荐使用。
删除的元素不在渲染树中,我们可以先把id为framework的ul元素删除,然后添加li元素,最后再显示,但是实际操作中也可能会出现闪动,所以不推荐使用。
但是我们还可以使用fragment这个元素,利用如下代码就OK:

1
2
3
4
5
6
7
8
9
10
11
12
var liWrap=document.getElementById('framework');
var fragment=document.createDocumentFragment();

var li=document.createElement('li');
li.innerHTML='jquery-mobile';
fragment.appendChild(li);

var li2=document.createElement('li');
li2.innerHTML='sencha-touch';
fragment.appendChild(li2);

liWrap.appendChild(fragment);

文档片段是个轻量级的Document对象,它的设计初衷就是为了完成这类重复大量的更新和移动DOM节点的。
文档片段的一个语法特性是当你附加一个片断到节点时,实际上被添加的是该片断的子节点,而不是片断本身。只触发了一次重排,而且只访问了一次实时的DOM。