4. 重排与重绘:代价天差地别,如何精准避坑?
聊到浏览器渲染,有两个词你肯定绕不开——重排(Reflow)和重绘(Repaint)。
很多人觉得它们差不多,都是页面变了然后重新画一遍呗。其实差远了。我打个比方你就懂了:重排就像你把整个房间的家具重新摆一遍,重绘只是给墙刷个新颜色。哪个累?一目了然。
我个人习惯,在优化卡顿问题时,第一件事就是打开 DevTools 的 Performance 面板,看 Layout 和 Paint 的耗时。如果 Layout 占了大部分时间,那基本就是重排在作祟。
核心结论:重排的代价通常是重绘的 10 倍以上。能避免重排,就绝不只做重绘优化。
4.1 到底什么是重排?什么是重绘?
先理清概念。浏览器渲染流程大致是:
- 解析 HTML 生成 DOM 树
- 解析 CSS 生成 CSSOM 树
- 合并成 Render 树
- 布局(Layout)——计算每个节点的几何位置和大小
- 绘制(Paint)——把像素画到屏幕上
重排,就是当 DOM 的几何属性发生变化时,浏览器需要重新计算布局。比如你改了元素的 width、height、margin、padding、top、left,或者增删了 DOM 节点。
重绘,则是当元素的外观变了但几何尺寸没变时,浏览器只需要重新绘制。比如改了 color、background-color、visibility、outline。
嗯,这里要注意:重排一定会触发重绘,但重绘不一定触发重排。
我的经验:我在项目中遇到过,一个同事为了改按钮的 hover 颜色,顺手改了 padding。结果整个列表都跟着抖了一下。其实改颜色只需要重绘,改 padding 就触发了重排。这个「顺手」代价可不小。
4.2 哪些操作会触发重排?
说白了,只要浏览器觉得某个元素的「位置」或「尺寸」可能变了,它就会触发重排。我整理了一个常见触发清单:
| 操作类型 | 具体行为 | 触发范围 |
|---|---|---|
| 尺寸变化 | width、height、padding、margin、border |
自身 + 子元素 + 后续兄弟 |
| 位置变化 | top、left、position、float |
自身 + 后续兄弟 |
| DOM 操作 | 增删节点、修改 display(none ↔ block) |
整个子树 |
| 内容变化 | 文本内容、图片尺寸、输入框值 | 自身 + 父元素 |
| 读取布局属性 | offsetHeight、scrollTop、getComputedStyle() |
强制同步布局 |
最后一条很多人会忽略。你只是读了一个属性,为什么也会触发重排?
因为浏览器为了性能,会把多次样式修改攒起来一起处理。但当你读取 offsetHeight 这类属性时,浏览器必须立刻计算最新的布局来给你准确的值——这就叫强制同步布局(Forced Synchronous Layout)。
避坑指南:我曾经在一个长列表的滚动事件里,每帧都去读 element.offsetTop。结果页面滚动起来像 PPT 翻页一样卡。后来改成在 requestAnimationFrame 里统一读取,卡顿立刻消失了。
4.3 重排 vs 重绘:代价对比
光说理论不够,我们看个实际对比。假设你有一个 1000 个元素的列表:
// 只触发重绘
element.style.backgroundColor = 'red';
// 触发重排 + 重绘
element.style.width = '200px';
在 Chrome DevTools 里实测,改 backgroundColor 的 Paint 耗时大约 0.5ms。改 width 的 Layout + Paint 耗时大约 8ms。差了 16 倍。
你想想看,如果这个操作发生在滚动或动画的每一帧里,8ms 的 Layout 加上其他开销,很容易就超过 16ms 的帧预算,卡顿就来了。
| 对比维度 | 重排(Reflow) | 重绘(Repaint) |
|---|---|---|
| 触发条件 | 几何属性变化 | 外观属性变化 |
| 影响范围 | 自身 + 子元素 + 兄弟 + 祖先 | 仅自身 |
| 性能开销 | 高(毫秒级) | 低(微秒级) |
| 是否触发 GPU | 否,CPU 计算 | 可能触发合成层 |
| 优化优先级 | 最高 | 次之 |
4.4 精准避坑的 6 个实战策略
光知道概念不够,得能落地。下面是我这些年总结的 6 个策略,每个都踩过坑。
策略一:批量修改样式
不要一条一条改,用 class 一次性切换。
// ❌ 坏写法:触发 3 次重排
element.style.width = '100px';
element.style.height = '200px';
element.style.margin = '10px';
// ✅ 好写法:只触发 1 次重排
element.className = 'new-style';
策略二:读写分离
把「读布局属性」和「写样式」分开。先统一读,再统一写。
// ❌ 坏写法:读写交替,触发多次强制同步布局
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'; // 统一写
}
策略三:使用 transform 代替 top/left
动画移动元素时,用 transform: translate() 代替 top 和 left。前者只触发合成(Composite),不触发重排和重绘。
// ❌ 触发重排
element.style.top = '100px';
// ✅ 只触发合成
element.style.transform = 'translateY(100px)';
小技巧:我习惯把需要频繁动画的元素单独提升为合成层。用 will-change: transform 或 transform: translateZ(0) 告诉浏览器提前准备。但别滥用,每个合成层都会占用 GPU 内存。
策略四:脱离文档流
对于频繁变化的元素,用 position: fixed 或 position: absolute 把它从文档流里拿出来。这样它的重排不会影响到兄弟和父元素。
策略五:避免 table 布局
table 的布局计算非常复杂,一个小改动可能触发整个表格的重排。能用 flex 或 grid 就别用 table。
策略六:使用 requestAnimationFrame
把 DOM 操作放在 requestAnimationFrame 里,让浏览器在下一帧统一处理,避免在滚动或事件回调里直接操作。
// ❌ 在滚动回调里直接改样式
window.addEventListener('scroll', () => {
element.style.transform = `translateY(${window.scrollY}px)`;
});
// ✅ 用 rAF 节流
window.addEventListener('scroll', () => {
requestAnimationFrame(() => {
element.style.transform = `translateY(${window.scrollY}px)`;
});
});
4.5 知识体系总览
下面这张图帮你理清本章的核心逻辑:
4.6 写在最后
重排和重绘,说白了就是「动结构」和「动皮相」的区别。我见过太多卡顿问题,追根溯源都是因为一个 width 或 top 的改动引发了整棵子树的重排。
记住一个原则:能用 transform 就别用 top,能用 class 就别一条条改,能读就别写,能写就别读。
嗯,把这些策略记在心里,下次再遇到卡顿,你就能精准定位问题在哪了。