跳到主要内容
预计阅读 41 分钟

CSS篇 | 回流与重绘

前言

前端性能优化是面试的高频主题,而回流(Reflow)和重绘(Repaint)是其中最核心的概念之一。很多同学知道”回流比重绘更消耗性能”这句话,但说不清楚为什么,也不知道具体哪些操作会触发回流。

面试中,回流与重绘通常以这些形式出现:

  • “说一下浏览器的渲染流程”
  • “回流和重绘的区别是什么?”
  • “哪些操作会触发回流?”
  • “怎么减少回流和重绘?”
  • “为什么 transform 动画比 left/top 动画性能好?”
  • “什么是合成层?will-change 有什么作用?”

想把这些问题讲清楚,就得从浏览器的渲染流水线说起。

诊断自测

在开始之前,试着回答以下问题:

1. 以下两段代码,哪个性能更好?为什么?

// 代码 A
for (let i = 0; i < 100; i++) {
  el.style.left = i + 'px';
  el.style.top = i + 'px';
}

// 代码 B
for (let i = 0; i < 100; i++) {
  el.style.transform = `translate(${i}px, ${i}px)`;
}

2. 读取 offsetWidth 属性会触发回流吗?

3. display: nonevisibility: hidden 在回流重绘方面有什么区别?

点击查看答案

第1题: 代码 B 性能更好。修改 left/top 会触发回流(Layout)和重绘(Paint),而 transform 只会触发合成(Composite),跳过了 Layout 和 Paint 阶段,性能开销小得多。而且 transform 动画可以在 GPU 上运行,不占用主线程。

第2题: 会。读取 offsetWidthoffsetHeightclientWidthscrollTopgetBoundingClientRect() 等属性时,浏览器为了返回最新的准确值,会强制执行一次回流。这叫做”强制同步布局”(Forced Synchronous Layout)。

第3题: display: none 会触发回流(元素从渲染树中移除,后面的元素要重新排列),visibility: hidden 只会触发重绘(元素仍然占据空间,只是不可见了)。

浏览器渲染流水线

要理解回流和重绘,首先得知道浏览器是怎么把 HTML 和 CSS 变成我们看到的页面的。

完整的渲染流程

HTML → DOM 树

                      合并 → 渲染树 → Layout(布局) → Paint(绘制) → Composite(合成)

CSS  → CSSOM 树

每一步的职责:

1. 解析 HTML → DOM 树

浏览器将 HTML 解析为一棵 DOM 树(Document Object Model),每个 HTML 标签对应一个节点。

2. 解析 CSS → CSSOM 树

浏览器将所有 CSS(外部样式表、内联样式、浏览器默认样式)解析为 CSSOM 树(CSS Object Model)。

3. 合并 → 渲染树(Render Tree)

将 DOM 树和 CSSOM 树合并为渲染树。注意:display: none 的元素不会出现在渲染树中,但 visibility: hidden 的元素会。

4. Layout(布局/回流)

计算渲染树中每个节点的几何信息——位置(x, y)、大小(width, height)。这一步也叫 Reflow(回流)。

5. Paint(绘制/重绘)

将每个节点的视觉属性(颜色、阴影、边框样式等)绘制到屏幕上的各个层(Layer)。这一步也叫 Repaint(重绘)。

6. Composite(合成)

将各个绘制层按正确的顺序合成到一起,最终显示在屏幕上。这一步在 GPU 上完成。

渲染流水线的关键路径

┌──────────┐   ┌──────────┐   ┌──────────┐   ┌──────────┐   ┌──────────┐
│ JavaScript│ → │  Style   │ → │  Layout  │ → │  Paint   │ → │Composite │
│ (JS 修改) │   │(样式计算) │   │  (回流)  │   │  (重绘)  │   │  (合成)  │
└──────────┘   └──────────┘   └──────────┘   └──────────┘   └──────────┘

这条流水线中,越靠前的步骤被触发,后续的所有步骤都要重新执行。这就是为什么回流比重绘更消耗性能——回流之后必定要重绘,而重绘不一定会回流。

回流(Reflow)vs 重绘(Repaint)

什么是回流?

回流是指浏览器重新计算元素的几何属性(位置和大小)的过程。 当页面的布局发生变化时,浏览器需要重新计算哪些元素在哪里、有多大,然后重新排列。

回流的代价很高,因为它可能影响整个页面的布局——改变一个元素的宽度,可能导致它的兄弟元素、父元素、甚至不相关的元素都需要重新排列。

什么是重绘?

