一篇文章带你看懂浏览器的重排和重绘

114 阅读8分钟

最近在翻找文章的时候看到一篇非常不错的介绍浏览器重排重绘优化的文章Rendering: repaint, reflow/relayout, restyle / Stoyan's phpied.com 因此就有想对这一篇文章进行翻译的欲望,并将他记录在这里。

一:引言

在标题中有五个非常不错以R开头的单词,不是嘛?接下来让我们开始讨论渲染这一件事情吧。它是在Life of Page 2.0 生命周期中的一个阶段,在下载组件的瀑布之后,有时是在下载组件期间。

接下来我们探索下浏览器是如何在给定一大块 HTML、CSS 和可能的 JavaScript之后在屏幕上显示你的页面,

二:主体

2.1 渲染过程

不同的浏览器工作方式不同,但下图给出了一个大致的概念,即一旦浏览器下载了您的页面代码,它们或多或少一致地会发生什么。

image.png

  • 浏览器解析出 HTML 源代码(tag soup)并构建一个 DOM 树 - 一种数据表示,其中每个 HTML 标签在树中都有一个对应的节点,标签之间的文本块也得到一个文本节点表示。 DOM 树中的根节点是 documentElement( 标签)
  • 浏览器会去解析css源代码,同时他会对里面可能存在的一些hacks进行过滤,并且还会一些它不认识的拓展如(-moz, -webkit)等给忽略掉。样式信息是多样层叠的,首先基础规则来自于用户代理侧的样式表(通常是浏览器自带的那一些),同时也会有一些用户样式表,(来自于外部,import,内敛)页面样式表,以及在html中使用style标签包裹住的一些样式。
  • 然后是有趣的部分——构建渲染树。渲染树有点像 DOM 树,但并不完全匹配。渲染树知道样式,因此如果您使用 display: none 隐藏 div,它将不会在渲染树中表示。其他不可见元素也是如此,例如head和其中的所有内容。另一方面,在渲染树中可能有 DOM 元素用多个节点表示 - 例如文本节点,其中 <p> 中的每一行都需要一个渲染节点。渲染树中的节点称为frame或box(就像是在css的box一样,更具盒子模型理念)。这些节点中的每一个都具有 CSS box属性 - 宽度、高度、边框、边距等
  • 一旦构建了渲染树,浏览器就可以在屏幕上绘制(绘制)渲染树节点

2.2 重排和重绘

我们通常在绘制前就有一些初始化的页面框架。当我们修改一些要被使用去建设这一个渲染树的参数的时候,至少会有以下两种情况之一发生

  1. 渲染树的一部分(或整个树)将需要重新验证并重新计算节点位置。这称为回流、layout, 或者 layouting.。 (或我编造的“relayout”,所以我在标题中有更多的“R”,对不起,我的错)。请注意,至少有一次重排——页面的初始布局
  2. 屏幕的某些部分需要更新,要么是因为节点的几何属性发生变化,要么是因为样式变化,例如改变背景颜色。此屏幕更新称为重绘重绘和重排可能会很昂贵,同时它们会影像用户体验,并使 UI 绘制显得迟钝。

2.3 是什么导致了重排和重绘的

任何改变用于构建渲染树的输入信息的东西都可能导致重绘或回流,例如:

  • 添加、删除、更新 DOM 节点
  • 使用display: none(会导致重排和重绘)或者visibility: hidden(只会导致重绘,因为没有几何位置的改变)来隐藏dom节点
  • 在页面上移动、动画化 DOM 节点
  • 添加样式表,调整样式属性
  • 用户操作,例如调整窗口大小、更改字体大小或滚动 让我们看一个简单的例子
var bstyle = document.body.style; // cache
 
bstyle.padding = "20px"; // reflow, repaint
bstyle.border = "10px solid red"; // another reflow and a repaint
 
bstyle.color = "blue"; // repaint only, no dimensions changed
bstyle.backgroundColor = "#fad"; // repaint
 
bstyle.fontSize = "2em"; // reflow, repaint
 
// new DOM element - reflow, repaint
document.body.appendChild(document.createTextNode('dude!'));

有一些重排花销可能会比其他的更加昂贵,例如:你直接摆弄body的直接后代,它可能不会影响太多的元素(和他并列的元素不多)。但假如是当您在页面顶部设置动画并展开一个 div,然后将页面的其余部分向下推时,该怎么办 - 这听起来很昂贵。

