[转]重绘与重排(回流)

前言

早在五年前,Google 就提出了 1s 完成终端页面的首屏渲染的标准。

常见的优化网络请求的方法有:DNS Lookup,减少重定向,避免 JS、CSS 阻塞,并行请求,代码压缩,缓存,按需加载,前端模块化…

虽然相较于网络方面的优化,前端渲染的优化显得杯水车薪,而且随着浏览器和硬件性能的增长,再加上主流前端框架(react、vue、angular)的已经帮我们解决了大多数的性能问题,但是前端渲染性能优化依然值得学习,除去网络方面的消耗,留给前端渲染的时间已经不多了。本文主要学习前端渲染相关的问题。

浏览器是如何渲染一个页面的

  1. 浏览器把获取到的 HTML 代码解析成1个 DOM 树,HTML 中的每个 tag 都是 DOM 树中的1个节点,根节点是 document 对象。DOM 树里包含了所有 HTML 标签,包括 display:none 隐藏的标签,还有用 JS 动态添加的元素等。
  2. 浏览器把所有样式解析成样式结构体,在解析的过程中会去掉浏览器不能识别的样式,比如 IE 会去掉 -moz 开头的样式。
  3. DOM Tree 和样式结构体组合后构建 render tree, render tree 类似于 DOM tree,但区别很大,render tree 能识别样式,render tree 中每个 NODE 都有自己的 style,而且 render tree 不包含隐藏的节点 (比如 display:none 的节点,还有 head 节点),因为这些节点不会用于呈现,而且不会影响呈现的节点,所以就不会包含到 render tree 中。注意 visibility:hidden 隐藏的元素还是会包含到 render tree 中的,因为 visibility:hidden 会影响布局(layout),会占有空间。根据css2的标准,render tree 中的每个节点都称为 box(Box dimensions),box所有属性:width, height, margin, padding, left, top, border等。

    注意结果就是渲染树是和DOM树是相对应的,但是不是一一对应的,因为非可视化的DOM元素不会插入到渲染树中,例如head元素;而如果元素的display属性的值是none的话,也不会出现在渲染树中。

  4. 一旦 render tree 构建完毕后,浏览器就可以根据 render tree 来绘制页面了。

在此过程中,前端工程师主要的敌人为:

  1. 重新计算样式(Recalculate Style)、计算布局(Layout)=> Rendering/Reflow。
  2. 绘制 => Painting/Repaint。

重绘与回流

在讨论回流与重绘之前,我们要知道:

  1. 浏览器使用流式布局模型 (Flow Based Layout)。
  2. 浏览器会把HTML解析成DOM,把CSS解析成CSSOM,DOM和CSSOM合并就产生了Render Tree。
  3. 有了RenderTree,我们就知道了所有节点的样式,然后计算他们在页面上的大小和位置,最后把节点绘制到页面上。
  4. 由于浏览器使用流式布局,对Render Tree的计算通常只需要遍历一次就可以完成,但table及其内部元素除外,他们可能需要多次计算,通常要花3倍于同等元素的时间,这也是为什么要避免使用table布局的原因之一。
  1. 回流(Reflow)是指布局引擎为frame计算图形的过程, frame是一个矩形,拥有宽高和相对父容器的偏移。frame用来显示盒模型(content model), 但一个content model可能会显示为多个frame,比如换行的文本每行都会显示为一个frame。
    当Render Tree中部分或全部元素的尺寸、结构、或某些属性发生改变时,浏览器重新渲染部分或全部文档的过程就是回流。页面第一次加载的时候,至少发生一次回流。
  2. 当 render tree 中的一些元素需要更新属性,而这些属性只是影响元素的外观,风格,而不会影响布局的,比如背景色、前景色等,这个过程叫做重绘(repaint)

在回流的时候,浏览器会使 render tree 中受到影响的部分失效,并重新构造这部分渲染树,完成回流后,浏览器会重新绘制受影响的部分到屏幕中,该过程成为重绘。因此回流必将引起重绘,而重绘不一定会引起回流。

