# 重排与重绘优化

彻底理解 Reflow 和 Repaint 的区别、触发条件、性能影响,以及减少重排重绘的实战策略

前置知识:第01章(浏览器渲染管线)+ 第02章(合成层)


阅读指南(初学者必看)

为什么你需要学习重排与重绘?

你已经知道了浏览器渲染管线的 8 个阶段。但知道理论还不够,你需要知道:

  • 哪些操作会触发重排?哪些只触发重绘?
  • 为什么强制同步布局(Layout Thrashing)会让页面卡死?
  • 如何批量优化 DOM 操作,减少渲染开销?

学完本章,你能回答:

  • 修改 width 和修改 color 的性能差异是什么?
  • 为什么先读取布局属性再写入会导致 100 次重排?
  • 如何用 transform 完全避免重排?

一、什么是重排(Reflow)?

重排(Reflow),也叫布局(Layout),是浏览器重新计算页面元素位置和尺寸的过程。

触发重排的操作

类别 属性
尺寸 width, height, min-width, max-width
位置 top, left, right, bottom, margin, padding
布局 display, position, float, clear, overflow
文字 font-size, line-height, text-align
其他 border-width, outline-width
// 这些操作触发重排
element.style.width = '100px';
element.style.margin = '10px';
document.body.appendChild(newElement);
const height = element.offsetHeight;  // 强制同步布局

重排的成本

重排是渲染管线中最昂贵的操作之一:

  • 关联计算:影响子元素、兄弟元素、父元素
  • 同步执行:阻塞主线程
  • 一次重排可能需要 10-50ms(60fps要求每帧16.67ms)

二、什么是重绘(Repaint)?

重绘(Repaint) 是浏览器重新绘制元素外观的过程。尺寸和位置不变,只改变外观。

触发重绘的操作

类别 属性
颜色 color, background-color, border-color
背景 background-image, background-position
可见性 visibility(注意:不是display!)
// 这些操作只触发重绘
element.style.color = 'red';
element.style.backgroundColor = 'blue';

重绘的成本

重绘比重排便宜得多:

  • 不需要计算布局
  • 只影响元素自身
  • 一次重绘大约 1-5ms

三、重排 vs 重绘 vs 合成

对比项 重排 重绘 合成
触发条件 尺寸、位置、布局变化 外观变化 transform/opacity变化
是否计算布局
是否重新绘制
影响范围 元素 + 关联元素 仅元素自身 仅元素自身
耗时 10-50ms 1-5ms <1ms
性能影响 严重 轻微 极优
代价排序:重排 > 重绘 > 合成

修改 width/height/top/left → 重排 → 最慢
修改 color/background → 重绘 → 中等
修改 transform/opacity → 合成 → 最快

四、强制同步布局(Forced Synchronous Layout)

什么是强制同步布局?

当你在 JavaScript 中先读取布局属性,再修改样式,浏览器会被迫立即执行布局计算。这叫做强制同步布局(Forced Synchronous Layout),也叫布局抖动(Layout Thrashing)

代码示例

// 错误!强制同步布局
function updateLayout() {
  const boxes = document.querySelectorAll('.box');
  
  boxes.forEach(box => {
    // 读取布局属性 -> 浏览器被迫立即计算布局
    const height = box.offsetHeight;
    
    // 修改样式 -> 再次触发布局
    box.style.height = height * 2 + 'px';
  });
  // 循环100次 -> 浏览器做了200次布局计算!
}

发生了什么?

浏览器本来是延迟布局的(等到需要显示时才计算)。但当你读取 offsetHeight 时,浏览器必须立即给出准确的值,所以被迫立刻计算布局。然后你修改了样式,下一次读取又被迫重新计算。

如何避免强制同步布局?

方案1:先读取,后写入

// 正确:先读取所有值,再修改
function updateLayout() {
  const boxes = document.querySelectorAll('.box');
  
  // 第一步:读取所有值
  const heights = [];
  boxes.forEach(box => {
    heights.push(box.offsetHeight);  // 只触发1次布局
  });
  
  // 第二步:修改所有样式
  boxes.forEach((box, index) => {
    box.style.height = heights[index] * 2 + 'px';
  });
  // 总共只触发2次布局!
}

方案2:使用 requestAnimationFrame

// 正确:在下一帧渲染前批量处理
function updateLayout() {
  requestAnimationFrame(() => {
    const boxes = document.querySelectorAll('.box');
    boxes.forEach(box => {
      box.style.height = box.offsetHeight * 2 + 'px';
    });
  });
}

方案3:使用 FastDOM 库

// FastDOM 会自动批量处理读和写
fastdom.measure(() => {
  const height = element.offsetHeight;
  fastdom.mutate(() => {
    element.style.height = height * 2 + 'px';
  });
});

五、减少重排重绘的实战策略

策略1:使用 class 代替逐个修改 style

// 错误:每修改一个属性就触发一次重排
const box = document.getElementById('box');
box.style.width = '100px';
box.style.height = '100px';
box.style.margin = '10px';
box.style.padding = '5px';

// 正确:使用 class 一次性修改
box.className = 'new-style';
// 或者使用 cssText
box.style.cssText = 'width:100px;height:100px;margin:10px;padding:5px;';

策略2:使用 transform 代替 top/left

// 错误:触发重排
box.style.left = '100px';
box.style.top = '50px';

// 正确:只触发合成
document.getElementById('box').style.transform = 'translate(100px, 50px)';

策略3:先隐藏元素,修改完再显示

// 错误:每修改一个子元素都触发重排
list.style.width = '100px';
items.forEach(item => {
  item.style.height = '50px';  // 每次都会触发重排
});

// 正确:先隐藏,批量修改,再显示
list.style.display = 'none';   // 第1次重排
list.style.width = '100px';
items.forEach(item => {
  item.style.height = '50px';
});
list.style.display = 'block';  // 第2次重排
// 总共只触发2次重排!

策略4:使用 DocumentFragment 批量插入DOM

// 错误:每 appendChild 都触发重排
const list = document.getElementById('list');
for (let i = 0; i < 100; i++) {
  const item = document.createElement('li');
  item.textContent = 'Item ' + i;
  list.appendChild(item);  // 每次触发重排
}

// 正确:使用 DocumentFragment
const fragment = document.createDocumentFragment();
for (let i = 0; i < 100; i++) {
  const item = document.createElement('li');
  item.textContent = 'Item ' + i;
  fragment.appendChild(item);  // 不触发重排
}
list.appendChild(fragment);  // 只触发1次重排

策略5:使用 requestAnimationFrame 批量执行

// 错误:在滚动事件中直接修改样式
window.addEventListener('scroll', () => {
  document.getElementById('header').style.top = window.scrollY + 'px';
});

// 正确:使用 requestAnimationFrame
let ticking = false;
window.addEventListener('scroll', () => {
  if (!ticking) {
    requestAnimationFrame(() => {
      document.getElementById('header').style.transform = 'translateY(' + window.scrollY + 'px)';
      ticking = false;
    });
    ticking = true;
  }
});

策略6:缓存布局属性值

// 错误:反复读取布局属性
for (let i = 0; i < 100; i++) {
  // 每次读取都强制布局
  if (element.offsetWidth > 500) {
    element.style.width = '500px';
  }
}

// 正确:缓存布局属性
const width = element.offsetWidth;  // 只读取1次
for (let i = 0; i < 100; i++) {
  if (width > 500) {
    element.style.width = '500px';
  }
}

六、Chrome DevTools 检测重排重绘

方法1:Performance 面板

  1. 打开 Chrome DevTools -> Performance 面板
  2. 点击录制按钮,操作页面
  3. 停止录制
  4. 查看 Main 线程的火焰图:
    • 黄色条 = JavaScript 执行
    • 紫色条 = 样式计算(Style)
    • 蓝色条 = 布局(Layout)
    • 绿色条 = 绘制(Paint)

方法2:Rendering 面板

  1. 打开 Chrome DevTools -> 按 Esc -> 点击菜单(三个点)
  2. 选择 More tools -> Rendering
  3. 勾选以下选项:
    • Paint flashing:重绘的区域会绿色闪烁
    • Layout Shift Regions:布局变化的区域会蓝色闪烁

方法3:Layers 面板

  1. 打开 Chrome DevTools -> 按 Esc -> More tools -> Layers
  2. 查看合成层的数量和内存占用

自问自答

Q:为什么 visibility:hidden 不会触发重排,但 display:none 会? A:visibility:hidden 只是让元素看不见,但元素仍然占据原来的空间(位置和大小不变),所以只需要重绘。display:none 会让元素从 Render 树中移除,不再占据空间,周围的元素会填补它的位置,所以需要重排。

Q:修改 transform 真的完全不触发重排吗? A:是的!transform 只改变元素的变换矩阵,不改变元素在文档流中的位置和尺寸。浏览器可以把元素预先绘制在一个独立的合成层上,transform 只是改变这个层的位置/旋转/缩放,由 GPU 处理,不需要 CPU 参与布局计算。

Q:如何批量检测页面中的强制同步布局? A:打开 Chrome DevTools 的 Performance 面板,录制页面操作后,在 Main 线程火焰图中搜索"Layout",如果出现很多短小的紫色条交替出现(读-写-读-写模式),就说明存在强制同步布局。

Q:CSS 动画和 JS 动画哪个性能更好? A:CSS 动画通常性能更好,因为浏览器可以优化 CSS 动画的执行(如提前创建合成层、在合成线程运行)。JS 动画在主线程执行,如果逻辑复杂可能阻塞渲染。但如果动画需要复杂的动态计算(如物理模拟),JS 动画更灵活。


实践任务

  • 任务1:用 Chrome DevTools Performance 面板录制页面操作,识别重排和重绘的发生时刻
  • 任务2:制造强制同步布局,用 Rendering 面板的 Paint flashing 观察影响范围
  • 任务3:将一个 left/top 动画改造成 transform 动画,对比帧率变化
  • 任务4:用 DocumentFragment 批量插入 1000 个 DOM 元素,对比插入耗时
  • 任务5:缓存布局属性,将循环中的 offsetWidth 读取从 100 次降到 1 次

与其他章节的关联

本章内容 关联章节 关联点
重排/重绘 第01章 渲染管线的第4~6阶段
强制同步布局 第04章 长任务阻塞渲染导致掉帧
transform优化 第02章 合成层避免重排的原理
Performance面板 第10章 性能监控与预算

上一章:02-合成层与GPU加速 下一章:04-事件循环与渲染时序