【每日一面】浏览器渲染机制:重绘与回流

_

基础问答

问: 请简述浏览器的渲染过程,并解释什么是重绘和回流。

答: 浏览器拿到 HTML 后,渲染过程大致是这样的:

HTML 解析
DOM 树
CSS 解析
CSSOM 树
渲染树 Render Tree
布局 Layout
绘制 Paint
合成 Composite
屏幕显示

渲染树只包含可见节点display: none 的元素不会出现在渲染树中,但 visibility: hidden 的会)。

回流(Reflow / Layout) :当渲染树中节点的几何信息(位置、大小)发生变化时,浏览器需要重新计算布局。这个过程叫回流,也叫重排。

触发回流的操作:添加/删除 DOM 元素、修改元素尺寸(width/height/padding/margin)、修改元素位置(top/left)、窗口大小变化、读取 offsetWidth/scrollTop 等强制布局的属性。

重绘(Repaint) :当元素的外观属性(颜色、背景色、阴影等)发生变化,但不影响布局时,浏览器只需要重新绘制像素,不需要重新计算位置。这个过程叫重绘。

触发重绘的操作:修改 colorbackground-colorbox-shadowvisibilityoutline 等视觉属性。

总之一句话,回流一定导致重绘,但重绘不一定引发回流。 因为回流的代价比重绘高得多。

扩展延伸

渲染阶段

理解重绘和回流,关键是要我们理解浏览器的渲染流水线。现代浏览器的渲染过程可以拆成五个阶段:

阶段作用输入输出
HTML 解析构建 DOM 树HTML 字节流DOM 节点树
CSS 解析构建 CSSOM 树CSS 文本样式规则树
布局 Layout计算几何信息渲染树盒模型位置/尺寸
绘制 Paint填充像素布局结果位图
合成 Composite合并图层多个位图图层最终屏幕画面

回流在 Layout 阶段:浏览器重新计算元素的位置和大小。

重绘在 Paint 阶段:浏览器重新生成受影响区域的位图。

合成在 Composite 阶段:如果页面有多个合成层(如 CSS 3D 变换、will-change),GPU 会将它们合成到一起,在这个过程中是不触发 Layout 和 Paint 的。

回流的代价

回流的开销取决于改动的范围:

局部回流单个元素尺寸变化
重算受影响节点布局
开销小
全局回流 body 字体大小变化
重算整棵渲染树
开销极大
强制同步布局 读取 offsetHeight
阻塞主线程
开销致命

最坏的情况是强制同步布局(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/CSS 操作
影响几何信息? 位置/尺寸/结构
回流 Reflow
影响视觉外观? 颜色/背景/阴影
重绘 Repaint
只影响合成层? transform/opacity
仅合成 Composite - 最优
无渲染操作

常见的回流触发场景:

  • 增删 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/rotate
  • opacity
  • filter

这三个属性走的是合成器(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 的事件循环不同,渲染发生在每次事件循环迭代的末尾,而不是宏任务/微任务之间。

事件循环开始
执行宏任务
清空微任务队列
浏览器需要渲染? 约 16.6ms 间隔
执行 rAF 回调
布局 Layout
绘制 Paint
合成 Composite
直接进入下一轮

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[&#34;transform: translateZ(0)&#34; 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。这就是为什么 transformopacity 动画极其流畅的原因。

虚拟 DOM 减少回流

React 的虚拟 DOM 本质上是批量修改策略的一种实现。它把多次 DOM 操作合并成一次:

状态变更
生成新虚拟 DOM
Diff 算法 对比新旧虚拟 DOM
计算最小变更集
一次性应用到真实 DOM 一次回流
浏览器渲染

虚拟 DOM 不一定就会比直接操作 DOM 更快。

它的优势在于我们不需要去手动的去批量操作,而是让框架自动帮你把多次变更合并成一次回流。但是在极端性能场景下,精心手写的 DOM 操作仍然比虚拟 DOM 更快,但维护成本高得多。

面试追问

追问 1:读取 offsetWidth 为什么会触发回流?如何避免?

浏览器为了保证返回的布局信息是最新的,会在读取 offsetWidthscrollTopgetComputedStyle 等属性时,立即执行挂起的布局计算。如果在此前有未处理的样式变更,就会强制立即回流。

避免方法是在修改样式之前一次性读取所有需要的布局信息,然后批量修改样式,不要读写交替。现代框架(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/secStyle/sec 计数,帮助我们快速判断页面是否存在频繁回流。

【每日一面】事件循环机制 2026-09-04

评论区