Reflow 的成本比 Repaint 高得多的多。回流的花销跟render tree有多少节点需要重新构建有关系,假设你直接操作body,比如在body最前面插入1个元素,会导致整个render tree回流,这样代价当然会比较高,但如果是指body后面插入1个元素,则不会影响前面元素的回流。

一句话:回流必将引起重绘,重绘不一定会引起回流。

重绘何时发生

当一个元素的外观的可见性 visibility 发生改变的时候,但是不影响布局。类似的例子包括:outline, visibility, background color。

回流何时发生

  1. 页面渲染初始化。
  2. 调整窗口大小。
  3. 改变字体,比如修改网页默认字体。
  4. 增加或者移除样式表。
  5. 内容变化,比如文本改变或者图片大小改变而引起的计算值宽度和高度改变。
  6. 激活 CSS 伪类,比如 :hover
  7. 操作 class 属性。
  8. 脚本操作 DOM,增加删除或者修改 DOM 节点,元素尺寸改变——边距、填充、边框、宽度和高度。
  9. 计算 offsetWidth 和 offsetHeight 属性。
  10. 设置 style 属性的值。
1
2
3
4
5
6
7
var s = document.body.style
s.padding = "2px"; // 回流+重绘
s.border = "1px solid red"; // 回流+重绘
s.color = "blue"; // 重绘
s.backgroundColor = "#ccc"; // 重绘
s.fontSize = "14px"; // 再一次 回流+重绘
document.body.appendChild(document.createTextNode('abc!')); // 回流+重绘

聪明的浏览器

如果向上述代码中那样,浏览器不停地回流+重绘,很可能性能开销非常大,实际上浏览器会优化这些操作,将所有引起回流和重绘的操作放入一个队列中,等待队列达到一定的数量或者时间间隔,就 flush 这个队列,一次性处理所有的回流和重绘。

虽然有浏览器优化,但是当我们向浏览器请求一些 style 信息的时候,浏览器为了确保我们能拿到精确的值,就会提前 flush 队列,有可能会引发回流。

  1. offsetTop/Left/Width/Height
  2. scrollTop/Left/Width/Height
  3. clientTop/Left/Width/Height
  4. width,height
  5. getComputedStyle(), 或者 IE的 currentStyle

如何减少回流、重绘

  1. JavaScript 修改 class 的时候,就尽可能使用使用 classList 代替 className ,因为 className 只要赋值,就一定出现一次 rendering 计算;classList 的 add 和 remove,浏览器会进行样式名是否存在的判断,以减少重复的 rendering。
    尽可能在DOM树的里面改变class,可以限制了回流的范围,使其影响尽可能少的节点。例如,你应该避免通过改变对包装元素类去影响子节点的显示。

    1
    2
    3
    ele.className += 'something'
    ele.classList.add('something')
    ele.classList.remove('something')
  2. 避免使用table布局。

  3. 保持 DOM 操作“原子性”

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    // bad
    // 下面这个读取操作会导致连续的清空和放入操作到 重绘+回流队列
    var newWidth = ele.offsetWidth + 10;
    ele.style.width = newWidth + 'px';

    var newHeight = ele.offsetHeight + 10;
    ele.style.height = newHeight + 'px';

    // good 读写分离,批量操作
    var newWidth = ele.offsetWidth + 10; // read
    var newHeight = ele.offsetHeight + 10; // read
    ele.style.width = newWidth + 'px'; // write
    ele.style.height = newHeight + 'px'; // write
  4. 如果动态改变style,则使用cssText。

    1
    2
    3
    4
    5
    6
    7
    //不好的写法
    var left = 1;
    var top = 1;
    el.style.left = left + "px";
    el.style.right = right + "px";
    //动态改变样式,比较好的写法
    el.style.cssText += ";left:" + left + "px; top:" + top + "px;";
  5. 不要经常访问会引起浏览器flush队列的属性,如果你确实要访问,利用缓存

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    //不好的写法
    for(循环) {
    el.style.left = el.offsetLeft + 5 + "px";
    el.style.top = el.offsetTop + 5 + "px";
    }
    // 比较好的写法
    var left = el.offsetLeft,
    top = el.offsetTop,
    s = el.style;
    for(循环) {
    left += 10;
    top += 10;
    s.left = left + "px";
    s.top = top + "px";
    }
  6. 对具有复杂动画的元素使用绝对定位,使它脱离文档流,否则会引起父元素及后续元素频繁回流
    动画效果应用到 position 属性为 absolute 或 fixed 的元素上,它们不影响其他元素的布局,所它他们只会导致重新绘制,而不是一个完整回流。这样消耗会更低。

    使用CSS3的transition也可以获得不错的性能。

