在前端开发中,我们经常听到前辈们说:“要尽量减少 DOM 操作,避免引起回流和重绘。”
这句话听起来像是一句放之四海而皆准的“政治正确”,但如果你去深究:为什么读一个 offsetHeight 就会引发性能灾难?为什么用 transform 做动画就比 margin-left 丝滑百倍?现代框架(如 React/Vue)在底层又是如何帮我们擦屁股的?
今天,我们就来扒一扒浏览器渲染引擎底层的这三个核心概念:回流(Reflow/Layout)、重绘(Repaint/Paint)与 合成(Compositing)。
1. 渲染流水线:从代码到像素的旅程
在聊这三个概念之前,我们需要先达成一个共识:浏览器把 HTML/CSS 变成屏幕上的像素,是一条严格的流水线。精简来看,核心步骤如下:
- 构建树: DOM 树 + CSSOM 树 $\rightarrow$ 生成渲染树(Render Tree)。
- Layout(回流/布局): 计算每个节点在屏幕上的确切大小和位置。
- Paint(重绘/绘制): 填充像素,画出节点的颜色、文字、阴影等视觉效果。
- Composite(合成): 把绘制好的图层按正确顺序叠放在一起,最终输出到屏幕。
性能优化的核心奥义就一句话:在修改页面时,尽可能跳过前面的步骤,直接走后面的步骤。
2. 回流 (Reflow) —— 牵一发而动全身的“核弹”
什么是回流? 当元素的几何属性(宽高、位置、隐藏/显示)发生改变时,浏览器需要重新计算渲染树中受影响节点的几何信息。这个过程在 Chrome 中被称为 Layout。
生动比喻: 想象一个排满人的大合影。如果你要在第一排中间硬塞进一个胖子(修改 DOM 或改变宽度),那么他后面的所有人,甚至旁边的人,都要重新调整站位。这就是回流。
触发条件:
- 修改
width、height、padding、margin。 - 改变窗口大小(Resize)。
- 隐形杀手: 当你用 JS 读取
offsetTop、scrollWidth、getComputedStyle()时。浏览器为了给你最精确的值,会强制清空当前的渲染队列,立刻触发一次回流。
性能代价:极高。 回流是一个 $O(N)$ 级别的操作,DOM 树越复杂,卡顿越明显。并且,回流必定会触发重绘。
3. 重绘 (Repaint) —— 换个马甲的“刷漆工”
什么是重绘? 当元素的外观属性发生变化,但没有改变其布局空间时,浏览器只需要重新把新的像素颜色涂上去。
生动比喻: 还是那个大合影,合影队形完全没变,只是其中一个人把红衣服换成了绿衣服。其他人都不需要动,摄影师只需要给这个人重新上色即可。
触发条件:
- 修改
color、background-color、visibility。
性能代价:中等。 虽然省去了复杂的布局计算,但重新填充像素依然需要消耗 CPU 资源。
4. 合成 (Compositing) —— GPU 护航的“终极魔法”
什么是合成? 现代浏览器为了追求极致性能,引入了分层(Layer)的概念。它会把页面拆分成多个图层(类似 Photoshop 的图层),各自绘制后,再由 GPU 将它们合成在一起。
如果你修改的属性既不需要改变布局(无回流),也不需要重新填充像素(无重绘),仅仅是改变了图层的位置、缩放比例或透明度,浏览器就会直接跳过 Layout 和 Paint,进入 Composite 阶段。
触发条件:
transform(如translate,scale,rotate)。opacity。
性能代价:极低。 这步操作完全交由 GPU 处理,GPU 天生就是做矩阵变换和透明度合成的王者,所以用 transform 做的动画永远比改 left/top 流畅。
5. 现代框架的降维打击:React 与 Vue 是怎么做的?
了解了底层的残酷,我们再来看看平时用的 React 和 Vue 是如何充当“性能保镖”的。
React:Virtual DOM 的批量更新艺术
在 jQuery 时代,我们要让一个列表增加 3 个元素,可能会执行 3 次 appendChild,触发 3 次回流。
React 引入了 Virtual DOM。你在代码里不管怎么 setState,React 都会先在内存里的 JS 对象树(虚拟 DOM)上进行模拟修改。
通过 Diff 算法(在 React 16+ 中进化为 Fiber 架构),React 会计算出这 3 个元素的最小变更集(Patch),然后合并成一次真实 DOM 的更新,最终只触发一次回流。
Vue:精准打击与异步队列
Vue 的响应式系统在依赖收集时,精确知道了哪个组件的数据变了。
但如果同一个组件里的一个数据在一个事件循环(Tick)里被连续修改了 10 次,Vue 会触发 10 次回流吗?
不会。 Vue 内部维护了一个异步更新队列(基于 Promise 或 MutationObserver 这种微任务)。它会把这 10 次修改合并,直到当前同步代码执行完毕后,在 nextTick 中统一执行 DOM 更新。这就是所谓的“读写分离与批量更新”。
总结
- 能用 CSS3
transform和opacity做的动画,绝不用left/top。(榨干 GPU) - 尽量不要在 JS 的循环里频繁读取
offsetHeight等属性。(避免强制同步布局) - 如果一定要进行复杂的 DOM 操作,先让元素脱离文档流(
display: none或使用DocumentFragment),操作完再放回去。
理解了回流、重绘与合成,你就掌握了前端性能优化的“上帝视角”。无论是手写原生 JS,还是用 Next.js 搭建复杂的 SSR 页面,你都能清楚地知道每一行代码在浏览器底层引发的蝴蝶效应。
Leave a comment