让我们聊聊渲染,一个发生在 Page2.0(注:应该是作者自创概念)的生命周期之后的解析,有时发生在瀑布流加载组件的时候。所以浏览器是怎样通过很多 HTML,CSS 和 Javascript 脚本来在屏幕上展示你的页面呢?
原文链接:https://www.phpied.com/rendering-repaint-reflowrelayout-restyle/ 译者:Icarus 邮箱:xdlrt0111@163.com
渲染过程
不同的浏览器工作方式是不一样的,下面的图表提供了渲染时的共同实现,一旦他们已经下载好了你页面的代码,多半都会通过浏览器这样实现。
浏览器把 HTML 源代码解析,并且创建一个DOM 树(DOM tree)-每个 HTML 标签在这个树上都有一个对应的节点,标签中的文本也有一个相应的文本节点。DOM 树上的根节点是documentElement
(也就是<html></html>
)
浏览器解析 CSS 代码,通过一堆 hack 使得它变得有意义,诸如-moz
,-webkit
和其它不能理解的拓展名,浏览器会大胆的把他们忽略。CSS 级联的优先级从低到高是:浏览器默认的基本规则,用户引用的样式表,优先级最高的的是外部引入的并且最终被加入到 HTML 标签的 style 属性中的行内样式。
接下来就是有趣的部分-创建一个渲染树(render tree)。渲染树类似 DOM 树,但是并不是一一对应的。渲染树基于样式,所以如果你使用display:none
属性来隐藏一个div
,那么它不会被呈现在渲染树中。其它像head
标签和所有在其中不可见的元素也一样。另一方面,DOM 元素可能在渲染树中存在多个节点,比如说每行在<p></p>
中的文本都需要一个渲染节点。渲染树上的节点被称为一个frame
或者一个box
(跟 CSS box 类似,相关内容可查阅the box model)。每一个节点都有 CSS box 的属性-宽度、高度、边框、外边距等等。
一旦渲染树被创建成功,浏览器就可以在屏幕上绘制渲染树节点。
森林和树
让我们举个例子。
HTML 源码:
<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 树对每一个标签和标签间每一段文本都有一个对应的节点。(为了简便起见,我们暂且忽略空白符也是文本节点。)
documentElement (html)
head
title
body
p
[text node]
div
[text node]
div
img
...
渲染树就是 DOM 树中可见的部分。它缺少一些元素-比如head
和隐藏的div
,但是它对文本有额外的节点(又名 frame/box)。
root (RenderView)
body
p
line 1
line 2
line 3
...
div
img
...
渲染树的根节点是包含的所有其它元素的 frame/box。你可以把它理解为限制在浏览器页面范围内的窗口区域。技术上来说,WebKit 把根节点称为RenderView
,对应于 CSS 的初始化包含块(initial containing block),也就是从视窗范围内页面(0, 0)
到(window.innerWidth
, window.innerHeight
)的矩形显示区域。
搞清楚怎样呈现和呈现什么需要递归的遍历渲染树。
重绘和重排
无论何时总会有一个初始化的页面布局伴随着一次绘制。(除非你希望你的页面是空白的:))之后,每一次改变用于构建渲染树的信息都会导致以下至少一个的行为:
-
部分渲染树(或者整个渲染树)需要重新分析并且节点尺寸需要重新计算。这被称为重排。注意这里至少会有一次重排-初始化页面布局。
-
由于节点的几何属性发生改变或者由于样式发生改变,例如改变元素背景色时,屏幕上的部分内容需要更新。这样的更新被称为重绘。
重排和重绘代价是高昂的,它们会破坏用户体验,并且让 UI 展示非常迟缓。
什么情况会触发重排和重绘?
任何改变用来构建渲染树的信息都会导致一次重排或重绘。
-
添加、删除、更新 DOM 节点
-
通过
display: none
隐藏一个 DOM 节点-触发重排和重绘 -
通过
visibility: hidden
隐藏一个 DOM 节点-只触发重绘,因为没有几何变化 -
移动或者给页面中的 DOM 节点添加动画
-
添加一个样式表,调整样式属性
-
用户行为,例如调整窗口大小,改变字号,或者滚动。
让我们来看一些例子:
var bstyle = document.body.style; // cache
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 下的一个子节点,可能并不会对其它节点造成影响。但是当你给一个当前页面顶级的 div 添加动画或者改变它的大小,就会推动整个页面改变-听起来代价就十分高昂。
善于应对的浏览器
既然渲染树变化伴随的重排和重绘代价如此巨大,浏览器一直致力于减少这些消极的影响。一个策略就是干脆不做,或者说至少现在不做。浏览器会基于你的脚本要求创建一个变化的队列,然后分批去展现。通过这种方式许多需要一次重排的变化就会整合起来,最终只有一次重排会被计算渲染。浏览器可以向已有的队列中添加变更并在一个特定的时间或达到一个特定数量的变更后执行。
但是有时你的代码会组织浏览器优化重排并立即刷新队列,与此同时展示所有批次的变化。这通常发生在你请求样式信息的时候,例如:
-
offsetTop
,offsetLeft
,offsetWidth
,offsetHeight
-
scrollTop
/Left
/Width
/Height
-
clientTop
/Left
/Width
/Height
-
getComputedStyle()
, orcurrentStyle
in IE
以上就是一个节点基本的可请求信息。无论合适你发出请求,浏览器不得不提供给你最新的值。为了实现这一目的,浏览器需要应用所有队列中的变更,刷新队列然后去实现重排。
举个例子,同时去 set 和 get 样式是很糟糕的想法:
// no-no!
el.style.left = el.offsetLeft + 10 + "px";
最小化重排和重绘
通过减少重排/重绘的负面影响来提高用户体验的最简单方式就是尽可能少的去使用他们同时尽可能少的请求样式信息。这样浏览器就可以优化重排。如何实践呢?
- 不要逐个变样式。对于静态页面来说,明智且兼具可维护性的做法是改变类名而不是样式。对于动态改变的样式来说,相较每次微小修改都直接触及元素,更好的办法是统一在
cssText
变量中编辑。
// bad
var left = 10,
top = 10;
el.style.left = left + "px";
el.style.top = top + "px";
// better
el.className += " theclassname";
// 当top和left的值是动态计算而成时...
// better
el.style.cssText += "; left: " + left + "px; top: " + top + "px;";
````
* “离线”的批量改变和表现DOM。“离线”意味着不在当前的DOM树中做修改。你可以:
- 通过`documentFragment`来保留临时变动。
- 复制你即将更新的节点,在副本上工作,然后将之前的节点和新节点交换。
- 通过`display:none`属性隐藏元素(只有一次重排重绘),添加足够多的变更后,通过`display`属性显示(另一次重排重绘)。通过这种方式即使大量变更也只触发两次重排。
* 不要频繁计算样式。如果你有一个样式需要计算,只取一次,将它缓存在一个变量中并且在这个变量上工作。看一下下面这个反例:
// no-no!
for(big; loop; here) {
el.style.left = el.offsetLeft + 10 + "px";
el.style.top = el.offsetTop + 10 + "px";
}
// better
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";
}
````
- 通常情况下,考虑一下渲染树和变更后需要重新验证的消耗。举个例子,使用绝对定位会使得该元素单独成为渲染树中 body 的一个子元素,所以当你对其添加动画时,它不会对其它节点造成太多影响。当你在这些节点上放置这个元素时,一些其它在这个区域内的节点可能需要重绘,但是不需要重排。
工具
就在一年前,还没有什么浏览器工具能够展示绘制和渲染的过程。但是现在已经有了很多非常酷的工具。
第一个是 Firefox 中的MozAfterPaint,但它仅仅能够展现重绘的内容。
DynaTrace Ajax和最近 Google 发布的SpeedTracer是两个非常棒的深入了解重绘和重排的工具-前者是 IE 使用,后者是 WebKit。
去年的某个时候 Douglas Crockford 发现我们在 CSS 中做了一些连我们自己也不知道的蠢事情。我也深有体会。我之前陷入了一个难题,当在 IE6 中改变字体大小的时候会导致 CPU 占用飙升至 100%,并且在大概 10-15 分钟后才能重绘整个页面。
现在有了这些工具,我们再也没有借口在 CSS 中做这些愚蠢的事情。
除此以外,如果像 Firebug 这类工具除 DOM 树以外还展示了渲染树不是很酷吗?
最后一个例子
让我们来看看这些工具该并且演示一下 restyle(不影响几何形状的渲染树变化)、reflow(重排,影响布局)和 repaint(重绘)。
我们来比较一下两种方式。第一种,我在不改变布局的情况下改变一些样式,同时在每个变更之后,我们检查一下样式属性,这些变更之间完全没有联系。
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, '');
}
现在两个例子会在 document 被点击后触发。测试页在这里,我们称之为“restyle” - restyle.html。
第二个测试和第一个类似,但是这次我们还改变的布局信息:
// touch styles every time
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;
// touch at the end
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 测试”,源代码在这.
这是你在 restyle 测试中用 DynaTrace 看到的可视化结果。
页面加载完之后,我点击一次去执行第一个方案(修改完后立即请求样式信息,在大概两秒处),然后再次点击去执行第二方案(样式变更完成后再请求样式信息,在大概四秒处)。
这个工具为我们展示了页面是怎样加载的和 IE 的 logo 正处于加载状态。然后鼠标光标放置于渲染事件部分。放大后能看到更详细的信息:
你可以非常清楚的看到蓝色条,代表 JavaScript 事件,还有下面的绿色条,代表渲染事件。现在,这是个很简单的例子,但是仍然需要注意那些条的长度 - 渲染比执行 JavaScript 消耗多了多长时间。在 Ajax/富应用中,JavaScript 不是瓶颈,DOM 的访问、操作和渲染才是。
好了,现在我们来运行一下”relayout 测试”,它改变了 body 的几何形状。这次我们在“PurePaths”视图中检查。这是一条可以查看每项元素的信息的时间线。我已经高亮了第一次点击,它作为 JavaScript 事件提供了一个定时的布局任务。
再一次的放大这个有趣的部分,你可以看到除了“绘制”条以外,还有一个新的“计算流布局”,这是因为在这个测试中,我们除了重绘同样触发了重排。
现在让我们在 Chrome 中测试同一个页面,然后看看 SpeedTracer 的结果。
这是第一个“restyle”测试放大到了有趣的部分,看看发生了什么。
总体来看就是一次点击和一次绘制。但是在第一次点击的时候,同样有一半的时间消耗在重新计算样式。为什么会这样呢?这是因为我们在每次变更时就请求一次样式信息。
展开事件会出现隐藏的线(灰色的线由于很快会被 Speedtracer 隐藏),我们能够看到发生了什么 - 在第一次点击后,样式被计算了三次。而第二种方案只计算了一次。
现在让我们运行“relayout 测试”。整个列表看起来是一样的:
但是详细的视图展示了第一次点击后导致了三次重排而第二次点击只导致了一次重排。这很清楚的展示了发生了什么。
这两个工具有一些微小的不同 - SpeedTracer 不会展示布局任务何时被执行和放入队列,而 DynaTrace 会。DynaTrace 不会区分restyle
和reflow/repaint
的区别,而 SpeedTracer 会。可能 IE 对这两者并没有做区分吧。DynaTrace 在两次测试中并不会因为重排次数不同而显示三次重排或者一次,可能 IE 工作原理本就如此?
即使运行上述测试几百次,IE 浏览器仍然不关心你在改变样式后是否请求样式信息。
多次运行这些测试后,以下是得出的结论:
-
在 chrome 相较于改变样式后立即请求样式信息,等待样式修改完成后检查,在
restyle
测试中会快 2.5 倍,在relayout
测试中会快 4.42 倍。 -
在 Fifefox 中,restyle 测试快 1.87 倍,relayout 测试快 4.64 倍。
-
IE6 和 IE8 中,没有什么区别。
在所有浏览器中改变样式仅仅需要改变样式和布局的一半时间。(虽然我这么说,但实际上我仅仅比较了改变样式和改变布局。)除了在 IE6 中改变布局会以改变样式四倍的代价来消耗资源。
小结
非常感谢你看完了这篇很长的文章。希望大家注意到重排这个特性。作为总结,我来重新解释一下术语。
-
渲染树 - DOM 树的可见部分。
-
渲染树上的节点被称为
box
或ifame
-
重新计算渲染树被称为
reflow
(在 Firefox 中),在其它浏览器中被成为relayout
-
更新屏幕中的显示结果并且重新计算渲染树被成为
repaint
或者在 IE/DynaTrace 中被称为redraw
-
SpeedTracer 将没有几何变化的样式变动称为
样式重计算
,将有几何变动的称为layout
如果你想更深层次的去了解这些,可以参考以下资料:
-
Mozilla: notes on reflow
-
David Baron of Mozilla: Google tech talk on Layout Engine Internals for Web Developers
-
WebKit: rendering basics - 6-part series of posts
-
Opera: repaints and reflows is a part of an article on efficient JavaScript
-
Dynatrace: IE rendering behavior