2.4 DOM操作缓存队列 - 聪明的浏览器

由于与渲染树更改相关的重排和重绘成本很高,但是浏览器又想要减少负面影响。一种策略是干脆不做这项工作。或者至少现在不做。浏览器会去设置一个DOM操作缓冲队列并且分批执行它们。这样,将几个需要重排的操作合并成一个,那就最后只需要计算一个回流。浏览器不断给这一个队列加缓冲操作,然后在经过一定时间或达到一定数量的更改后刷新执行队列。

但有时一些脚本可能会阻止浏览器优化重排,并导致它刷新队列并执行所有批量更改。当您请求dom的样式信息时会发生这种情况,例如

  1. offsetTopoffsetLeftoffsetWidthoffsetHeight
  2. scrollTop/Left/Width/Height
  3. clientTop/Left/Width/Height
  4. getComputedStyle(), or currentStyle in IE

以上所有这些本质上都是请求dom节点的样式信息,并且无论何时执行此操作,浏览器都必须为您提供最新的值。为此,它需要应用所有计划的更改、刷新队列并进行重排计算操作。 注意是当缓冲队列中有操作的时候再调用这一些指令才会导致重排操作,假如缓冲队列为空,调用这一些指令的时候不会导致重排操作

例如,快速连续(循环)设置和获取样式是一个坏主意,例如:

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

2.4 如何尽可能减少重排和重绘操作

减少重排/重绘对用户体验带来的负面影响的策略是尽可能减少重排和重绘以及对样式信息的请求,以便浏览器可以优化重排。该怎么做?

  • 不要一一改变dom元素样式。最好的方法是更改类名而不是样式。但这假设是静态样式。如果样式是动态的,请编辑 cssText 属性,而不是为每一个微小的变化都去修改元素及其样式属性。
// bad
var left = 10,
    top = 10;
el.style.left = left + "px";
el.style.top  = top  + "px";
 
// better 
el.className += " theclassname";
 
// or when top and left are calculated dynamically...
 
// better
el.style.cssText += "; left: " + left + "px; top: " + top + "px;";
  • 批量 DOM 更改并“离线”执行它们。离线意味着不在活动的 DOM 树中。你可以:
    • 使用 documentFragment 保存临时更改,
    • 克隆你要更新的节点,处理副本,然后用更新的克隆交换原始节点
    • 使用 display: none 来隐藏dom元素(1 次重排,重绘),添加 100 处更改,恢复显示display: block(1次重排,重绘)。这样你就可以用 2 次回流换取 100 次的修改了
  • 不要多次请求读写dom的style信息,可以批量读dom的style信息然后保存下来,并且之后使用这一块备份
// 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";
}
  • 尽可能避免元素重排重绘影响其他更多的节点。例如,使用绝对定位使该元素成为渲染树中主体的子元素,因此当您为其设置动画时,它不会影响太多其他节点。当您将元素放在它们上面时,其他一些节点可能位于需要重新绘制的区域,但它们不需要重绘。

2.5 对重排和重绘花销的实际测量

上面我们讲了这么多的一个优化手段和测量标准。 接下来我们实战演练以下看使用读写分离减少重排重绘的方法看能提升多少的时间占比。

下面的代码中 touch 函数是没有进行读写分离的,touchlast 函数是进行了读写分离,这两个函数会在运行的时候交替执行。然后我们使用 性能工具 来对这两个的花销进行具体批判。

<html>
<head></head>
<body>
<script>

var bodystyle = document.body.style;
var computed;
if (document.body.currentStyle) {
  computed = document.body.currentStyle;
} else {
  computed = document.defaultView.getComputedStyle(document.body, '');
}


function touch(){
  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;
}

function touchlast() {
  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;

}

var a = false;
document.body.onclick = function() {
  if (!a) {
    touch();
  } else {
    touchlast();
  }
  a = !a;

}

</script>

dude

</body>
</html>

当使用读写不分离形式的时候,会有三次的这一个重新计算样式的花销在,且执行时间在1.03ms. image.png

当使用读写分离形式的时候,会有一次的这一个重新计算样式的花销在,且执行时间在0.61ms. image.png

从执行时间来看使用读写分离之后速度有了近 40% 左右的提升。 可以说这样优化之后的效果是非常巨大的勒。

好了这一篇文章就主要介绍到这里啦,非常感谢大家能够有耐心看到现在,也希望大家能够从我这里获得一些对于重排和重绘的启发吧~