重绘是指浏览器重新绘制元素的视觉样式(不涉及几何属性)的过程。 比如只改变了颜色或背景色,元素的位置和大小没变,就只需要重绘,不需要回流。

关系图

修改几何属性(宽、高、位置等)
  → 触发回流(Layout)
    → 触发重绘(Paint)
      → 触发合成(Composite)

修改视觉属性(颜色、阴影等)
  → 不触发回流
    → 触发重绘(Paint)
      → 触发合成(Composite)

修改 transform / opacity
  → 不触发回流
    → 不触发重绘
      → 只触发合成(Composite)

哪些操作触发回流?

一定会触发回流的操作

1. 修改元素的几何属性

/* 以下属性的修改都会触发回流 */
width, height
padding, margin, border
top, left, right, bottom(定位元素)
font-size, line-height
min-width, max-height

2. 增删 DOM 节点

document.body.appendChild(newElement); // 回流
document.body.removeChild(oldElement); // 回流

3. 修改 display 属性

el.style.display = 'none';  // 回流(元素从渲染树移除)
el.style.display = 'block'; // 回流(元素加入渲染树)

4. 改变窗口大小

window.addEventListener('resize', () => {
  // 每次 resize 都会触发回流
});

5. 读取某些属性(强制同步布局)

这是最容易被忽视的。浏览器为了返回准确的值,会在读取以下属性时强制执行回流:

// 这些属性/方法的读取都会触发强制回流
el.offsetWidth / el.offsetHeight
el.offsetTop / el.offsetLeft
el.clientWidth / el.clientHeight
el.scrollWidth / el.scrollHeight
el.scrollTop / el.scrollLeft
el.getBoundingClientRect()
window.getComputedStyle(el)

只触发重绘的操作

/* 以下属性的修改只触发重绘,不触发回流 */
color
background-color
background-image
visibility
border-color
border-style
box-shadow
outline

只触发合成的操作

/* 以下属性的修改只触发合成,不触发回流和重绘 */
transform
opacity
filter
will-change

这就是为什么 transform 动画性能好的原因——它完全跳过了 Layout 和 Paint 阶段。

光栅化(Rasterization)

在 Paint 阶段,浏览器并不是直接把像素画到屏幕上的,而是经过一个叫光栅化的过程。

什么是光栅化?

光栅化就是将绘制指令转换为实际像素的过程。你可以理解为:Paint 阶段生成了一份”绘图指令清单”(“先画一个红色矩形,再画一行文字…”),光栅化就是把这份清单”执行”出来,变成一块块像素数据(位图)。

现代浏览器的分块光栅化

现代浏览器(如 Chrome)会将页面分成多个图块(Tiles),然后对每个图块独立光栅化。这样做的好处是:

  1. 优先光栅化可视区域:用户看到的区域先渲染,看不到的部分可以延后
  2. 可以利用 GPU 加速:图块可以交给 GPU 来光栅化,速度更快
  3. 增量更新:某个图块变化了,只需要重新光栅化该图块,不影响其他图块
页面
┌────┬────┬────┐
│ T1 │ T2 │ T3 │  ← 每个 Tile 独立光栅化
├────┼────┼────┤
│ T4 │ T5 │ T6 │
├────┼────┼────┤
│ T7 │ T8 │ T9 │
└────┴────┴────┘

光栅化后的图块(位图数据)会被上传到 GPU 显存中,然后在 Composite 阶段由 GPU 合成最终画面。

性能优化

1. 使用 transform 和 opacity 做动画

/* 不推荐:触发回流 */
.box-bad {
  transition: left 0.3s, top 0.3s;
}
.box-bad:hover {
  left: 100px;
  top: 100px;
}

/* 推荐:只触发合成 */
.box-good {
  transition: transform 0.3s;
}
.box-good:hover {
  transform: translate(100px, 100px);
}

同理,用 opacity 做淡入淡出比 visibility 性能更好:

/* 推荐 */
.fade {
  transition: opacity 0.3s;
}
.fade.hidden {
  opacity: 0;
}

2. 避免频繁的强制同步布局

问题代码:

// 每次循环都会触发强制回流
for (let i = 0; i < items.length; i++) {
  items[i].style.width = container.offsetWidth + 'px';
  // 读取 offsetWidth → 强制回流 → 设置 width → 标记需要回流 → 下次循环又读取...
}

优化代码:

// 先读取,再批量设置
const width = container.offsetWidth; // 只触发一次回流
for (let i = 0; i < items.length; i++) {
  items[i].style.width = width + 'px';
}
// 所有设置完成后,浏览器在下一帧统一回流一次

3. 使用 will-change 提升合成层

will-change 告诉浏览器”这个元素即将发生变化”,浏览器会提前将该元素提升到独立的合成层(Compositing Layer)

.animated-box {
  will-change: transform;
}

什么是合成层?

默认情况下,页面上所有元素共享同一个绘制层。如果某个元素被提升为合成层,它就有了自己的”画布”。这个元素的变化(如 transform 动画)不会影响其他元素的绘制,GPU 可以独立处理。

触发合成层的条件:

/* 以下情况会创建合成层 */
will-change: transform / opacity / filter;
transform: translateZ(0); /* 经典的 hack */
position: fixed;
video / canvas / iframe 元素;
有 3D transform 的元素;
对 opacity/transform/filter 应用了 CSS 动画;

will-change 的注意事项:

/* 不要滥用! */
* {
  will-change: transform; /* 错误:所有元素都创建合成层,消耗大量内存 */
}

/* 正确用法:只在需要的元素上使用 */
.carousel-item {
  will-change: transform;
}

/* 更好的做法:动态添加 */
.box:hover {
  will-change: transform;
}
.box.animating {
  will-change: transform;
}

每个合成层都需要额外的内存,滥用 will-change 会导致内存占用暴增(“层爆炸”)。

4. 批量修改 DOM

如果需要对 DOM 做多次修改,可以:

方案一:使用 DocumentFragment

const fragment = document.createDocumentFragment();
for (let i = 0; i < 100; i++) {
  const li = document.createElement('li');
  li.textContent = `Item ${i}`;
  fragment.appendChild(li);
}
// 只触发一次回流
document.querySelector('ul').appendChild(fragment);

方案二:先脱离文档流,修改后再放回

const el = document.getElementById('list');
el.style.display = 'none'; // 回流一次
// 进行各种 DOM 操作...
el.style.display = 'block'; // 回流一次
// 总共只触发两次回流,而不是每次操作都回流

方案三:使用 requestAnimationFrame

// 将 DOM 修改推迟到下一帧,让浏览器统一处理
requestAnimationFrame(() => {
  el.style.width = '200px';
  el.style.height = '100px';
  el.style.margin = '10px';
  // 浏览器会在下一帧统一执行这些修改
});

5. 使用 CSS 的 contain 属性

contain 属性告诉浏览器,某个元素的内部变化不会影响外部布局:

.widget {
  contain: layout; /* 内部布局变化不影响外部 */
}

.card {
  contain: content; /* 相当于 layout + paint + style */
}

.isolated {
  contain: strict; /* 最严格的隔离 */
}

这可以帮助浏览器优化回流的范围——如果一个元素有 contain: layout,它内部的回流不会”传染”到外部。

如何用 DevTools 检测回流和重绘

Chrome DevTools 检测方法

1. Performance 面板

按 F12 打开 DevTools → Performance → 点击录制 → 进行页面操作 → 停止录制。

在录制结果中,你可以看到:

  • 紫色:Layout(回流)
  • 绿色:Paint(重绘)
  • 浅绿色:Composite(合成)

如果你看到大量紫色块,说明页面有频繁的回流,需要优化。

2. Rendering 面板

在 DevTools 中按 Cmd+Shift+P(Mac)或 Ctrl+Shift+P(Windows),搜索”Show Rendering”。

勾选以下选项:

  • Paint flashing:重绘区域会高亮(绿色闪烁)
  • Layout Shift Regions:布局偏移区域会高亮(蓝色闪烁)
  • Layer borders:显示合成层边界

3. Performance Monitor

在 DevTools 的 More tools 中找到”Performance Monitor”,可以实时看到:

  • Layouts / sec(每秒回流次数)
  • Style recalcs / sec(每秒样式重计算次数)

实战检测示例

// 打开 Performance 面板录制,然后执行以下代码
function badAnimation() {
  const box = document.querySelector('.box');
  let pos = 0;
  function frame() {
    pos += 1;
    box.style.left = pos + 'px'; // 每帧触发回流
    if (pos < 300) requestAnimationFrame(frame);
  }
  frame();
}

function goodAnimation() {
  const box = document.querySelector('.box');
  let pos = 0;
  function frame() {
    pos += 1;
    box.style.transform = `translateX(${pos}px)`; // 只触发合成
    if (pos < 300) requestAnimationFrame(frame);
  }
  frame();
}

在 Performance 面板中对比这两个函数的录制结果,你会清楚地看到 badAnimation 有大量的 Layout 事件(紫色),而 goodAnimation 几乎没有。

常见误区

误区一:每次修改样式都会立即触发回流

