有一段时间我一直搞不明白一件事:同样是"移动一个元素",用 transform: translateX() 就很丝滑,用 left 就会掉帧------明明做的是同一件事,为什么差这么多?后来真正把浏览器渲染的这三个概念搞清楚之后,才发现这不是玄学,是完全可以用机制解释的。这篇是我的学习笔记。
一、先厘清一个容易混淆的概念
在进入正题前,必须先把这两件事分开:React re-render 和浏览器重排/重绘。
它们经常被放在一起讨论,但其实是两个不同层的事情:
markdown
筛选条件变化
↓
React re-render(React 层)
→ 组件函数重新执行,生成新的虚拟 DOM
→ Diff 算出最小变更
→ 更新真实 DOM
↓
浏览器重排 / 重绘(浏览器层)
→ 浏览器感知到 DOM 变化,重新计算布局/绘制
React re-render 可能 触发浏览器重排/重绘,但两者不是同一回事。React re-render 是 JS 层面的虚拟 DOM 计算,浏览器重排/重绘是渲染引擎层面的像素计算。优化方向也不同:useMemo/React.memo 减少的是 React re-render,transform 替代 top 优化的是浏览器渲染层。
二、浏览器渲染流程回顾
在"从 URL 到页面"的完整链路里,最后一段是浏览器拿到 DOM + CSSOM 之后的渲染工作:
markdown
DOM + CSSOM
↓
Render Tree(渲染树)
↓
Layout(重排) ← 计算每个元素的位置和大小
↓
Paint(重绘) ← 填充颜色、边框、阴影......
↓
Composite(合成)← 合并图层,输出到屏幕
重排、重绘、合成,是这条流水线的最后三步。理解它们的代价差异,是理解所有 CSS 性能优化的基础。
三、重排(Reflow):最贵的一步
什么是重排?
当元素的几何属性(位置、大小)发生变化,浏览器需要重新计算所有受影响元素的布局信息------这个过程叫重排,也叫 Reflow。
典型触发场景:
javascript
// 修改几何属性
element.style.width = '200px';
element.style.height = '100px';
element.style.margin = '20px';
element.style.padding = '10px';
// 改变元素显示状态
element.style.display = 'none'; // 从文档流移除,触发重排
element.style.display = 'block'; // 重新加入文档流,触发重排
// DOM 结构变化
document.body.appendChild(newElement);
parent.removeChild(child);
// 窗口大小变化
window.addEventListener('resize', handler);
为什么代价大?
重排的代价在于连锁反应。HTML 元素的布局是相互影响的------一个元素的宽度变了,它的兄弟元素可能需要重新排列,父元素的高度可能随之变化,父元素的父元素又可能受影响......
浏览器需要从受影响的节点开始,向上向下重新计算整棵子树的几何信息。如果变化发生在页面顶层,几乎等于重算整个页面布局。
四、重绘(Repaint):比重排轻,但不是没有代价
什么是重绘?
当元素的外观 发生变化,但位置和大小没变,浏览器只需要重新绘制受影响区域的像素------这叫重绘,也叫 Repaint。
典型触发场景:
javascript
// 颜色类变化
element.style.color = '#333';
element.style.backgroundColor = '#f5f5f5';
// 装饰性变化
element.style.boxShadow = '0 2px 8px rgba(0,0,0,0.1)';
element.style.borderColor = 'red';
element.style.outline = '2px solid blue';
// 可见性(注意:visibility 不触发重排,display 触发)
element.style.visibility = 'hidden';
重绘不需要重新计算布局,只需要重新"上色"------所以比重排轻得多,但仍然有开销,不是免费的。
五、关键规律:三者的包含关系
重排 ⊃ 重绘 ⊃ 合成
重排一定触发重绘(几何变了,外观也要重画)
重绘不一定触发重排(外观变了,位置不一定变)
合成不触发重排和重绘(完全跳过前两步)
开销排序:重排 > 重绘 > 合成
六、合成层与 transform 为什么快
这是整篇文章最关键的部分。
三种操作的完整流程对比
| 操作 | 触发流程 | 性能 |
|---|---|---|
改 width / height / top / left |
重排 → 重绘 → 合成 | 最差 |
改 color / background-color |
重绘 → 合成 | 中等 |
改 transform / opacity |
只合成 | 最好 |
transform 的工作原理
当浏览器发现一个元素使用了 transform 或 opacity 动画,它会把这个元素提升到独立的合成层(Compositing Layer) ,交给 GPU 处理。
css
普通元素动画(left/top):
修改样式
↓
重新 Layout(计算位置) ← CPU,影响其他元素
↓
重新 Paint(绘制像素) ← CPU,绘制整个区域
↓
Composite(合成) ← GPU
transform/opacity 动画:
修改样式
↓
Composite(合成) ← GPU 直接处理
(跳过 Layout 和 Paint)
关键在于:transform 是在已经绘制好的图层上做变换(平移、缩放、旋转),不改变元素在文档流中的实际位置,所以浏览器不需要重新计算布局,也不需要重新绘制像素------只需要 GPU 把这个图层的矩阵变换一下,直接合成输出。
实际代码对比
css
/* 触发重排 + 重绘,动画掉帧 */
.box-bad {
position: absolute;
left: 0;
transition: left 0.3s ease;
}
.box-bad:hover {
left: 200px; /* 每一帧都触发重排 */
}
/* 只触发合成,动画丝滑 */
.box-good {
position: absolute;
transform: translateX(0);
transition: transform 0.3s ease;
}
.box-good:hover {
transform: translateX(200px); /* 每一帧只触发合成,GPU 处理 */
}
视觉效果完全一样,但渲染代价天壤之别。这就是为什么 CSS 动画优先推荐使用 transform。
主动触发合成层提升
除了 transform 和 opacity,还可以通过 will-change 提示浏览器提前创建合成层:
css
/* 告诉浏览器:这个元素即将发生 transform 变化,提前准备合成层 */
.animated-card {
will-change: transform;
}
javascript
// 动画结束后,记得移除(合成层有内存开销)
element.addEventListener('animationend', () => {
element.style.willChange = 'auto';
});
will-change 不是越多越好------每个合成层都占用 GPU 内存,滥用反而会导致内存压力和性能下降。只在真正需要优化的动画元素上使用。
七、实际开发陷阱:循环里交替读写 DOM
这是一个在真实项目里很容易踩的坑,也是面试的高频考题。
为什么读取布局属性会触发强制重排?
当你读取 offsetHeight、clientWidth、getBoundingClientRect() 等属性时,浏览器必须给你一个当前准确的值。
如果在读取之前你刚刚写入了一些样式变化,而浏览器还没来得及执行重排,它就必须立即同步执行重排 ,才能返回准确数值。这叫强制同步重排(Forced Synchronous Layout)。
问题代码:循环内交替读写
javascript
// 每次循环都触发一次强制重排------100 次循环 = 100 次重排
const boxes = document.querySelectorAll('.box');
for (let i = 0; i < boxes.length; i++) {
const height = boxes[i].offsetHeight; // 读:强制触发重排,获取准确值
boxes[i].style.height = height + 10 + 'px'; // 写:标记待重排
// 下一次循环读 offsetHeight,又强制清算上面的标记
}
浏览器原本会把多次样式修改批量处理(一次重排),但读写交替打破了这个批处理------每次读取都迫使浏览器立即清算之前积累的修改。
修复:先批量读,再批量写
javascript
// 先读完所有值,再批量写------只触发 1 次重排
const boxes = document.querySelectorAll('.box');
// 第一步:批量读取(此时触发 1 次重排)
const heights = Array.from(boxes).map(box => box.offsetHeight);
// 第二步:批量写入(浏览器合并成 1 次重排处理)
boxes.forEach((box, i) => {
box.style.height = heights[i] + 10 + 'px';
});
本质是:把读操作和写操作分离,让浏览器能够合批处理写操作。
如果修改逻辑更复杂,可以借助 requestAnimationFrame 把写操作推到下一帧的开头执行:
javascript
// 环境:浏览器
// 场景:确保在下一帧开始时批量执行所有 DOM 写操作
const heights = Array.from(boxes).map(box => box.offsetHeight);
requestAnimationFrame(() => {
boxes.forEach((box, i) => {
box.style.height = heights[i] + 10 + 'px';
});
});
八、浏览器完整渲染流程总图
把前面所有内容串起来,完整看一遍:
css
URL 输入 → DNS → TCP → TLS → HTTP 请求/响应
↓
解析 HTML → DOM 树
解析 CSS → CSSOM 树
↓
Render Tree(去掉不可见节点)
↓
┌───────────────────────────────────────────────────────────┐
│ 浏览器渲染流水线 │
│ │
│ Layout(重排) │
│ 触发条件:width/height/top/left/margin/display 等改变 │
│ ↓ │
│ Paint(重绘) │
│ 触发条件:color/background/shadow/visibility 等改变 │
│ ↓ │
│ Composite(合成) │
│ 所有操作最终都到这一步 │
│ │
│ ✦ transform / opacity │
│ → 元素提升为独立合成层,GPU 直接处理 │
│ → 跳过 Layout 和 Paint,直达 Composite │
└───────────────────────────────────────────────────────────┘
↓
屏幕显示 🎉
延伸思考
梳理完这些,还有几个问题没完全搞清楚:
- 合成层的内存代价怎么量化? 什么情况下合成层的开销会超过它带来的性能收益,Chrome DevTools 里怎么观测?
- React 的批量更新(Batching)和浏览器的批量渲染是什么关系? React 18 的自动批处理,是不是某种程度上也在减少强制同步重排?
- CSS contain 属性是什么? 据说它可以把一个元素声明为"独立的渲染作用域",让重排影响范围收敛到局部------这个机制是怎么运作的?
🧠 面试常问版(核心记忆点)
5 条浓缩,面试前快速过一遍:
- React re-render ≠ 浏览器重排:React re-render 是 JS 层虚拟 DOM 的重新计算,浏览器重排是渲染引擎的布局重算。前者可能触发后者,但优化手段不同,不要混淆。
- 三层开销排序:重排(Reflow)> 重绘(Repaint)> 合成(Composite)。重排必触发重绘,重绘不必触发重排,合成跳过前两步。
transform快的原因 :浏览器把transform/opacity的元素提升到独立合成层,由 GPU 直接处理矩阵变换,完全跳过 Layout 和 Paint。left/top每帧都触发重排,transform每帧只做合成------这是动画性能差异的根源。- 强制同步重排陷阱 :读取
offsetHeight、getBoundingClientRect()等属性会强制浏览器立即执行重排。循环内交替读写 DOM = 每次循环触发一次重排。解决:先批量读,再批量写。 will-change的正确用法 :提前声明元素将发生 transform 变化,让浏览器预先创建合成层。但合成层有内存开销,不要滥用,动画结束后用will-change: auto释放。
参考资料
- web.dev - Rendering Performance - 渲染性能优化官方指南
- web.dev - Stick to Compositor-Only Properties - 合成层与 GPU 加速
- MDN - will-change - will-change 使用说明
- What forces layout/reflow - 触发强制重排的完整属性列表(Paul Irish 整理)