reflow

135 阅读5分钟

搜到的网页中感觉比较简洁清楚的是frendyguo.me/excessive-r… 我这篇文章大部分内容可能就是把它翻译一下,但中间也会穿插一些我的理解。

浏览器的渲染步骤

image.png

Web browser took a number of steps also known as CRP (Critical Rendering Path) in order to paint elements on your screen. Essentially there are four main steps: Parsing, Render Tree, Layout, and Paint.

这段话的要点是说:浏览器是通过被称为CRP (Critical Rendering Path)来把网页绘制到屏幕上的,而CRP基本上是4个步骤。Parsing, Render Tree, Layout, and Paint。对照上面的图也就是:

  1. 把html parse成dom树,把css parse成cssom树,
  2. 把两者叠加为render tree;
  3. 计算元素的位置和dimension(例如宽度)
  4. 将元素转化为实际的像素,绘制到屏幕上。

例子:

假设有下面的代码

<html>
  <head>
    <link rel="stylesheet" href="./styles.css">
  </head>
  <body>
    <p>
      Hello <span>World</span>
    </p>
    <div>How are you?</div>
  </body>
</html>
p {
  font-size: 12px;
  font-weight: 400;
}

span {
  color: red;
}

div {
  color: red;
}
  1. 浏览器拿到文件时,首先会进行parse。 html会被解析为类似于下面这样的结构。在解析过程中,如果结点有外部资源的link,那么当link中的资源被解析后parse才会继续执行。 image.png

而css则是下面这样的结构:

image.png 2. 当上面的DOM和 CSSOM 都构建完成后,它们会被组合在一起,形成render tree。display: none;的元素会被忽略。不过

image.png 3. layout:

在这个阶段,浏览器会遍历render tree,然后进行计算以获取每个元素的position和dimension信息(dimension应该指的是例如宽度这样的概念吧)。结点数据越多,这个过程花费的时间就越长。

  1. paint 这个步骤就是把每个像素渲染到屏幕上。

其实reflow对应的就是layout阶段,只不过第一次进行这个阶段时它被称为layout,后续它就会被称为reflow。

同样,repaint也是对应paint,第一次被称为paint,之后被称为repaint

reflow

它主要要做的就是:

a web browser recalculates the position and dimension of elements

上面也提过,其实就是Critical Rendering Path中的layout阶段。

reflow的性能消耗比较高,但是它却很容易被触发。当触发时,不仅会影响触发元素本身,还会影响它的children、ancestors、以及它后面的dom。

它的触发情况基本可以被归为以下两大类:

  1. dom出现了会影响layout的调整。例如:调整了div的宽度,插入了一个新元素。一个元素本来是display:none的,却改为了block。
  2. 在mutation发生过后获取measurement。 例如:offsetHeight。浏览器其实会把每个结点的信息缓存起来,但如果页面已发生了改变,那么之前的缓存就失效了,浏览器需要重新计算。

根据这篇文章, 这里可以补充一个信息是: reflow比较昂贵,所以当有reflow的需求时,浏览器可能不会马上执行,而是会放到内部的等待队列中,而是到下一个frame执行。(window.requestAnimationFrame())所以,当获取元素的物理属性,例如width时,有可能等待队列还有有样式修改在等着,所以为了保证获取的物理属性是对的,浏览器会强制执行一次reflow。

用Chrome DevTools查看reflow次数

Chrome DevTools中的performance可以让我们看到layout以及paint的次数等信息。

以下面代码为例:

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <style>
        div {
            height: 100px;
            width: 100px;
            background-color: pink;
        }
    </style>
</head>

<body>
    <html>


    <body>
        <div id="box1"></div>
        <div id="box2"></div>
        <div id="box3"></div>
        <div id="box4"></div>
        <div id="box5"></div>
        <div id="box6"></div>
    </body>

    </html>
    <script>
        var box1Height = document.getElementById('box1').clientHeight; // 第一次layout forced 
        document.getElementById('box1').style.height = box1Height + 10 + 'px';

        var box2Height = document.getElementById('box2').clientHeight; // 第二次layout forced  

        document.getElementById('box2').style.height = box2Height + 10 + 'px';

        var box3Height = document.getElementById('box3').clientHeight; // 
        document.getElementById('box3').style.height = box3Height + 10 + 'px';

        var box4Height = document.getElementById('box4').clientHeight;  // 
        document.getElementById('box4').style.height = box4Height + 10 + 'px';

        var box5Height = document.getElementById('box5').clientHeight; // 
        document.getElementById('box5').style.height = box5Height + 10 + 'px';

        var box6Height = document.getElementById('box6').clientHeight; // 
        document.getElementById('box6').style.height = box6Height + 10 + 'px';  
    </script> 
