DOM操作成本为什么高

Posted by huangqing on May 8, 2019

操作 DOM 具体的成本,说到底是造成浏览器回流 reflow 和重绘 reflow,从而消耗 GPU 资源。

什么是 DOM

Document Object Model 文档对象模型

什么是DOM?可能很多人第一反应就是div、p、span等html标签(至少我是),但要知道,DOM是Model,是Object Model,对象模型,是为 HTML(and XML)提供的 API。HTML(Hyper Text Markup Language)是一种标记语言,HTML 在 DOM 的模型标准中被视为对象,DOM 只提供编程接口,却无法实际操作 HTML 里面的内容。但在浏览器端,前端们可以用脚本语言(JavaScript)通过 DOM 去操作 HTML 内容。

Python 也可以访问 DOM。所以 DOM 不是提供给 Javascript 的 API,也不是 Javascript 里的 API。

实质上还存在CSSOM:CSS Object Model,浏览器将 CSS 代码解析成树形的数据结构,与 DOM 是两个独立的数据结构。

浏览器渲染过程

讨论DOM操作成本,肯定要先了解该成本的来源,那么就离不开浏览器渲染,这里只讨论浏览器拿到 HTML 之后开始解析、渲染。

  1. 解析 HTML,构建 DOM 树(这里遇到外链,此时会发起请求)
  2. 解析 CSS,生成 CSS 规则树
  3. 合并 DOM 树和 CSS 规则,生成 render 树
  4. 布局 render 树(Layout/reflow),负责各元素尺寸、位置的计算
  5. 绘制 render 树(paint),绘制页面像素信息
  6. 浏览器会将各层的信息发送给 GPU,GPU 将各层合成(composite),显示在屏幕上

1.构建 DOM 树

<html>
  <head>
    <meta name="viewport" content="width=device-width,initial-scale=1" />
    <link href="style.css" rel="stylesheet" />
    <title>Critical Path</title>
  </head>

  <body>
    <p>
      Hello <span> web performance</span>
      students!
    </p>
    <div><img src="awesome-photo.jpg" /></div>
  </body>
</html>

无论是 DOM 还是 CSSOM,都是要经过 Bytes→characters→tokens→nodes→objectmodel这个过程。

2.构建 CSSOM 树

上述也提到了 CSSOM 的构建过程,也是树的结构,在最终计算各个节点的样式时,浏览器都会先从该节点的普遍属性(比如 body 里设置的全局样式)开始,再去应用该节点的具体属性。还有要注意的是,每个浏览器都有自己默认的样式表,因此很多时候这棵 CSSOM 树只是对这张默认样式表的部分替换。

3.生成 render 树

DOM 树和 CSSOM 树合并生成 render 树

简单描述这个过程:

DOM 树从根节点开始遍历可见节点,这里之所以强调了“可见”,是因为如果遇到设置了类似 display: none;的不可见节点,在 render 过程中是会被跳过的(但 visibility: hidden; opacity: 0 这种仍旧占据空间的节点不会被跳过 render),保存各个节点的样式信息及其余节点的从属关系。

注意:渲染树只包含可见的节点。

4.Layout 布局

有了各个节点的样式信息和属性,但不知道各个节点的确切位置和大小,所以要通过布局将样式信息和属性转换为实际可视窗口的相对大小和位置。

5.Paint 绘制

万事俱备,最后只要将确定好位置大小的各节点,通过 GPU 渲染到屏幕的实际像素。

Tips

  • 在上述渲染过程中,前 3 点可能要多次执行,比如 js 脚本去操作 dom、更改 css 样式时,浏览器又要重新构建 DOM、CSSOM 树,重新 render,重新 layout、paint;
  • Layout 在 Paint 之前,因此每次 Layout 重新布局(reflow 回流)后都要重新出发 Paint 渲染,这时又要去消耗 GPU;
  • Paint 不一定会触发 Layout,比如改个颜色改个背景;(repaint 重绘)
  • 图片下载完也会重新出发 Layout 和 Paint;

浏览器渲染过程如下:

  1. 解析 HTML,生成 DOM 树,解析 CSS,生成 CSSOM 树
  2. 将 DOM 树和 CSSOM 树结合,生成渲染树(Render Tree)
  3. Layout(回流):根据生成的渲染树,进行回流(Layout),得到节点的几何信息(位置,大小)
  4. Painting(重绘):根据渲染树以及回流得到的几何信息,得到节点的绝对像素
  5. Display:将像素发送给 GPU,展示在页面上

何时触发 reflow 和 repaint

reflow(回流): 根据 Render Tree 布局(几何属性),意味着元素的内容、结构、位置或尺寸发生了变化,需要重新计算样式和渲染树; repaint(重绘): 意味着元素发生的改变只影响了节点的一些样式(背景色,边框颜色,文字颜色等),只需要应用新样式绘制这个元素就可以了; reflow 回流的成本开销要高于 repaint 重绘,一个节点的回流往往回导致子节点以及同级节点的回流; GoogleChromeLabs 里面有一个 csstriggers,列出了各个 CSS 属性对浏览器执行 Layout、Paint、Composite 的影响。

引起 reflow 回流

通过构造渲染树,我们将可见DOM节点以及它对应的样式结合起来,可是我们还需要计算它们在设备视口(viewport)内的确切位置和大小,这个计算的阶段就是回流。

为了弄清每个对象在网站上的确切大小和位置,浏览器从渲染树的根节点开始遍历。

