3. 重排与重绘:浏览器渲染流水线深度解析,如何避免性能杀手
页面切换卡顿,很多时候不是网络慢,也不是 JS 执行慢。真正拖后腿的,是浏览器在背后默默做的那些「重排」和「重绘」。
我刚开始做前端那会儿,总觉得页面卡顿是框架的问题。后来有一次,一个列表页在滚动时疯狂掉帧,我用 Performance 面板一查,发现 Layout 和 Paint 占了 80% 的耗时。嗯,从那以后,我才真正开始重视渲染流水线。
3.1 浏览器渲染流水线:从 HTML 到像素
浏览器把代码变成屏幕上的像素,不是一步到位的。它走了一条流水线,每一步都可能成为性能瓶颈。
核心流水线(5 步走):
- DOM 树构建 — 解析 HTML,生成节点树
- CSSOM 树构建 — 解析 CSS,生成样式规则树
- Render Tree(渲染树) — 合并 DOM + CSSOM,只保留可见节点
- Layout(布局/重排) — 计算每个节点的几何位置(宽、高、x、y)
- Paint(绘制/重绘) — 把像素画到屏幕上
说白了,前两步是准备阶段,后两步才是真正的「渲染干活」。而重排和重绘,就是第 4 步和第 5 步的重复执行。
3.2 重排(Reflow)—— 性能杀手之王
重排,也叫 Layout。只要元素的几何属性变了,浏览器就得重新计算所有相关元素的位置和大小。
你想想看,一个元素宽度变了,它兄弟元素、父元素、甚至整个文档流都可能受影响。这就是重排的「连锁反应」。
⚠️ 哪些操作会触发重排?
- 修改 width、height、padding、margin、border
- 修改 top、left、position(脱离文档流)
- 修改 display: none → block(隐藏/显示)
- 读取 offsetTop、offsetLeft、scrollTop、clientWidth 等属性
- 添加或删除 DOM 节点
- 改变窗口大小(resize)
我记得有一次,一个同事在循环里反复读取 offsetHeight,然后修改元素高度。每次读取都会强制浏览器「提前」执行一次 Layout 队列。这就是典型的「强制同步布局」—— 性能毒药。
💡 避坑指南:
我曾经在优化一个表格组件时,发现每次排序都卡 2 秒。一查,排序函数里每比较一次就读一次 offsetTop。解决办法很简单:先把所有测量值缓存到变量里,操作完再统一写回。
3.3 重绘(Repaint)—— 没那么重,但也不能忽视
重绘只改变元素的外观,不改变几何尺寸。比如改个颜色、背景图、阴影。
重绘的代价比重排小,但也不是免费的。尤其是大面积区域重绘,比如整个页面背景色变化,或者一个覆盖全屏的弹窗出现,都会触发大量 Paint 操作。
| 操作 | 触发重排 | 触发重绘 | 性能影响 |
|---|---|---|---|
| 修改 width | ✅ 是 | ✅ 是 | 高 |
| 修改 color | ❌ 否 | ✅ 是 | 中 |
| 修改 transform | ❌ 否 | ❌ 否 | 低(合成层) |
| 修改 opacity | ❌ 否 | ❌ 否 | 低(合成层) |
3.4 如何避免重排与重绘?实战策略
光知道概念没用,得能落地。我总结了 6 条实战策略,每一条都是我在项目里踩过坑之后才真正理解的。
3.4.1 批量修改样式,别一条一条改
最蠢的做法:
// ❌ 糟糕:触发 3 次重排
element.style.width = '100px';
element.style.height = '200px';
element.style.margin = '10px';
正确做法:
// ✅ 推荐:用 class 一次性修改
element.classList.add('new-style');
// 或者用 cssText 批量写入
element.style.cssText = 'width:100px;height:200px;margin:10px;';
3.4.2 读写分离,避免强制同步布局
浏览器有个「渲染队列」机制。你连续改多个样式,它会攒一波再统一执行 Layout。但如果你在中间插了一个「读取」操作,队列就被清空了。
// ❌ 糟糕:读写交替,强制同步布局
for (let i = 0; i < items.length; i++) {
let h = items[i].offsetHeight; // 读 → 强制清空队列
items[i].style.height = h + 10 + 'px'; // 写
}
// ✅ 推荐:先统一读,再统一写
let heights = [];
for (let i = 0; i < items.length; i++) {
heights.push(items[i].offsetHeight);
}
for (let i = 0; i < items.length; i++) {
items[i].style.height = heights[i] + 10 + 'px';
}
3.4.3 使用 transform 和 opacity 做动画
这两个属性不会触发重排和重绘。它们由 GPU 单独处理,走的是「合成层」。说白了,就是白嫖 GPU 的算力。
// ❌ 糟糕:用 left 做动画 → 每帧都重排
element.style.left = '100px';
// ✅ 推荐:用 transform → 只合成,不重排
element.style.transform = 'translateX(100px)';
🎯 核心原则:
能用 transform 和 opacity 解决的动画,绝对不要用 left、top、width、height。
3.4.4 脱离文档流,减少连锁反应
如果一个元素用 position: fixed 或 absolute,它的变化不会影响其他元素的位置。重排范围被限制在它自己身上。
我建议:对于频繁变化的元素(比如拖拽、弹窗、加载动画),尽量用 fixed 或 absolute 定位。
3.4.5 使用 will-change 提前告知浏览器
will-change 属性告诉浏览器:「这个元素接下来要变了,你先准备好合成层。」
/* 提前告知浏览器,这个元素要频繁变化 */
.element {
will-change: transform, opacity;
}
⚠️ 注意:不要滥用 will-change。用得太多,浏览器会创建大量合成层,反而消耗内存。只在确实需要动画的元素上用。
3.4.6 避免频繁操作 DOM
DOM 操作本身就是昂贵的。频繁增删节点,每次都会触发重排。
// ❌ 糟糕:每次循环都插入 DOM
for (let i = 0; i < 1000; i++) {
document.body.appendChild(createItem(i));
}
// ✅ 推荐:用 DocumentFragment 批量插入
const fragment = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
fragment.appendChild(createItem(i));
}
document.body.appendChild(fragment);
3.5 总结:一张表记住所有优化点
| 优化策略 | 核心原理 | 优先级 |
|---|---|---|
| 批量修改样式 | 利用渲染队列,减少 Layout 次数 | ⭐⭐⭐ |
| 读写分离 | 避免强制同步布局 | ⭐⭐⭐ |
| transform / opacity | 走合成层,不触发重排重绘 | ⭐⭐⭐ |
| 脱离文档流 | 限制重排范围 | ⭐⭐ |
| will-change | 提前创建合成层 | ⭐⭐ |
| DocumentFragment | 批量 DOM 操作 | ⭐⭐ |
说实话,重排和重绘是前端性能优化里最容易被忽视的环节。很多人只盯着 JS 执行时间,却不知道浏览器在 Layout 和 Paint 上浪费了多少帧。你想想看,一个 60fps 的动画,每帧只有 16.6ms。一次重排如果吃掉 10ms,那留给 JS 和绘制的时间就只剩 6ms 了。卡顿,就是这么来的。
嗯,这一章的内容就到这里。记住一句话:减少重排,就是减少浏览器的无效劳动。