从我接触前端到现在, 一直听到的一句话: 操作 DOM 的成本很高, 不要轻易去操作 DOM. 尤其是 React,vue 等 MV * 框架的出现, 数据驱动视图的模式越发深入人心, jQuery 时代提供的强大便利地操作 DOM 的 API 在前端工程里用的越来越少. 刨根问底, 这里说的成本, 到底高在哪儿呢?
什么是 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 内容.
那么问题来了, 只有 JavaScript 才能调用 DOM 这个 API 吗?
答案是 NO.
Python 也可以访问 DOM. 所以 DOM 不是提供给 JavaScript 的 API, 也不是 JavaScript 里的 API.
PS: 实质上还存在 CSSOM:CSS Object Model, 浏览器将 CSS 代码解析成树形的数据结构, 与 DOM 是两个独立的数据结构.
浏览器渲染过程
讨论 DOM 操作成本, 肯定要先了解该成本的来源, 那么就离不开浏览器渲染.
这里暂只讨论浏览器拿到 HTML 之后开始解析, 渲染.(怎么拿到 HTML 资源的可能后续另开篇总结吧, 什么握握握手啊挥挥挥挥手啊, 万恶的 flag...)
解析 HTML, 构建 DOM 树(这里遇到外链, 此时会发起请求)
解析 CSS, 生成 CSS 规则树
合并 DOM 树和 CSS 规则, 生成 render 树
布局 render 树(Layout/reflow), 负责各元素尺寸, 位置的计算
绘制 render 树(paint), 绘制页面像素信息
浏览器会将各层的信息发送给 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 → object model 这个过程.
DOM 树构建过程: 当前节点的所有子节点都构建好后才会去构建当前节点的下一个兄弟节点.
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;
何时触发 reflow 和 repaint
reflow(回流): 根据 Render Tree 布局(几何属性), 意味着元素的内容, 结构, 位置或尺寸发生了变化, 需要重新计算样式和渲染树; repaint(重绘): 意味着元素发生的改变只影响了节点的一些样式(背景色, 边框颜色, 文字颜色等), 只需要应用新样式绘制这个元素就可以了; reflow 回流的成本开销要高于 repaint 重绘, 一个节点的回流往往回导致子节点以及同级节点的回流;
https://github.com/GoogleChromeLabs 里面有一个 https://csstriggers.com/ , 列出了各个 CSS 属性对浏览器执行 Layout,Paint,Composite 的影响.
引起 reflow 回流
现代浏览器会对回流做优化, 它会等到足够数量的变化发生, 再做一次批处理回流.
页面第一次渲染(初始化)
DOM 树变化(如: 增删节点)
Render 树变化(如: padding 改变)
浏览器窗口 resize
获取元素的某些属性: 浏览器为了获得正确的值也会提前触发回流, 这样就使得浏览器的优化失效了, 这些属性包括 offsetLeft,offsetTop,offsetWidth,offsetHeight, scrollTop/Left/Width/Height,clientTop/Left/Width/Height, 调用了 getComputedStyle()或者 IE 的 currentStyle
引起 repaint 重绘
reflow 回流必定引起 repaint 重绘, 重绘可以单独触发
背景色, 颜色, 字体改变(注意: 字体大小发生变化时, 会触发回流)
优化 reflow,repaint 触发次数
避免逐个修改节点样式, 尽量一次性修改
使用 DocumentFragment 将需要多次修改的 DOM 元素缓存, 最后一次性 append 到真实 DOM 中渲染
可以将需要多次修改的 DOM 元素设置 display: none, 操作完再显示.(因为隐藏元素不在 render 树内, 因此修改隐藏元素不会触发回流重绘)
避免多次读取某些属性(见上)
将复杂的节点元素脱离文档流, 降低回流成本
为什么一再强调将 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 的构建, 导致首次渲染的时间延迟
so on...
知道操作 DOM 成本多高了吗?
其实写了这么多, 感觉偏题了, 大量的资料参考的是 Chrome 开发者文档. 感觉 JS 脚本资源那块还是有点乱, 包括和 DOMContentLoaded 的关系, 希望大家能多多指点, 多多批评, 谢谢大佬们.
操作 DOM 具体的成本, 说到底是造成浏览器回流 reflow 和重绘 reflow, 从而消耗 GPU 资源.
来源: https://juejin.im/post/5c1069a16fb9a049e0632fb6