翻译计划-重绘重排重渲染

发表于:November 5, 2016 at 10:39 AM

让我们聊聊渲染,一个发生在 Page2.0(注:应该是作者自创概念)的生命周期之后的解析,有时发生在瀑布流加载组件的时候。所以浏览器是怎样通过很多 HTML,CSS 和 Javascript 脚本来在屏幕上展示你的页面呢?

原文链接:https://www.phpied.com/rendering-repaint-reflowrelayout-restyle/ 译者:Icarus 邮箱:xdlrt0111@163.com

渲染过程

不同的浏览器工作方式是不一样的,下面的图表提供了渲染时的共同实现,一旦他们已经下载好了你页面的代码,多半都会通过浏览器这样实现。

Rendering process in the browser

浏览器把 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)的矩形显示区域。

搞清楚怎样呈现和呈现什么需要递归的遍历渲染树。

重绘和重排

无论何时总会有一个初始化的页面布局伴随着一次绘制。(除非你希望你的页面是空白的:))之后,每一次改变用于构建渲染树的信息都会导致以下至少一个的行为:

  1. 部分渲染树(或者整个渲染树)需要重新分析并且节点尺寸需要重新计算。这被称为重排。注意这里至少会有一次重排-初始化页面布局。

  2. 由于节点的几何属性发生改变或者由于样式发生改变,例如改变元素背景色时,屏幕上的部分内容需要更新。这样的更新被称为重绘

重排和重绘代价是高昂的,它们会破坏用户体验,并且让 UI 展示非常迟缓。

什么情况会触发重排和重绘?

任何改变用来构建渲染树的信息都会导致一次重排或重绘。

让我们来看一些例子:

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 添加动画或者改变它的大小,就会推动整个页面改变-听起来代价就十分高昂。

善于应对的浏览器

既然渲染树变化伴随的重排和重绘代价如此巨大,浏览器一直致力于减少这些消极的影响。一个策略就是干脆不做,或者说至少现在不做。浏览器会基于你的脚本要求创建一个变化的队列,然后分批去展现。通过这种方式许多需要一次重排的变化就会整合起来,最终只有一次重排会被计算渲染。浏览器可以向已有的队列中添加变更并在一个特定的时间或达到一个特定数量的变更后执行。

但是有时你的代码会组织浏览器优化重排并立即刷新队列,与此同时展示所有批次的变化。这通常发生在你请求样式信息的时候,例如:

  1. offsetTop, offsetLeft, offsetWidth, offsetHeight

  2. scrollTop/Left/Width/Height

  3. clientTop/Left/Width/Height

  4. getComputedStyle(), or currentStyle in IE

以上就是一个节点基本的可请求信息。无论合适你发出请求,浏览器不得不提供给你最新的值。为了实现这一目的,浏览器需要应用所有队列中的变更,刷新队列然后去实现重排。

举个例子,同时去 set 和 get 样式是很糟糕的想法:

// no-no!
el.style.left = el.offsetLeft + 10 + "px";

最小化重排和重绘

通过减少重排/重绘的负面影响来提高用户体验的最简单方式就是尽可能少的去使用他们同时尽可能少的请求样式信息。这样浏览器就可以优化重排。如何实践呢?

    // 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";
}
````

工具

就在一年前,还没有什么浏览器工具能够展示绘制和渲染的过程。但是现在已经有了很多非常酷的工具。

第一个是 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 看到的可视化结果。

DynaTrace

页面加载完之后,我点击一次去执行第一个方案(修改完后立即请求样式信息,在大概两秒处),然后再次点击去执行第二方案(样式变更完成后再请求样式信息,在大概四秒处)。

这个工具为我们展示了页面是怎样加载的和 IE 的 logo 正处于加载状态。然后鼠标光标放置于渲染事件部分。放大后能看到更详细的信息:

dynatrace

你可以非常清楚的看到蓝色条,代表 JavaScript 事件,还有下面的绿色条,代表渲染事件。现在,这是个很简单的例子,但是仍然需要注意那些条的长度 - 渲染比执行 JavaScript 消耗多了多长时间。在 Ajax/富应用中,JavaScript 不是瓶颈,DOM 的访问、操作和渲染才是。

好了,现在我们来运行一下”relayout 测试”,它改变了 body 的几何形状。这次我们在“PurePaths”视图中检查。这是一条可以查看每项元素的信息的时间线。我已经高亮了第一次点击,它作为 JavaScript 事件提供了一个定时的布局任务。

dynatrace

再一次的放大这个有趣的部分,你可以看到除了“绘制”条以外,还有一个新的“计算流布局”,这是因为在这个测试中,我们除了重绘同样触发了重排。

dynatrace

现在让我们在 Chrome 中测试同一个页面,然后看看 SpeedTracer 的结果。

这是第一个“restyle”测试放大到了有趣的部分,看看发生了什么。

speedtracer

总体来看就是一次点击和一次绘制。但是在第一次点击的时候,同样有一半的时间消耗在重新计算样式。为什么会这样呢?这是因为我们在每次变更时就请求一次样式信息。

展开事件会出现隐藏的线(灰色的线由于很快会被 Speedtracer 隐藏),我们能够看到发生了什么 - 在第一次点击后,样式被计算了三次。而第二种方案只计算了一次。

speedtracer

现在让我们运行“relayout 测试”。整个列表看起来是一样的:

speedtracer

但是详细的视图展示了第一次点击后导致了三次重排而第二次点击只导致了一次重排。这很清楚的展示了发生了什么。

speedtracer

这两个工具有一些微小的不同 - SpeedTracer 不会展示布局任务何时被执行和放入队列,而 DynaTrace 会。DynaTrace 不会区分restylereflow/repaint的区别,而 SpeedTracer 会。可能 IE 对这两者并没有做区分吧。DynaTrace 在两次测试中并不会因为重排次数不同而显示三次重排或者一次,可能 IE 工作原理本就如此?

即使运行上述测试几百次,IE 浏览器仍然不关心你在改变样式后是否请求样式信息。

多次运行这些测试后,以下是得出的结论:

在所有浏览器中改变样式仅仅需要改变样式和布局的一半时间。(虽然我这么说,但实际上我仅仅比较了改变样式和改变布局。)除了在 IE6 中改变布局会以改变样式四倍的代价来消耗资源。

小结

非常感谢你看完了这篇很长的文章。希望大家注意到重排这个特性。作为总结,我来重新解释一下术语。

如果你想更深层次的去了解这些,可以参考以下资料: