文章

回流与重绘深度解析

什么是强制同步布局?为什么读 offsetTop 再改 left 会卡成狗?怎么避免?

回流与重绘深度解析

一句话概括

回流(Reflow)是重新计算元素几何属性,重绘(Repaint)是重新画元素外观。回流一定触发重绘,重绘不一定触发回流。而强制同步布局——先读一个几何属性再改样式——是性能杀手。

核心知识点

1. 回流 vs 重绘 vs 合成

操作触发GPU 参与代价
回流改 width/height/margin/display/增删 DOM否高
重绘改 color/background/visibility/shadow否中
合成改 transform/opacity是极低

1000 次批量操作耗时对比:合成 ~2ms,重绘 ~15ms,回流 ~60ms,强制同步布局 200ms+。

2. 强制同步布局:最隐蔽的性能杀手

浏览器为了优化,会把多次样式修改合并到一帧末尾统一计算布局。但——如果代码在修改样式后马上读取几何属性,浏览器被迫立即计算布局,这就是 Forced Synchronous Layout(强制同步布局)。

1
2
3
4
5
6
7
8
9
// ❌ 每轮循环触发一次强制同步布局
for (let i = 0; i < boxes.length; i++) {
  boxes[i].style.width = boxes[i].offsetWidth + 10 + 'px';
  //                  ↑ 读 offsetWidth 时浏览器被迫立即布局
}

// ✅ 先读后写,分离读写批次
const widths = boxes.map(b => b.offsetWidth); // 读:触发一次布局
boxes.forEach((b, i) => b.style.width = widths[i] + 10 + 'px'); // 写:下一帧统一处理

会触发强制布局的属性:offsetTop/Left/Width/Height、scrollTop/Left/Width/Height、clientTop/Left/Width/Height、getComputedStyle()、getBoundingClientRect()。

3. 批量操作减少回流

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ❌ 三行三次回流
el.style.padding = '10px';
el.style.margin = '20px';
el.style.border = '1px solid red';

// ✅ cssText 一次回流
el.style.cssText = 'padding:10px; margin:20px; border:1px solid red';

// ✅ classList 更干净
el.classList.add('active');

// ✅ DocumentFragment 批量插入 DOM
const frag = document.createDocumentFragment();
items.forEach(item => { const li = document.createElement('li'); li.textContent = item; frag.appendChild(li); });
list.appendChild(frag); // 一次回流

4. 脱离文档流后再修改

1
2
3
4
5
6
7
8
9
10
11
// 方案1:display:none 后修改
el.style.display = 'none';   // 回流
el.style.width = '200px';    // display:none 下不回流
el.style.height = '100px';
el.style.display = 'block';  // 回流
// 总计 2 次,而不是每行 1 次

// 方案2:clone 替换
const clone = el.cloneNode(true);
clone.style.width = '200px';
el.parentNode.replaceChild(clone, el); // 1 次回流

5. 用 GPU 合成属性替代回流属性

1
2
3
4
5
/* ❌ 改 left → 回流 */
.moving { position: relative; left: 100px; }

/* ✅ 改 transform → 只合成,不进回流阶段 */
.moving { transform: translateX(100px); }

transform、opacity、filter 在独立合成层上运行时只走 GPU,完全跳过 Layout 和 Paint 阶段。这就是 FLIP 动画技巧的核心(First, Last, Invert, Play)。

其实你每天都在用

  • Chrome DevTools Rendering → Paint Flashing:绿色闪烁区域 = 在重绘,大片绿色 = 该优化了
  • Performance 面板的紫色块:紫色 = Paint,看到大块紫色说明绘制开销大
  • CSS contain 属性:contain: layout 告诉浏览器”这个元素的布局变化不影响到外部”,限制回流传播范围
  • will-change:提前提升到独立合成层,后续动画跳过回流重绘
  • ResizeObserver 替代 window.resize 监听:精确到元素级别,避免全局回流

常见误解(FAQ)

❌ 误区一:”visibility: hidden 改 visible 不会触发回流”

会触发重绘但不会回流,因为元素的位置和大小在渲染树中不变。但如果有兄弟元素依赖它(如 flex 布局中),变化可导致兄弟元素重排——这取决于具体布局上下文。

❌ 误区二:”给所有动画加上 will-change 就不会卡”

will-change 通知浏览器预先创建独立合成层,但每个层都消耗 GPU 显存。移动端大量 will-change 会导致显存溢出,浏览器被迫频繁清理图层,反而更卡。只在动画开始前加、结束后移除。

❌ 误区三:”requestAnimationFrame 里的操作不会回流”

rAF 只是把回调推迟到渲染帧的开始,不改变回调内部的渲染行为。在 rAF 里连续读写几何属性一样触发强制同步布局。

❌ 误区四:”回流只影响当前元素”

回流会向下传播到子元素、向上传播到父元素、横向传播到兄弟元素——除非用了 position: absolute/fixed 或 contain: layout 隔离。一个深层嵌套的元素改 width,可能引发整个文档树的布局重算。

一句话总结

性能问题很少是因为”浏览器太慢”,而是因为你不小心触发了它不该做的工作——在渲染帧中反复读写几何属性。记住”先读后写、批量操作、合成优先”十二字,大部分回流问题都能避开。

本文由作者按照 CC BY 4.0 进行授权