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: none 和 visibility: hidden 在回流重绘方面有什么区别?
点击查看答案
第1题: 代码 B 性能更好。修改 left/top 会触发回流(Layout)和重绘(Paint),而 transform 只会触发合成(Composite),跳过了 Layout 和 Paint 阶段,性能开销小得多。而且 transform 动画可以在 GPU 上运行,不占用主线程。
第2题: 会。读取 offsetWidth、offsetHeight、clientWidth、scrollTop、getBoundingClientRect() 等属性时,浏览器为了返回最新的准确值,会强制执行一次回流。这叫做”强制同步布局”(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),然后对每个图块独立光栅化。这样做的好处是:
- 优先光栅化可视区域:用户看到的区域先渲染,看不到的部分可以延后
- 可以利用 GPU 加速:图块可以交给 GPU 来光栅化,速度更快
- 增量更新:某个图块变化了,只需要重新光栅化该图块,不影响其他图块
页面
┌────┬────┬────┐
│ 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 次。
分析:
el.style.width = '100px'— 标记需要回流(不立即执行)el.style.height = '200px'— 标记需要回流(不立即执行)el.offsetHeight— 强制回流第 1 次(把前面积累的变更执行掉)el.style.margin = '10px'— 标记需要回流el.style.padding = '5px'— 标记需要回流el.offsetWidth— 强制回流第 2 次el.style.border = '1px solid red'— 标记需要回流- 下一帧渲染时 — 回流第 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); // 强制同步布局!
});
}
点击查看答案
问题分析:
- CSS 动画用了
left/top,触发回流 transition: all会导致所有属性变化都有动画,性能浪费box-shadow变化触发重绘(不是大问题,但可以优化)- 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);
}
优化要点:
transform替代left/top— 回流变合成- 明确列出
transition的属性 — 避免不必要的动画计算 will-change提前创建合成层 — 避免动画开始时的”层提升”开销DocumentFragment批量操作 DOM — 减少回流次数- 读写分离 — 避免强制同步布局
自我检测
读完本章后,确认你能回答以下问题:
- 能完整描述浏览器渲染流水线的各个阶段
- 能准确区分回流和重绘的定义
- 知道回流一定伴随重绘,但重绘不一定伴随回流
- 能列举至少 5 种触发回流的操作
- 知道读取 offsetWidth 等属性会触发强制同步布局
- 知道 transform 和 opacity 只触发合成阶段
- 能解释什么是合成层以及 will-change 的作用
- 知道 will-change 不能滥用(层爆炸问题)
- 能说出至少 3 种减少回流的优化手段
- 知道如何用 Chrome DevTools 检测回流和重绘
购买课程解锁全部内容
大厂前端面试通关:71 篇构建完整知识体系
¥89.90