</body>
</html>

这个例子是从这篇文章拿来的,但是原文章里一笔带过了,所以我自己尝试分析一下,不保证争取。

原文章里说以上代码会执行6次layout,但我自己尝试用录制performance, 发现似乎执行了7次。

image.png

我在上面把7次layout行数后面都标记了//。 根据开发者工具的提示信息和我自己根据现有知识的分析,它可能是这样的思路。

<script>
    //这行因为要获取元素物理信息,所以force layout,以确保获取的是最新的信息
    var box1Height = document.getElementById('box1').clientHeight;//1
    // 这行似乎不会马上执行layout,而是会先进入到队列中
    document.getElementById('box1').style.height = box1Height + 10 + 'px';
    // 这行跟第一行一样,执行了force layout,这使得第二行和第三行会被合并执行一次layout
    var box2Height = document.getElementById('box2').clientHeight;//2

    document.getElementById('box2').style.height = box2Height + 10 + 'px';

    var box3Height = document.getElementById('box3').clientHeight; // 3
    document.getElementById('box3').style.height = box3Height + 10 + 'px';

    var box4Height = document.getElementById('box4').clientHeight; // 4
    document.getElementById('box4').style.height = box4Height + 10 + 'px';

    var box5Height = document.getElementById('box5').clientHeight; // 5
    document.getElementById('box5').style.height = box5Height + 10 + 'px';

    var box6Height = document.getElementById('box6').clientHeight; // 6
    // 这行因为是最后一行了,下面也没有其他要执行的,所以它自己直接执行一次layout。
    document.getElementById('box6').style.height = box6Height + 10 + 'px';// 7
</script>

接着我又试了下,把以上脚本改为click触发,再录制一次performance试试看。此时似乎变成了6次,上面的第一次不见了。所以是否第一行的获取宽度是可以和第一次渲染一起执行的? 但是感觉这样不太符合逻辑..这个只能先存疑了,应该是我哪里理解得不对。

换成下面这部分代码,以及用click处理后,layout变成了只有一次

var box1Height = document.getElementById('box1').clientHeight;
var box2Height = document.getElementById('box2').clientHeight;
var box3Height = document.getElementById('box3').clientHeight;
var box4Height = document.getElementById('box4').clientHeight;
var box5Height = document.getElementById('box5').clientHeight;
var box6Height = document.getElementById('box6').clientHeight;

document.getElementById('box1').style.height = box1Height + 10 + 'px'; // 我就是被layout提到的第80行。
document.getElementById('box2').style.height = box2Height + 10 + 'px';
document.getElementById('box3').style.height = box3Height + 10 + 'px';
document.getElementById('box4').style.height = box4Height + 10 + 'px';
document.getElementById('box5').style.height = box5Height + 10 + 'px';
document.getElementById('box6').style.height = box6Height + 10 + 'px';

image.png

这里layout信息中提到了第80行,我在上面代码中注释出来了。我猜测这里应该指的是第80行触发了layout,也就是说前面的获取高度可能都会被储存在队列中,毕竟假设我们只获取宽度什么都不做的话,那么浏览器会觉得“我也不要辛辛苦苦去layout获取一个你并不会使用的高度”。然后直到下面一段,浏览器发觉高度还是需要的,采取执行。但我好奇的是:后面的设高度的几行为什么只需要执行一次layout,是浏览器具有这种智能合并的行为吗?

一个例子

dom manipulation

<html>
  <head>
    <script type="text/javascript">
      function addElement() {
        const el = document.createElement('div');
        el.innerHTML = 'Appended!'; 
        const hello = document.querySelector('#hello');
        document.body.insertBefore(el, hello); // Insert before the div will cause layout shift
      }
    </script>
  </head>
  <body>
    <button onclick="addElement()">Add an element</button>
    <div id="hello">Hello World</div>
  </body>
</html>

上面这段代码,每点击一次按钮,会触发一次reflow。如果用DocumentFragment,则会大大减少reflow的次数。

<html>
  <body>
    <div id="hello">Hello World</div>
    <script type="text/javascript">
      setTimeout(function() {
        var fragment = new DocumentFragment();
        for (var i=0; i<20; i++) {
          const el = document.createElement('div');
          el.innerHTML = 'Appended!';
          fragment.appendChild(el);
        }
        document.body.prepend(fragment); // 只有这行会触发一次reflow
      }, 1000);
    </script>
  </body>
</html>