浏览器渲染机制:重绘与回流
1. 浏览器的渲染流程 (Rendering Process)
当浏览器从服务器下载完 HTML、CSS 和 JavaScript 等代码后,渲染引擎(如 Chrome 的 Blink,Safari 的 WebKit)会执行以下核心步骤:
- 解析 HTML,构建 DOM 树 (DOM Tree):
- 浏览器将 HTML 标签解析成树状的数据结构。DOM 树描述了网页的内容和骨架(包含所有的节点,哪怕是被
display: none隐藏的节点)。
- 浏览器将 HTML 标签解析成树状的数据结构。DOM 树描述了网页的内容和骨架(包含所有的节点,哪怕是被
- 解析 CSS,构建 CSSOM 树 (CSSOM Tree):
- 浏览器将 CSS 样式表解析成树状结构,计算出每个节点的最终样式(Computed Style)。
- 合并,构建渲染树 (Render Tree):
- 将 DOM 树和 CSSOM 树结合起来。注意:渲染树只包含需要显示在屏幕上的节点。例如,
<head>标签、带有display: none样式的节点不会出现在渲染树中;但visibility: hidden的节点会存在(因为它占据空间)。
- 将 DOM 树和 CSSOM 树结合起来。注意:渲染树只包含需要显示在屏幕上的节点。例如,
- 布局 / 回流 (Layout / Reflow):
- 有了渲染树(知道了有哪些元素以及它们的样式),浏览器需要计算出每个节点在屏幕上的确切几何信息(大小、位置)。这个过程就叫布局或回流。
- 绘制 / 重绘 (Paint / Repaint):
- 知道了元素的位置和大小,接下来就是把它们“画”出来。浏览器遍历渲染树,调用系统的图形 API(如 CPU 或 GPU),将每个节点的颜色、背景、边框、阴影、文字等视觉属性填充成屏幕上的像素。
- 合成 (Compositing):
- 现代浏览器为了提高效率,会将页面分成多个图层(Layers)分别绘制,最后再将这些图层按照正确的层叠顺序合并到一起,显示在屏幕上。
2. 什么是回流 (Reflow) 与重绘 (Repaint)?
在网页首次加载完成后,如果我们通过 JavaScript 操作 DOM 或者改变了 CSS 样式,浏览器可能需要重新执行上述的部分渲染步骤。
2.1 回流 (Reflow) —— “牵一发而动全身”
- 定义:当渲染树 (Render Tree) 中的部分或全部元素的几何属性(尺寸、位置、结构)发生改变时,浏览器必须重新计算这些元素及其受影响的父节点、子节点甚至兄弟节点的几何信息。这个重新计算布局的过程就是回流(也叫重排 Relayout)。
- 代价:回流是非常昂贵的性能操作。因为元素的尺寸和位置变化,往往会导致整个页面的文档流发生错位,浏览器不得不重新计算大半个甚至整个页面的布局。
- 触发回流的操作:
- 添加或删除可见的 DOM 元素。
- 元素的位置发生变化(如
top,left,margin,padding)。 - 元素的尺寸发生变化(如
width,height,border)。 - 内容发生变化(如文本数量增加导致高度撑开,或者替换了不同尺寸的图片)。
- 浏览器窗口尺寸改变(resize)。
- 读取某些特定的布局属性(极其重要,下文 FAQ 详述)。
2.2 重绘 (Repaint) —— “换个马甲”
- 定义:当元素的外观、视觉属性发生改变,但**完全没有改变它的几何属性(没有改变它在文档流中的位置和大小)**时,浏览器只需要把这个元素重新画一遍。这个过程就是重绘。
- 代价:重绘的代价比回流小得多,因为它不需要重新计算布局。
- 触发重绘的操作:
- 改变
color,background-color。 - 改变
visibility: hidden(注意:display: none会触发回流,因为元素消失且不再占位;而visibility: hidden只是不可见,仍占位,所以只触发重绘)。 - 改变
box-shadow,border-radius,outline。
- 改变
核心定律:回流必定会引起重绘,但重绘不一定会引起回流。
3. 浏览器的优化机制:渲染队列
由于回流和重绘很消耗性能,浏览器其实非常聪明。它内置了一个渲染队列来进行批量优化。
如果你用 JS 连续修改了 3 个样式:
js
div.style.width = '100px';
div.style.height = '100px';
div.style.marginTop = '10px';浏览器不会立刻执行 3 次回流。它会把这些修改操作放入一个队列中。当队列达到一定数量,或者过了一小段时间(通常是下一帧,约 16.6ms)后,浏览器会清空队列,将这多次修改合并成一次回流和重绘。
4. 常见问题 (FAQ) 与性能优化实战
4.1 为什么“读取”某些属性会导致浏览器强制回流(性能杀手)?
浏览器有队列优化机制。但如果你在修改样式的同时,立刻去读取了元素的几何属性:
js
div.style.width = '100px'; // 放入队列
console.log(div.offsetWidth); // 致命操作!
div.style.height = '100px';当执行到第二行 div.offsetWidth 时,浏览器为了给你返回最精确、最新的宽度值,它不得不立刻清空渲染队列,强行触发一次同步回流(Forced Synchronous Layout),然后再把值给你。这直接打破了浏览器的优化机制。
会触发强制同步回流的常见属性/方法:
offsetTop,offsetLeft,offsetWidth,offsetHeightscrollTop,scrollLeft,scrollWidth,scrollHeightclientTop,clientLeft,clientWidth,clientHeightgetComputedStyle()getBoundingClientRect()
优化建议:如果要在循环中读取这些值,一定要把它们缓存到局部变量中,不要在循环体内反复读取和写入。
4.2 如何尽量避免或减少回流与重绘?
这是前端性能优化的重头戏:
CSS 层面:
- 合并样式修改:不要逐条修改
style,而是通过改变className或使用cssText一次性修改。 - 避免使用
table布局:表格中哪怕一个单元格的内容改变,都可能导致整个表格的重新计算。 - CSS3 硬件加速 (GPU 加速):对于需要频繁做动画的元素,使用
transform(如translate,scale) 和opacity。这两个属性的改变,既不会触发回流,也不会触发重绘,它们是在最后的“合成 (Compositing)”阶段由 GPU 直接处理的,性能极高。 - 使用
will-change: transform提前告诉浏览器该元素要做动画,浏览器会为其分配独立的图层(Layer),避免它的变化影响到其他节点。
- 合并样式修改:不要逐条修改
JavaScript (DOM 操作) 层面:
- 离线操作 DOM:如果需要对 DOM 节点进行大量复杂的修改(如插入 1000 个
<li>):- 先用
display: none隐藏元素(1次回流)。 - 在内存中进行 1000 次修改(0次回流)。
- 再把
display改回来(1次回流)。总共只发生 2 次回流。
- 先用
- 使用 DocumentFragment:利用文档碎片
document.createDocumentFragment(),在内存中组装好一堆节点后,再用appendChild一次性插入到真实的 DOM 树中,这样只引发一次回流。 - 脱离文档流:对于复杂的动画元素,将其设置为绝对定位
position: absolute或fixed,使其脱离文档流。这样它的变化就不会影响周围其他元素的布局了。
- 离线操作 DOM:如果需要对 DOM 节点进行大量复杂的修改(如插入 1000 个