4. 重排与重绘:代价天差地别,如何精准避坑?

聊到浏览器渲染,有两个词你肯定绕不开——重排(Reflow)和重绘(Repaint)。

很多人觉得它们差不多,都是页面变了然后重新画一遍呗。其实差远了。我打个比方你就懂了:重排就像你把整个房间的家具重新摆一遍,重绘只是给墙刷个新颜色。哪个累?一目了然。

我个人习惯,在优化卡顿问题时,第一件事就是打开 DevTools 的 Performance 面板,看 Layout 和 Paint 的耗时。如果 Layout 占了大部分时间,那基本就是重排在作祟。

核心结论:重排的代价通常是重绘的 10 倍以上。能避免重排,就绝不只做重绘优化。

4.1 到底什么是重排?什么是重绘?

先理清概念。浏览器渲染流程大致是:

  1. 解析 HTML 生成 DOM 树
  2. 解析 CSS 生成 CSSOM 树
  3. 合并成 Render 树
  4. 布局(Layout)——计算每个节点的几何位置和大小
  5. 绘制(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 知识体系总览

下面这张图帮你理清本章的核心逻辑:

重排与重绘知识体系 重排与重绘:核心知识体系 浏览器渲染流程 重排(Reflow) 重绘(Repaint) 触发条件 尺寸/位置/DOM 操作 内容变化/读取布局属性 触发条件 color/background-color visibility/outline/box-shadow 6 大优化策略 ① 批量修改样式(class 切换) ② 读写分离 ③ transform 代替 top/left ④ 脱离文档流(absolute/fixed) ⑤ 避免 table 布局 ⑥ requestAnimationFrame 核心原则:能避重排就避,避不了就缩小影响范围 重排必然触发重绘,但重绘不一定触发重排。优化优先级:重排 > 重绘

4.6 写在最后

重排和重绘,说白了就是「动结构」和「动皮相」的区别。我见过太多卡顿问题,追根溯源都是因为一个 width 或 top 的改动引发了整棵子树的重排。

记住一个原则:能用 transform 就别用 top,能用 class 就别一条条改,能读就别写,能写就别读。

嗯,把这些策略记在心里,下次再遇到卡顿,你就能精准定位问题在哪了。