4. 重排与重绘:触发条件、性能开销对比,以及如何避免强制同步布局

说到页面流畅度,有两个概念你肯定绕不开——重排和重绘。

我见过不少开发者,一提到性能优化就只知道「减少重排重绘」。但真要问他们:什么操作会触发重排?重排和重绘到底谁更贵?怎么才能避免强制同步布局?很多人就答不上来了。

这一节,咱们就把这些事彻底掰扯清楚。

4.1 先搞清楚:浏览器是怎么渲染一帧的?

在讲重排重绘之前,你得先理解浏览器渲染一帧的流水线。我习惯把它分成五个步骤:

  1. JavaScript:执行脚本,修改 DOM 或 CSSOM
  2. Style:计算元素的最终样式
  3. Layout:计算元素的位置和尺寸(这就是重排)
  4. Paint:绘制像素(这就是重绘)
  5. Composite:合成图层,显示到屏幕上

说白了,重排一定会触发重绘,但重绘不一定需要重排。举个例子:你只改了个背景色,浏览器不需要重新计算布局,直接画就行。但你要是改了宽度或高度,那布局就得重新算,然后重新画。

核心结论:重排的性能开销远大于重绘。一次重排可能引发整个子树甚至整个文档的布局重新计算。

4.2 哪些操作会触发重排?

我在项目中踩过不少坑,总结下来,触发重排的操作大致分这几类:

类别 具体操作 示例
尺寸变化 修改 width、height、padding、margin、border el.style.width = '200px'
位置变化 修改 top、left、right、bottom el.style.top = '50px'
内容变化 修改文本、图片尺寸、输入框值 el.textContent = '新内容'
DOM 操作 增删元素、修改 class、切换 display document.body.appendChild(div)
读取布局属性 读取 offsetTop、scrollHeight、getComputedStyle el.offsetHeight
窗口变化 resize 事件 window.addEventListener('resize', ...)

嗯,这里要注意最后一条——读取布局属性。这是最容易忽略的坑。你想想看,如果你先改了样式,然后又立刻去读布局属性,浏览器为了给你「准确」的值,只能强制把队列里的布局任务提前执行。这就是所谓的强制同步布局。

避坑指南:我曾经在一个列表动画里,每帧都去读 offsetTop,结果页面直接卡成 PPT。后来才发现,每次读取都触发了强制同步布局,性能直接崩了。

4.3 重排 vs 重绘:性能开销到底差多少?

我直接给你一个直观的对比:

对比维度 重排(Layout) 重绘(Paint)
触发条件 尺寸、位置、内容、DOM 结构变化 颜色、背景、阴影、边框样式变化
影响范围 可能波及整个文档 通常只影响当前图层
计算复杂度 O(n) 或更高(n 为元素数量) O(像素面积)
典型耗时 几毫秒到几十毫秒 亚毫秒到几毫秒
能否避免 尽量减少,无法完全避免 可以大量减少

说白了,一次重排的代价可能是重绘的 10 倍甚至更多。所以优化的时候,优先减少重排。

4.4 如何避免强制同步布局?

这是实战中最常见的坑。我总结了一套「读写分离」的策略:

4.4.1 批量读取,批量写入

不要写一行读一行。把所有的写操作集中在一起,读操作也集中在一起。

// ❌ 错误写法:读写交替,触发强制同步布局
el.style.width = '100px';
const height = el.offsetHeight;  // 强制布局
el.style.height = height + 50 + 'px';  // 再次触发布局

// ✅ 正确写法:先批量读,再批量写
const height = el.offsetHeight;  // 先读
el.style.width = '100px';
el.style.height = height + 50 + 'px';  // 再写

4.4.2 使用 requestAnimationFrame 做读写分离

如果读写操作必须分散在不同的事件里,可以用 requestAnimationFrame 把写操作推迟到下一帧。

// 在事件回调里只读
function onScroll() {
  const scrollTop = window.scrollY;
  // 把写操作推迟到下一帧
  requestAnimationFrame(() => {
    el.style.transform = `translateY(${scrollTop}px)`;
  });
}

4.4.3 用 transform 代替 top/left

这个技巧我几乎在每个项目里都用。修改 transform 不会触发重排,只会触发合成。

// ❌ 触发重排
el.style.top = '100px';
el.style.left = '200px';

// ✅ 只触发合成
el.style.transform = 'translate(200px, 100px)';

小技巧:如果你要做动画,优先用 transform 和 opacity。这两个属性只会触发合成,性能最好。我习惯把动画元素单独提升为一个图层,用 will-change: transform 告诉浏览器提前准备。

4.5 知识体系总览

下面这张图帮你把这一节的核心逻辑串起来:

重排与重绘:知识体系总览 浏览器渲染流水线 JavaScript Style Layout(重排) Paint(重绘) Composite 触发条件 触发重排 尺寸/位置/内容变化 DOM 操作 / 读取布局属性 触发重绘 颜色/背景/阴影变化 边框样式变化 仅触发合成 transform / opacity will-change 优化策略 读写分离 批量读取 → 批量写入 避免读写交替 使用 rAF 将写操作推迟到下一帧 配合事件回调使用 图层提升 will-change: transform 减少重绘范围 核心原则:重排 > 重绘 > 合成,优先减少重排 强制同步布局是性能杀手,务必读写分离

4.6 实战建议

最后,给你几个我平时写代码时一定会遵守的原则:

  • 能用 transform 就别用 top/left——这个习惯养成了,动画性能基本不会差。
  • 批量操作 DOM——用 DocumentFragment 或者先把元素隐藏再操作,最后一次性显示。
  • 避免频繁读取布局属性——如果一定要读,先缓存下来。
  • 使用 will-change 要克制——别给所有元素都加上,否则浏览器会提前分配大量资源,反而拖慢性能。

嗯,这一节的内容就到这里。记住一句话:重排是性能的敌人,读写分离是你的武器。下次遇到卡顿,先检查是不是触发了强制同步布局。