有没有被标题中的5个“R”吓到?今天,我们来讨论一下浏览器的渲染(Rendering)-一个产生于Page 2.0生命周期中,甚至有时候会在下载瀑布流中出现的过程。
我们来讨论浏览器在接收到HTML、CSS和JavasSript后,如何把你的页面呈现在屏幕上。
一、浏览器渲染过程
不同的浏览器的渲染过程存在些许不同,但大体的机制是一样的,下图展示的是浏览器自下载完全部的代码后的大致流程
- 首先,浏览器解析HTML源码构建DOM树,在DOM树中,每个HTML标签都有对应的节点,并且在介于两个标签中间的文字块也对应一个text节点。DOM树的根节点是documentElement,也就是<html>标签;
- 然后,浏览器对CSS代码进行解析,一些当前浏览器不能识别的CSS hack写法(如-moz-/-webkit等前缀,以及IE下的*/_等)将会被忽略。CSS样式的优先级如下:最低的是浏览器的默认样式,然后是通过<link>、import引入的外部样式和行内样式,最高级的是直接写在标签的style属性中的样式;
- 随后将进入非常有趣的环节-构建渲染树。渲染树跟DOM树结构相似但并不完全匹配。渲染树会识别样式,所以如果通过设置display:none隐藏的标签是不会被渲染树引入的。同样的规则适用于<head>标签以及其包含的所有内容。另外,在渲染树中可能存在多个渲染节点(渲染树中的节点称为渲染节点)映射为一个DOM标签,例如,多行文字的<p>标签中的每一行文字都会被视为一个单独的渲染节点。渲染树的一个节点也称为frame-结构体,或者盒子-box(与CSS盒子类似)。每个渲染节点都具有CSS盒子的属性,如width、height、border、margin等;
- 最后,等待渲染树构建完毕后,浏览器便开始将渲染节点一一绘制-paint到屏幕上。
二、森林和树
首先我们先看一个例子:
<html> <head> <title>Beautiful page</title> </head> <body> <p> Once upon a time there was a looong paragraph... </p> <div style="display: none"> Secret message </div> <div><img src="..." /></div> ... </body> </html>
HTML结构中的每个标签和标签间的文字都会被映射为DOM树种的一个节点(实际上,空白区域也会被映射为一个text节点,为了简单说明,在此忽略),构建完成的DOM树结构如下:
documentElement (html) head title body p [text node] div [text node] div img ...
由于渲染树会忽略head内容和隐藏的节点,并且会将<p>中的多行文字按行数映射为单独的渲染节点,故构建完成的渲染树结构如下:
root (RenderView) body p line 1 line 2 line 3 ... div img ...
渲染树的根节点是一个包括所有其他节点的结构体(盒子)。你可以将它理解为浏览器窗口的内部区域(个人理解为可绘制区域,即不包括浏览器边框、菜单栏、标签栏等等),页面被限制在此区域内。严格来说,webkit将渲染树的根节点称为渲染视图-RenderView,渲染视图符合CSS初始包含块-initial containing block,也就是浏览器的整个可绘制区域,从坐标(0,0)到(window.innerWidth,window.innerHeight)。
接下来,我们将研究浏览器是如何通过循环遍历渲染树把页面展示到屏幕上的。
三、重绘-repaint和回流-reflow
同一时间内至少存在一个页面初始化layout行为和一个绘制行为(除非你的页面是空白页-blank)。在此之后,改变任何影响构造渲染树的行为都会触发以下一种或者多种动作:
- 渲染树的部分或者全部将需要重新构造并且渲染节点的大小需要重新计算。这个过程叫做回流-reflow,或者layout,或者layouting(靠,能不能愉快的翻译了,是不是还来个过去式啊?!),或者relayout(这词是原文作者杜撰的,为了标题中多个“R”)。浏览器中至少存在一个reflow行为-即页面的初始化layout;
- 屏幕的部分区域需要进行更新,要么是因为节点的几何结构改变,要么是因为格式改变,如背景色的变化。屏幕的更新行为称作重绘-repaint,或者redraw。
重绘和回流的性能消耗是非常严重的,破坏用户体验,造成UI卡顿。
四、触发重绘/回流的机制
改变任何影响构造渲染树的行为都会触发重绘,例如
- 增加、删除、更新DOM节点;
- 通过display:none隐藏节点会触发重绘和回流,通过visibility:hidden隐藏只会触发重绘,因为没有几何结构的改变;
- 移动节点和动画;
- 增加、调整样式;
- 用户操作行为,如调整窗口大小、改变字体大小、滚动窗口(OMG,no!)等。
举个栗子:
var bstyle = document.body.style; // 缓存 bstyle.padding = "20px"; // 触发重绘和回流 bstyle.border = "10px solid red"; // 再次触发重绘和回流 bstyle.color = "blue"; // 只触发重绘,因为几何结构没有改变 bstyle.backgroundColor = "#fad"; // 同上 bstyle.fontSize = "2em"; // 再再次触发重绘和回流 // 新增DOM节点,再再再次触发重绘和回流 document.body.appendChild(document.createTextNode('dude!'));
有些回流行为要比其他的花销大一些。设想如下情景,一个直属于body节点的渲染树,如果你在此渲染树中乱搞,它不会影响很多其他节点(这个长句翻译不好,原文如下:Think of the render tree - if you fiddle with a node way down the tree that is a direct descendant of the body, then you’re probably not invalidating a lot of other nodes)。但是如果将页面顶部的一个div做动画或改变尺寸,页面的其他部分会被挤来挤去,这听起来会消耗很多性能。
五、聪明的浏览器
浏览器一直在努力减少消耗巨大的重绘和回流行为。要么选择不执行,要么至少不立即执行。浏览器会生成一个队列用于缓存这些行为并且以块为单位执行它们。通过这种方法,多次引发重绘或回流的操作会被组合在一起,以便在一个回流中完成。浏览器将这些操作加入到缓存队列中,当到达一定的时间间隔,或者累积了足够多的操作行为后执行它们。
但是,有时候某些的代码会破坏上述的浏览器优化机制,导致浏览器刷新缓存队列并且执行所有已已缓存的操作行为。这种情况发生在请求/获取下面这些样式的行为中:
- offsetTop,offsetLeft,offsetWidth,offsetheight
- scrollTop/Left/Width/Height
- clientTop/Left/Width/Height
- getComputedStyle(),或者IE下的currentStyle
以上的行为本质上是获取一个节点的样式信息,浏览器必须提供最新的值。为了达到此目的,浏览器需要将缓存队列中的所有行为全部执行完毕,并且被强制回流。
所以,在一条逻辑中同时执行set和get样式操作时非常不好的,如下:
el.style.left = el.offsetLeft + 10 + "px";
六、如何减少重绘和回流
减少因为重绘和回流引起的糟糕用户体验的本质是尽量减少重绘和回流,减少样式信息的set行为。可以通过以下几点来优化:
- 不要逐个修改多个样式。对于静态样式来说,最明智和易维护的代码是通过改变classname来控制样式;而对于动态样式来说,通过一次修改节点的cssText来代替样式的逐个改变。
// 糟糕的办法 var left = 10, top = 10; el.style.left = left + "px"; el.style.top = top + "px"; //静态样式通过改变classname // better el.className += " theclassname"; // 动态样式统一修改cssText // better el.style.cssText += "; left: " + left + "px; top: " + top + "px;";
- “离线”处理多个DOM操作。“离线”的意思是将需要进行的DOM操作脱离DOM树,比如:
- 通过documentFragment集中处理临时操作;
- 将需要更新的节点克隆,在克隆节点上进行更新操作,然后把原始节点替换为克隆节点;
- 先通过设置display:none将节点隐藏(此时出发一次回流和重绘),然后对隐藏的节点进行100个操作(这些操作都会单独触发回流和重绘),完毕后将节点的display改回原值(此时再次触发一次回流和重绘)。通过这种方法,将100次回流和重绘缩减为2次,大大减少了消耗
- 不要过多进行重复的样式计算操作。如果你需要重复利用一个静态样式值,可以只计算一次,用一个局部变量储存,然后利用这个局部变量进行相关操作。例如:
//糟糕的做法 for(big; loop; here) { el.style.left = el.offsetLeft + 10 + "px"; el.style.top = el.offsetTop + 10 + "px"; } //优化后的代码 var left = el.offsetLeft, top = el.offsetTop esty = el.style; for(big; loop; here) { left += 10; top += 10; esty.left = left + "px"; esty.top = top + "px"; }
- 总之,当你在打算改变样式时,首先考虑一下渲染树的机制,并且评估一下你的操作会引发多少刷新渲染树的行为。例如,我们知道一个绝对定位的节点是会脱离文档流,所以当对此节点应用动画时不会对其他节点产生很大影响,当绝对定位的节点置于其他节点上层时,其他节点只会触发重绘,而不会触发回流。
七、工具
(废话就不翻译了,大概就是一些吐槽IE开发者工具的话)
现在(原文作于2009年12月)有很多可以帮助我们深入了解浏览器重绘和回流机制的工具。
- FireFox提供了mozAfterPaint接口可供开发者查看重绘的动作;
- DynaTrace Ajax适用于IE浏览器,谷歌的SpeedTracer适用于Webkit内核的浏览器,这两种工具可以帮助开发者深入挖掘重绘和回流行为;
Douglas Crockford去年提到,我们可能会对一些不太了解的CSS做一些愚蠢的事情,并且我被包括在内。我被引入了一个项目组,研究一种奇怪的现象:在IE6浏览器中增大font-size会引起CPU占用率到达100%,并且会持续10到15分钟,IE浏览器才会完成重绘行为。
有了工具的辅助,我们没有任何理由再做一些愚蠢的CSS操作了。
顺便提一句,如果有一种像Firebug的工具可以象查看DOM结构一样查看渲染树,是不是很cooooooooooooooool?
八、举个栗子
下面我们简单的看一个如何运用工具来证明restyle(没有几何结构改变的渲染树变化)和回流(同时影响布局layout)、重绘。
第一个测试,我们比较解决同一问题的两种方法。第一种方法,改变一些样式,在每次改变之后检查一次呗改变的样式。
bodystyle.color = 'red'; tmp = computed.backgroundColor; bodystyle.color = 'white'; tmp = computed.backgroundImage; bodystyle.color = 'green'; tmp = computed.backgroundAttachment;
第二种方法,在等待全部样式改变完毕后再检查变化的样式信息。
bodystyle.color = 'yellow'; bodystyle.color = 'pink'; bodystyle.color = 'blue'; tmp = computed.backgroundColor; tmp = computed.backgroundImage; tmp = computed.backgroundAttachment;
上面两种方法用到的几个变量如下:
var bodystyle = document.body.style; var computed; if (document.body.currentStyle) { computed = document.body.currentStyle; } else { computed = document.defaultView.getComputedStyle(document.body, ''); }
上面两中方法的样式改变通过click事件触发。测试页面-restyle.html(点击“dude”)。我们将第一个测试称为restyle测试。
第二个测试在第一个测试的基础上,同事改变影响布局的样式。
// 每次修改后都检查 bodystyle.color = 'red'; bodystyle.padding = '1px'; tmp = computed.backgroundColor; bodystyle.color = 'white'; bodystyle.padding = '2px'; tmp = computed.backgroundImage; bodystyle.color = 'green'; bodystyle.padding = '3px'; tmp = computed.backgroundAttachment; // 全部修改完毕后再检查 bodystyle.color = 'yellow'; bodystyle.padding = '4px'; bodystyle.color = 'pink'; bodystyle.padding = '5px'; bodystyle.color = 'blue'; bodystyle.padding = '6px'; tmp = computed.backgroundColor; tmp = computed.backgroundImage; tmp = computed.backgroundAttachment;
我们称第二个测试为relayout测试,测试页面请点击。
我们通过DynaTrace工具得到restyle测试的表现如下图:
等页面加载完毕后,在第2秒左右点击触发第一种方案(即每次修改样式后立即检查),然后在第4秒左右再次点击触发第二种方案(即等待所有样式修改完毕后再统一检查)。
DynaTrace工具会显示页面的加载过程,从上图可以看到IE的logo图标被加载的时间节点。把鼠标移至Rendering一行以便追踪点击事件,滑动滚轮放大想要追踪的区域可以查看详细信息,如下图:
从上图中可以清晰的看到代表JavaScript行为的蓝色柱形条,一届代表渲染行为的绿色柱形条。通过这个简单的实验,我们可以注意到两个柱形条的长度,也就是比较渲染行为比JavaScript行为多花费的时间。在Ajax以及富应用中,性能瓶颈并不是JavaScript行为,而是DOM节点的操作使用和渲染行为。
接下来我们来运行relayout测试,也就是涉及几何结构改变的操作行为。通过测试工具的“PurePaths”视图,查看每种行为执行时间的瀑布流。下图中高亮部分显示的是第一次点击事件,执行一段JavaScript逻辑实现一些layout操作。
如下图所示,我们可以看到在这次的测试中,除了与第一次测试同样的具有代表“绘图”的绿色柱形条以外,还有一个新增的区域-“计算布局流”,因为这次测试中同时触发了重绘和回流。
接下来,我们通过SpeedTracer工具在Chrome下运行上面两个测试。
第一个测试-restyle测试的运行结果如下图所示:
总的来说,仍然是一次点击触发一次重绘,但是我们注意到,在第一次点击的时候,会有50%的时间消耗在计算样式(Style Recalculation)上。导致这种结果的原因是我们在每次改变样式后都检查了一次样式信息。
展开事件详细信息后可以清晰的看到,在第一次点击事件后,样式被计算了3次。而第二次点击值计算了一次。如下图所示:
接下来运行第二个测试-relayout测试。总体事件信息与restyle测试大致相同:
但是详情页显示的信息可以看到第一次点击后触发了3次回流(由请求样式信息操作触发),第二次点击只触发了一次回流。通过本工具可以清晰的看到浏览器内部到底发生了什么。
上述两种工具的区别在于:DynaTrace会显示layout行为被执行和加入执行队列的详细时间,而SpeedTracer不会;SpeedTracer会将restyle与reflow/layout两种浏览器行为区别开,而DynaTrace不会。难道IE浏览器本身不会区分这两种行为?另外,在两种不同的逻辑测试-改变-最后检查(change-end-touch)与改变-立即检查(change-then-touch)中,DynaTrace并不会显示两者触发回流的次数不同(第一种之触发一次,第二次触发3次,而DynaTrace统一显示为一次),难道IE浏览器的工作机制本就如此?
即使运行上述测试几百次,IE浏览器仍然不关心你在改变样式后是否请求样式信息。(译者注:我似乎感到原文作者对IE满满的恶意…)
在多次运行上述测试后,得到几点结论如下:
- Chrome中,相比较改变样式后立即检查样式信息,等待全部样式修改完毕后统一检查,在restyle测试中会快2.5倍,relayout测试中快4.42倍;
- Firefox中,restyle测试快1.87倍,relayout测试快4.64倍;
- IE6和IE8,不要在意这些细节(呵呵)
在所有浏览器(IE系列不在“所有”的范畴)的测试结果显示,只修改样式的时间花销仅仅是同时改变样式和触发layout的一半(我本该对比只改变样式和只改变layout的时间的,但是我没有,不用谢)。顺便提一下IE6,它的layout时间花销是只改变样式的4倍。(呵呵)