现代浏览器会对回流做优化,它会等到足够数量的变化发生,再做一次批处理回流。

  1. 页面第一次渲染(初始化)
  2. DOM 树变化(添加或删除可见的DOM元素)
  3. 元素的位置发生变化
  4. Render 树变化,元素的尺寸发生变化(包括外边距、内边框、边框大小、高度和宽度等)
  5. 内容发生变化,比如文本变化或图片被另一个不同尺寸的图片所替代。
  6. 浏览器窗口 resize
  7. 获取元素的某些属性:现代的浏览器都是很聪明的,由于每次重排都会造成额外的计算消耗,因此大多数浏览器都会通过队列化修改并批量执行来优化重排过程。浏览器会将修改操作放入到队列里,直到过了一段时间或者操作达到了一个阈值,才清空队列。但是!当你获取布局信息的操作的时候,会览器不得不清空队列,强制队列刷新,触发回流重绘来返回正确的值。因此,我们在修改样式的时候,最好避免使用上面列出的属性,他们都会刷新渲染队列。如果要使用它们,最好将值缓存起来。
    • offsetTop、offsetLeft、offsetWidth、offsetHeight
    • scrollTop、scrollLeft、scrollWidth、scrollHeight
    • clientTop、clientLeft、clientWidth、clientHeight
    • getComputedStyle()
    • getBoundingClientRect

引起 repaint 重绘

通过构造渲染树和回流阶段,我们知道了哪些节点是可见的,以及可见节点的样式和具体的几何信息(位置、大小),那么我们就可以将渲染树的每个节点都转换为屏幕上的实际像素,这个阶段就叫做重绘节点。

  1. reflow 回流必定引起 repaint 重绘,重绘可以单独触发
  2. 背景色、颜色、字体改变(注意:字体大小发生变化时,会触发回流)

优化 reflow、repaint 触发次数

  • 避免逐个修改节点样式,尽量一次性修改。
    • 使用 class :el.className += ' active';
    • 使用 cssText :el.style.cssText += 'border-left: 1px; border-right: 2px; padding: 5px;'
  • 使用 DocumentFragment 将需要多次修改的 DOM 元素缓存,最后一次性 append 到真实 DOM 中渲染
  • 可以将需要多次修改的 DOM 元素设置 display: none,操作完再显示。(因为隐藏元素不在 render 树内,因此修改隐藏元素不会触发回流重绘)
  • 避免多次读取某些属性,避免触发同步布局事件,如:读取offsetWidth
  • 将复杂的节点元素或复杂的动画效果,使用绝对定位,让其脱离文档流,降低回流成本

为什么一再强调将 css 放在头部,将 js 文件放在尾部

DOMContentLoaded 和 load

  • DOMContentLoaded 事件触发时,仅当 DOM 加载完成,不包括样式表,图片…
  • load 事件触发时,页面上所有的 DOM,样式表,脚本,图片都已加载完成

CSS 资源阻塞渲染

构建 Render 树需要 DOM 和 CSSOM,所以 HTML 和 CSS 都会阻塞渲染。所以需要让 CSS 尽早加载(如:放在头部),以缩短首次渲染的时间。

JS 资源

  • 阻塞浏览器的解析,也就是说发现一个外链脚本时,需等待脚本下载完成并执行后才会继续解析 HTML

    • 这和之前文章提到的浏览器线程有关,浏览器中 js 引擎线程和渲染线程是互斥的,详见《从 setTimeout-setInterval 看 JS 线程》
  • 普通的脚本会阻塞浏览器解析,加上 defer 或 async 属性,脚本就变成异步,可等到解析完毕再执行

    • async 异步执行,异步下载完毕后就会执行,不确保执行顺序,一定在 onload 前,但不确定在 DOMContentLoaded 事件的前后
    • defer 延迟执行,相对于放在 body 最后(理论上在 DOMContentLoaded 事件前)

举个栗子

<html>
  <head>
    <meta name="viewport" content="width=device-width,initial-scale=1" />
    <link href="style.css" rel="stylesheet" />
  </head>
  <body>
    <p>Hello <span>web performance</span> students!</p>
    <div><img src="awesome-photo.jpg" /></div>
    <script src="app.js"></script>
  </body>
</html>

  • 浏览器拿到 HTML 后,从上到下顺序解析文档
  • 此时遇到 css、js 外链,则同时发起请求
  • 开始构建 DOM 树
  • 这里要特别注意,由于有 CSS 资源,CSSOM 还未构建前,会阻塞 js(如果有的话)
  • 无论 JavaScript 是内联还是外链,只要浏览器遇到 script 标记,唤醒 JavaScript 解析器,就会进行暂停 blocked 浏览器解析 HTML,并等到 CSSOM 构建完毕,才执行 js 脚本
  • 渲染首屏(DOMContentLoaded 触发,其实不一定是首屏,可能在 js 脚本执行前 DOM 树和 CSSOM 已经构建完 render 树,已经 paint)

首屏优化 Tips

  • 减少资源请求数量(内联亦或是延迟动态加载)
  • 使 CSS 样式表尽早加载,减少@import 的使用,因为需要解析完样式表中所有 import 的资源才会算 CSS 资源下载完
  • 异步 js:阻塞解析器的 JavaScript 会强制浏览器等待 CSSOM 并暂停 DOM 的构建,导致首次渲染的时间延迟

css3硬件加速(GPU加速)

比起考虑如何减少回流重绘,我们更期望的是,根本不要回流重绘。这个时候,css3硬件加速就闪亮登场啦!!

使用css3硬件加速,可以让transform、opacity、filters、Will-change这些动画不会引起回流重绘 。但是对于动画的其它属性,比如background-color这些,还是会引起回流重绘的,不过它还是可以提升这些动画的性能。

在GPU渲染字体会导致抗锯齿无效。这是因为GPU和CPU的算法不同。因此如果你不在动画结束的时候关闭硬件加速,会产生字体模糊。