基础问答
问: 请简述浏览器的渲染过程,并解释什么是重绘和回流。
答: 浏览器拿到 HTML 后,渲染过程大致是这样的:
渲染树只包含可见节点(display: none 的元素不会出现在渲染树中,但 visibility: hidden 的会)。
回流(Reflow / Layout) :当渲染树中节点的几何信息(位置、大小)发生变化时,浏览器需要重新计算布局。这个过程叫回流,也叫重排。
触发回流的操作:添加/删除 DOM 元素、修改元素尺寸(width/height/padding/margin)、修改元素位置(top/left)、窗口大小变化、读取
offsetWidth/scrollTop等强制布局的属性。
重绘(Repaint) :当元素的外观属性(颜色、背景色、阴影等)发生变化,但不影响布局时,浏览器只需要重新绘制像素,不需要重新计算位置。这个过程叫重绘。
触发重绘的操作:修改
color、background-color、box-shadow、visibility、outline等视觉属性。
总之一句话,回流一定导致重绘,但重绘不一定引发回流。 因为回流的代价比重绘高得多。
扩展延伸
渲染阶段
理解重绘和回流,关键是要我们理解浏览器的渲染流水线。现代浏览器的渲染过程可以拆成五个阶段:
| 阶段 | 作用 | 输入 | 输出 |
|---|---|---|---|
| HTML 解析 | 构建 DOM 树 | HTML 字节流 | DOM 节点树 |
| CSS 解析 | 构建 CSSOM 树 | CSS 文本 | 样式规则树 |
| 布局 Layout | 计算几何信息 | 渲染树 | 盒模型位置/尺寸 |
| 绘制 Paint | 填充像素 | 布局结果 | 位图 |
| 合成 Composite | 合并图层 | 多个位图图层 | 最终屏幕画面 |
回流在 Layout 阶段:浏览器重新计算元素的位置和大小。
重绘在 Paint 阶段:浏览器重新生成受影响区域的位图。
合成在 Composite 阶段:如果页面有多个合成层(如 CSS 3D 变换、will-change),GPU 会将它们合成到一起,在这个过程中是不触发 Layout 和 Paint 的。
回流的代价
回流的开销取决于改动的范围:
最坏的情况是强制同步布局(Layout Thrashing) :
// 反例:读写交替导致强制布局
const items = document.querySelectorAll('.item');
for (let i = 0; i < items.length; i++) {
items[i].style.width = items[i].offsetWidth + 10 + 'px'; // 读 → 写 → 读 → 写...
}
每次读取 offsetWidth 都会触发浏览器立即执行布局计算,因为浏览器需要保证返回的值是最新的。在循环中反复读写交替,每一轮迭代都触发一次完整的布局重算。
正确做法是批量读写分离:
// 先读后写:浏览器只需要一次布局重算
const widths = [];
for (let i = 0; i < items.length; i++) {
widths[i] = items[i].offsetWidth; // 批量读
}
for (let i = 0; i < items.length; i++) {
items[i].style.width = widths[i] + 10 + 'px'; // 批量写
}
触发事件
常见的回流触发场景:
- 增删 DOM 节点
- 修改元素
width/height/padding/margin/border - 修改元素
position/top/left/float/clear - 修改
display/flex/grid布局属性 - 修改
font-size/font-family/text内容 - 改变窗口大小或调用
window.resize - 激活 CSS 伪类(
:hover改变了尺寸)
只触发重绘的场景:
- 修改
color/background-color/background-image - 修改
box-shadow/text-shadow - 修改
visibility/outline - 修改
border-color(注意:改border-width会触发回流)
只触发合成的场景(GPU 加速) :
transform: translate/scale/rotateopacityfilter
这三个属性走的是合成器(Compositor),不经过 Layout 和 Paint 阶段。做动画时优先用 transform 和 opacity ,这是前端性能优化的基本功。
减少回流和重绘
1. 使用 CSS 类名切换,而非逐条改样式
// 反例:逐条修改样式,触发多次回流
el.style.width = '100px';
el.style.height = '200px';
el.style.margin = '10px';
// 推荐:切换 class,浏览器只触发一次回流
el.className = 'active';
2. 批量 DOM 操作用 DocumentFragment
// 反例:每次 append 都触发一次回流
for (let i = 0; i < 100; i++) {
const li = document.createElement('li');
li.textContent = `Item ${i}`;
list.appendChild(li);
}
// 推荐:用 Fragment 一次性插入
const fragment = document.createDocumentFragment();
for (let i = 0; i < 100; i++) {
const li = document.createElement('li');
li.textContent = `Item ${i}`;
fragment.appendChild(li);
}
list.appendChild(fragment);
3. 先脱离文档流,修改完再放回去
如果需要对元素进行大量修改,可以先让它脱离文档流,等修改完成之后再塞回原来的位置:
// 脱离 → 修改 → 放回
el.style.display = 'none'; // 一次回流
el.style.width = '100px';
el.style.height = '200px';
el.style.margin = '10px';
el.style.display = 'block'; // 一次回流
// 总共两次回流,而非四次
4. 动画用 transform 和 opacity
/* 反例:用 left/top 做动画,每帧都触发回流 */
.move-bad {
transition: left 0.3s;
left: 0;
}
.move-bad.active {
left: 100px;
}
/* 推荐:用 transform 做动画,只触发合成 */
.move-good {
transition: transform 0.3s;
}
.move-good.active {
transform: translateX(100px);
}
5. 用 will-change 提前告知浏览器
.animated-element {
will-change: transform, opacity;
/* 浏览器会提前为该元素创建独立的合成层 */
}
值得注意的是,
will-change不是越多越好。每个will-change都会创建额外的合成层,消耗 GPU 内存。只在确实需要动画的元素上使用,动画结束后可以移除。
requestAnimationFrame
浏览器的渲染由一个事件循环驱动,但和 JavaScript 的事件循环不同,渲染发生在每次事件循环迭代的末尾,而不是宏任务/微任务之间。
requestAnimationFrame 的回调在 Layout 和 Paint 之前执行。这意味着在 rAF 回调里修改 DOM 样式,浏览器会在同一次渲染中处理这些变更,不会造成额外回流。
// 在 rAF 中批量修改样式,与浏览器渲染节奏对齐
function updateUI() {
element.style.transform = `translateX(${currentX}px)`;
requestAnimationFrame(updateUI);
}
requestAnimationFrame(updateUI);
合成层与硬件加速
当元素满足以下条件时,浏览器会为它创建独立的合成层(Compositing Layer) ,该层的修改不触发 Layout 和 Paint:
- 拥有 3D 变换(
transform: translateZ(0)或translate3d) - 使用了
will-change: transform / opacity - 使用了
opacity动画且值小于 1 - 使用了
filter动画 - 是
<video>、<canvas>、<iframe>元素 - 有 CSS 动画或 Web Animations API 动画
flowchart LR
subgraph 主文档层
A[普通 DOM 元素 回流\/重绘影响整层]
end
subgraph 独立合成层1
B["transform: translateZ(0)" GPU 独立合成]
end
subgraph 独立合成层2
C[will-change: opacity GPU 独立合成]
end
A --> D[GPU 合成器]
B --> D
C --> D
D --> E[屏幕输出]
style A fill:#fde8e8,stroke:#c0392b
style B fill:#e8f5e9,stroke:#1a7a3a
style C fill:#e8f5e9,stroke:#1a7a3a
style D fill:#f3e8ff,stroke:#7b3fbc,stroke-width:2px
合成层的修改走的是 GPU,不经过主线程的 Layout 和 Paint。这就是为什么 transform 和 opacity 动画极其流畅的原因。
虚拟 DOM 减少回流
React 的虚拟 DOM 本质上是批量修改策略的一种实现。它把多次 DOM 操作合并成一次:
虚拟 DOM 不一定就会比直接操作 DOM 更快。
它的优势在于我们不需要去手动的去批量操作,而是让框架自动帮你把多次变更合并成一次回流。但是在极端性能场景下,精心手写的 DOM 操作仍然比虚拟 DOM 更快,但维护成本高得多。
面试追问
追问 1:读取 offsetWidth 为什么会触发回流?如何避免?
浏览器为了保证返回的布局信息是最新的,会在读取 offsetWidth、scrollTop、getComputedStyle 等属性时,立即执行挂起的布局计算。如果在此前有未处理的样式变更,就会强制立即回流。
避免方法是在修改样式之前一次性读取所有需要的布局信息,然后批量修改样式,不要读写交替。现代框架(React、Vue)通过虚拟 DOM 自动避免了这个问题。
追问 2: display: none 和 visibility: hidden 在渲染上有什么区别?
display: none:元素从渲染树中移除,不参与布局,不占用空间。切换时会触发回流。visibility: hidden:元素仍在渲染树中,参与布局,占用空间,只是不可见。切换时只触发重绘。
对于频繁切换可见性的元素(如下拉菜单),用 visibility 性能更好。
追问 3:下面代码会触发几次回流?
const el = document.getElementById('test');
el.style.width = '100px';
el.style.height = '200px';
el.style.margin = '10px';
el.style.padding = '5px';
1 次。现代浏览器有样式批处理队列机制:连续修改样式时,浏览器会将变更加入队列,在当前 JavaScript 执行栈清空后统一刷新一次。但如果在修改之间读取了 offsetWidth 等布局属性,队列会被强制刷新,每读一次就多一次回流。
追问 4:CSS contain 属性如何影响回流范围?
contain: layout 告诉浏览器该元素的布局变化不会影响外部元素,浏览器可以将回流的范围限制在该元素内部,而不是整棵渲染树。
.card {
contain: layout style paint;
}
这对列表、卡片等独立组件特别有效。contain: strict 等价于 contain: size layout style paint,限制最严格。
追问 5:如何用 Chrome DevTools 分析回流和重绘?
打开 DevTools → More tools → Rendering,勾选 Paint flashing 可以看到重绘区域以绿色高亮显示。在 Performance 面板录制时,Layout 和 Paint 事件会显示在火焰图中,能精确看到回流的耗时和调用栈。
Performance Monitor 右上角可以实时显示 Layout/sec 和 Style/sec 计数,帮助我们快速判断页面是否存在频繁回流。