假设block1是position: absolute的元素,block2是position: relative的元素。当使用jquery的animate()方法移动元素来展示一些动画效果时$("#block1").animate({left:50});block1 移动,会影响到它父元素下的所有子元素。因为在移动过程中,所有子元素需要判断 block1 的z-index是否在自己的上面,如果是在自己的上面,则需要重绘,这里不会引起回流。

$("#block2").animate({marginLeft:50}); , block2 是相对定位的元素,影响的元素与 block1 一样,但是因为 block2 非绝对定位,而且改变的是 marginLeft 属性,所以这里每次改变不但会重绘,还会引起父元素及其下元素的回流。

​ 所以,动画效果应用到position属性为absolute或fixed的元素上,它们不影响其他元素的布局,所它他们只会导致重新绘制,而不是一个完整回流。这样消耗会更低。

  1. 让要操作的元素进行“离线处理”,处理完后一起更新。这里所谓的”离线处理”即让元素不存在于render tree中

    • 使用DocumentFragment进行缓存操作,引发一次回流和重绘;
      • 这个主要用于添加元素的时候,就是先把所有要添加到元素添加到1个div中(这个div也是新加的),最后才把这个div append到body中。
    • 使用display:none技术,只引发两次回流和重绘;
      • 先display:none 隐藏元素,然后对该元素进行所有的操作,最后再显示该元素。因对display:none的元素进行操作不会引起回流、重绘。所以只要操作只会有2次回流。
    • 使用cloneNode(true or false) 和 replaceChild 技术,引发一次回流和重绘。

      假如需要在下面的 html 中添加两个 li 节点:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      <ul id="">
      </ul>


      <script>
      let ul = document.getElementByTagName('ul')
      let man = document.createElement('li')
      man.innerHTML = 'man'
      ul.appendChild(li)

      let woman = document.createElement('li')
      woman.innerHTML = 'woman'
      ul.appendChild(woman)
      </script>

      上述代码会发生两次回流,假如使用 display: none 的方案,虽然能够减少回流次数,但是会发生一次闪烁,这时候使用 DocumentFragment 的优势就体现出来了。

      DocumentFragment 有两大特点:

    • DocumentFragment 节点不属于文档树,继承的 parentNode 属性总是 null。

    • 当请求把一个 DocumentFragment 节点插入文档树时,插入的不是 DocumentFragment 自身,而是它的所有子孙节点。这使得 DocumentFragment 成了有用的占位符,暂时存放那些一次插入文档的节点。它还有利于实现文档的剪切、复制和粘贴操作。、
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      let fragment = document.createDocumentFragment()

      let man = document.createElement('li')
      let woman = document.createElement('li')
      man.innerHTML = 'man'
      woman.innerHTML = 'woman'
      fragment.appendChild(man)
      fragment.appendChild(woman)

      document.body.appendChild(spanNode)
  2. 避免使用CSS表达式(例如:calc())

这项规则较过时,但确实是个好的主意。主要的原因,这些表现是如此昂贵,是因为他们每次重新计算文档,或部分文档、回流。正如我们从所有的很多事情看到的:引发回流,它可以每秒产生成千上万次。当心!

参考

Web性能优化-页面重绘和回流
网页性能管理详解
了解DocumentFragment 给我们带来的性能优化
高性能WEB开发(8) - 页面呈现、重绘、回流