浏览器有一个渲染队列(Rendering Queue)优化机制。当你连续修改多个样式时,浏览器会将这些修改”攒”起来,在下一次渲染时统一处理。

el.style.width = '100px';   // 不会立即回流
el.style.height = '200px';  // 不会立即回流
el.style.margin = '10px';   // 不会立即回流
// 浏览器在下一帧统一执行一次回流

但如果你在修改之后读取了布局属性,浏览器就不得不立即执行回流来返回准确值:

el.style.width = '100px';
console.log(el.offsetWidth); // 强制立即回流!
el.style.height = '200px';
console.log(el.offsetHeight); // 又一次强制回流!

误区二:transform 完全没有性能开销

虽然 transform 不触发回流和重绘,但它并不是零开销的。它需要 GPU 来处理合成,如果合成层太多,GPU 内存会成为瓶颈。在低端设备上,大量使用 transform 动画也可能导致卡顿。

关键是找到平衡:用 transform 代替 left/top 是对的,但不要把整个页面都变成合成层。

误区三:visibility: hidden 不触发任何开销

visibility: hidden 不触发回流(元素仍占据空间),但它会触发重绘。如果你的目的是完全隐藏元素,display: none 更彻底(虽然会触发回流);如果只是暂时不可见,opacity: 0 配合 pointer-events: none 是性能最好的方案(只触发合成)。

/* 方案对比 */
.hidden-display { display: none; }         /* 触发回流 + 重绘 */
.hidden-visibility { visibility: hidden; } /* 只触发重绘 */
.hidden-opacity {                          /* 只触发合成 */
  opacity: 0;
  pointer-events: none;
}

误区四:will-change 越多越好

will-change 会创建新的合成层,每个合成层都需要额外的内存。如果给太多元素加 will-change,内存占用会急剧增加,反而导致性能下降。

正确的做法是:只在确实需要优化的元素上使用,而且最好是在需要动画时动态添加,动画结束后移除。

// 动态管理 will-change
el.addEventListener('mouseenter', () => {
  el.style.willChange = 'transform';
});

el.addEventListener('animationend', () => {
  el.style.willChange = 'auto';
});

小结

回流和重绘是浏览器渲染性能的核心概念。理解了渲染流水线的每个阶段,你就能有针对性地优化页面性能。

本章思维导图

回流与重绘
  • 浏览器渲染流水线
    • HTML → DOM 树
    • CSS → CSSOM 树
    • 合并 → 渲染树
    • Layout(布局/回流)
    • Paint(绘制/重绘)
      • 光栅化(分块转换为位图)
    • Composite(合成,GPU 处理)
  • 回流(Reflow)
    • 修改几何属性(width, height, margin...)
    • 增删 DOM 节点
    • 修改 display
    • 窗口 resize
    • 读取布局属性(offsetWidth 等,强制同步布局)
  • 重绘(Repaint)
    • 修改视觉属性(color, background, box-shadow...)
  • 合成(Composite)
    • transform, opacity, filter
  • 光栅化
    • 将绘制指令转换为像素
    • 分块光栅化(Tiles)
    • GPU 加速
  • 性能优化
    • 用 transform/opacity 做动画
    • 避免频繁读取布局属性
    • will-change 提升合成层(不要滥用)
    • 批量修改 DOM(DocumentFragment)
    • requestAnimationFrame
    • contain 属性限制回流范围
  • DevTools 检测
    • Performance 面板(录制分析)
    • Rendering 面板(Paint flashing)
    • Performance Monitor(实时监控)

练习挑战

挑战一:基础(⭐)

以下操作中,哪些只触发重绘,哪些会触发回流?

A. el.style.color = 'red'
B. el.style.fontSize = '20px'
C. el.style.backgroundColor = 'blue'
D. el.style.padding = '10px'
E. el.style.boxShadow = '0 2px 4px rgba(0,0,0,0.1)'
F. el.style.transform = 'rotate(45deg)'
点击查看答案
  • A: color → 只触发重绘
  • B: fontSize → 触发回流(字号改变会影响元素大小和后续元素的位置)
  • C: backgroundColor → 只触发重绘
  • D: padding → 触发回流(影响元素大小)
  • E: boxShadow → 只触发重绘
  • F: transform → 只触发合成(不触发回流也不触发重绘)

挑战二:进阶(⭐⭐)

以下代码会触发几次回流?如何优化为最少的回流次数?

const el = document.getElementById('box');

el.style.width = '100px';
el.style.height = '200px';
const h = el.offsetHeight;
el.style.margin = '10px';
el.style.padding = '5px';
const w = el.offsetWidth;
el.style.border = '1px solid red';
点击查看答案

这段代码会触发 2 次强制回流 + 最终渲染时 1 次回流,共 3 次

分析:

  1. el.style.width = '100px' — 标记需要回流(不立即执行)
  2. el.style.height = '200px' — 标记需要回流(不立即执行)
  3. el.offsetHeight强制回流第 1 次(把前面积累的变更执行掉)
  4. el.style.margin = '10px' — 标记需要回流
  5. el.style.padding = '5px' — 标记需要回流
  6. el.offsetWidth强制回流第 2 次
  7. el.style.border = '1px solid red' — 标记需要回流
  8. 下一帧渲染时 — 回流第 3 次

优化方案:将所有读取操作集中在前面或后面,避免读写交替。

const el = document.getElementById('box');

// 先集中读取
const h = el.offsetHeight; // 强制回流 1 次(如果之前没有未处理的变更,这次可能不需要回流)
const w = el.offsetWidth;  // 不触发额外回流(刚刚已经是最新值了)

// 再集中写入
el.style.width = '100px';
el.style.height = '200px';
el.style.margin = '10px';
el.style.padding = '5px';
el.style.border = '1px solid red';
// 浏览器在下一帧统一回流 1 次

优化后最多 1-2 次回流,大幅减少。

挑战三:综合(⭐⭐⭐)

请分析以下动画方案的性能问题,并给出优化后的版本。

<div class="container">
  <div class="sidebar">固定侧边栏</div>
  <div class="content">
    <div class="card" id="animatedCard">
      <h2 class="card-title">标题</h2>
      <p class="card-desc">描述文字</p>
    </div>
  </div>
</div>
.card {
  position: relative;
  transition: all 0.3s;
}

.card:hover {
  left: 10px;
  top: -5px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
  border-color: #1890ff;
}
// 无限滚动列表,每次加载更多数据
function loadMore(items) {
  const list = document.querySelector('.list');
  items.forEach(item => {
    const li = document.createElement('li');
    li.textContent = item.name;
    li.style.height = '50px';
    list.appendChild(li); // 每次 appendChild 都可能触发回流
    console.log(li.offsetTop); // 强制同步布局!
  });
}
点击查看答案

问题分析:

  1. CSS 动画用了 left/top,触发回流
  2. transition: all 会导致所有属性变化都有动画,性能浪费
  3. box-shadow 变化触发重绘(不是大问题,但可以优化)
  4. JavaScript 中每次 appendChild + offsetTop 读取导致多次强制同步布局

优化后的 CSS:

.card {
  position: relative;
  /* 只对需要动画的属性设置 transition */
  transition: transform 0.3s, box-shadow 0.3s;
  will-change: transform; /* 提前创建合成层 */
}

.card:hover {
  /* 用 transform 代替 left/top */
  transform: translate(10px, -5px);
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
  /* border-color 变化不需要动画就去掉 transition */
  border-color: #1890ff;
}

优化后的 JavaScript:

function loadMore(items) {
  const list = document.querySelector('.list');
  const fragment = document.createDocumentFragment();

  items.forEach(item => {
    const li = document.createElement('li');
    li.textContent = item.name;
    li.style.height = '50px';
    fragment.appendChild(li); // 先添加到 fragment,不触发回流
  });

  // 一次性添加到 DOM,只触发一次回流
  list.appendChild(fragment);

  // 如果确实需要 offsetTop,在所有 DOM 操作完成后再读取
  // const offsets = [...list.querySelectorAll('li')].map(li => li.offsetTop);
}

优化要点:

  1. transform 替代 left/top — 回流变合成
  2. 明确列出 transition 的属性 — 避免不必要的动画计算
  3. will-change 提前创建合成层 — 避免动画开始时的”层提升”开销
  4. DocumentFragment 批量操作 DOM — 减少回流次数
  5. 读写分离 — 避免强制同步布局

自我检测

读完本章后,确认你能回答以下问题:

  • 能完整描述浏览器渲染流水线的各个阶段
  • 能准确区分回流和重绘的定义
  • 知道回流一定伴随重绘,但重绘不一定伴随回流
  • 能列举至少 5 种触发回流的操作
  • 知道读取 offsetWidth 等属性会触发强制同步布局
  • 知道 transform 和 opacity 只触发合成阶段
  • 能解释什么是合成层以及 will-change 的作用
  • 知道 will-change 不能滥用(层爆炸问题)
  • 能说出至少 3 种减少回流的优化手段
  • 知道如何用 Chrome DevTools 检测回流和重绘

购买课程解锁全部内容

大厂前端面试通关:71 篇构建完整知识体系

¥89.90