浏览器的渲染管线、回流与渲染(重绘)

浏览器的渲染管线、回流与渲染(重绘)通俗入门到进阶

搞清楚“浏览器是怎么把 HTML/CSS/JS 变成屏幕上的像素”的全过程,弄明白“回流(Reflow)”、“重绘(Repaint)”、“合成(Composite)”这些关键词到底指什么,以及如何写出更流畅的页面与动画。

1. 一个直观比喻

  • 把浏览器想象成一本杂志排版系统:
    • 作者把文章(HTML)、样式规范(CSS)、脚本(JS)交给排版员(浏览器主线程)。
    • 排版员先理解文章结构(DOM),再理解样式(CSSOM),合并成“要如何呈现每个元素”的清单(渲染树)。
    • 决定每一段文字、图片的大小和位置(布局/回流)。
    • 把内容涂上颜色、边框、阴影(绘制/重绘)。
    • 最后把这些页面层层叠好(合成),送上印刷机(GPU)输出为像素。

2. 全流程总览:从源码到像素(Critical Rendering Path)

下面是现代浏览器把页面绘制出来的大致流程(简化版):

网络请求/缓存
   ↓
解析 HTML → 构建 DOM
   ↓                   解析 CSS → 构建 CSSOM
   └─────────────── 合并(样式计算:Style Recalc)
                         ↓
                     渲染树(Render Tree)
                         ↓
                 布局(Layout / 回流 Reflow)
                         ↓
                 分层(Layerization)与分块
                         ↓
                    绘制(Paint / Raster)
                         ↓
                    合成(Composite)
                         ↓
                   屏幕显示(GPU)
  • DOM(Document Object Model):HTML 的结构化表示(节点树)。
  • CSSOM(CSS Object Model):CSS 的结构化表示(规则树)。
  • 渲染树(Render Tree):DOM 中可见节点 + 计算后的样式(不包含 display: none),用于后续排版与绘制。
  • 布局(Layout/回流 Reflow):计算每个盒子的尺寸与位置。
  • 绘制(Paint):把颜色、文字、边框、阴影等画到位图(Raster)。
  • 合成(Composite):把多个图层用 GPU 叠在一起,得到最终帧。

JavaScript 的位置:

  • JS 可以修改 DOM 或样式,从而触发样式计算、布局或绘制。
  • 同步脚本可能阻塞 HTML 解析(除非使用 defer / async)。
  • 尽量减少在不合适的时机做大量 DOM 操作,避免卡顿。

3. 回流(Reflow / Layout)是什么

  • 定义:回流是浏览器计算或重新计算页面元素几何信息(尺寸、位置、行内排版等)的过程。它也常被叫做“布局(Layout)”或“重排(Reflow/Relayout)”。
  • 为什么开销大:因为一个元素的位置/尺寸变化,可能会影响同级、父级、后续元素的排版,浏览器需要重新计算受影响的那一部分(甚至整棵文档),成本较高。
  • 回流的范围:
    • 增量回流:只对受影响的子树/区域重新布局(现代浏览器会尽量优化)。
    • 全局回流:某些操作会导致全页面重新布局(如改变根字体、窗口尺寸)。
  • 常见触发回流的操作:
    • DOM 结构变化:添加/删除节点、移动节点。
    • 改变布局相关属性:width/height/margin/padding/top/left/border/position/display/font-size/line-height/...
    • 更换字体、改变文字内容(影响行高、换行)。
    • 窗口尺寸变化、滚动容器尺寸改变。
    • 读取布局信息并迫使浏览器“立刻给出最新值”(强制同步布局),例如在样式变更后马上读取以下属性/方法:
      • offsetWidth/offsetHeight/offsetTop/offsetLeft
      • clientWidth/clientHeight
      • scrollWidth/scrollHeight/scrollTop/scrollLeft
      • getBoundingClientRect()
      • getComputedStyle(...) 的某些尺寸值
    • 注意:读取本身并不总是回流,只有在浏览器有“未结算的布局变更”时,这些读取会强制它先做一次布局,以返回准确数值,这就叫“强制同步回流(Forced Reflow/Layout Thrashing)”。

4. 重绘(Repaint)与合成(Composite)

  • 重绘(Repaint / Paint):

    • 改变了元素的“外观”但没有改变其几何尺寸和排版关系时,浏览器只需重新绘制受影响区域。例如:
      • colorbackground-colorvisibilitybox-shadowoutline(大多情况)等。
    • 重绘比回流成本低,但仍然需要 CPU/GPU 绘制操作。
  • 合成(Composite):

    • 一些变化可以由“合成线程/GPU”直接处理,而无需回流和重绘。例如只改变:
      • transform(如 translate/scale/rotate
      • opacity
    • 这些属性可以在“合成阶段”完成,仅更新图层的变换或透明度,非常高效,适合做动画。
    • 注意:不是所有 transform/opacity 都100%不触发重绘,具体实现细节与是否独立图层有关。通常搭配 will-change: transform, opacity 或让元素成为合成层,才能稳定享受合成路径的性能。

小结(经验规则,非绝对):

  • 影响布局 → 回流(最贵)
  • 不影响布局但需重新画像素 → 重绘(中等)
  • 仅图层合成变化 → 合成(最省)

5. “渲染”一词的两个层面

  • 广义“渲染”:指整个从解析到最终呈现的流程(渲染管线)。
  • 狭义“渲染/绘制”:指把样式画到位图的阶段(Paint),以及合成阶段(Composite)。

在日常交流中,“渲染”有时指“绘制”,有时指“整个过程”,语境不同要留意。

6. 性能坑与优化实践

6.1 避免读写交错导致的强制同步布局(Layout Thrashing)

坏示例:在循环里交替“读布局”再“写样式”,会逼迫浏览器每次都先回流,性能很差。

const items = document.querySelectorAll('.item');
for (const el of items) {
  // 写:改变样式(可能令布局脏)
  el.style.width = (el.offsetWidth + 10) + 'px'; // 这里读了 offsetWidth,且在写之后读取,强制同步布局
}

改进:先批量“读”,再批量“写”,或者把写聚合到 requestAnimationFrame

const items = [...document.querySelectorAll('.item')];

// 批量读(不会让中间产生写)
const widths = items.map(el => el.offsetWidth);

// 批量写
requestAnimationFrame(() => {
  items.forEach((el, i) => {
    el.style.width = (widths[i] + 10) + 'px';
  });
});

更进一步:通过切换 class 一次性应用多条样式,减少逐条 style.xxx 的写入。

6.2 动画尽量用合成友好属性

  • 优先用 transformopacity 做动画,避免频繁改 top/left/width/height
  • 适当使用:
    • will-change: transform, opacity; 提示浏览器提前为该元素分配合成层和资源(慎用,别全站到处加)。
    • 或在动画开始前设置一次 transform: translateZ(0) 促成独立图层(旧技巧,注意内存和层过多的副作用)。

示例:使用 transform 做位移动画

.box {
  transition: transform 300ms ease;
}
.box.move {
  transform: translateX(200px);
}

6.3 少量且成批地修改 DOM

  • 批量插入:使用 DocumentFragment 或一次性 innerHTML 拼装好再塞进去。
  • 多次样式改动:尽量合并到一个类或使用 el.style.cssText 批量写入。
  • 避免频繁触发回流的属性修改,必要时在 off-screen 容器修改(如 position: absolute; left:-9999px;),完成后再放回。

6.4 巧用 display 与 visibility

  • display: none:元素不参与布局,切换到 block 等会引发回流。
  • visibility: hidden:元素保留占位但不可见,通常触发重绘,不引发布局变化。
  • 根据场景选择:如果只是临时隐藏,且频繁切换,可优先考虑 visibilityopacity(配合合成层)。

6.5 避免在热点路径读取会触发布局的属性

以下属性/方法在布局脏时读取会触发强制同步布局:

  • offsetWidth/offsetHeight/offsetTop/offsetLeft
  • clientWidth/clientHeight
  • scrollWidth/scrollHeight/scrollTop/scrollLeft
  • getBoundingClientRect()
  • getComputedStyle(el).width/height/...

建议:

  • 在事件回调中(如 scrollresize)做节流或防抖。
  • 先集中读取,再集中写入。
  • 把昂贵操作移到 requestAnimationFramerequestIdleCallback

6.6 CSS Containment 与 content-visibility

  • contain:限制元素对子树之外的影响,缩小回流/重绘范围。例如:

    .card {
      contain: layout paint size style;
    }
    
  • content-visibility: auto:允许浏览器跳过不可见内容的布局与绘制,显著优化长列表初次渲染。

    .list-item {
      content-visibility: auto;
      contain-intrinsic-size: 200px; /* 预估尺寸,避免布局跳动 */
    }
    

6.7 长列表优化

  • 虚拟列表/窗口化(只渲染视口附近的元素)。
  • IntersectionObserver 延迟加载图片与模块。
  • 图片用合适尺寸与 loading="lazy",CSS 使用 aspect-ratio 预留空间,减少布局抖动(CLS)。

6.8 减少渲染阻塞与首屏关键路径

  • CSS 阻塞首次渲染:尽量合并关键 CSS,避免多层 @import,关键样式内联。
  • JS 阻塞解析:给非关键脚本加 deferasync
  • 资源提示:<link rel="preload">preconnectdns-prefetch 预热关键资源。
  • 字体优化:font-display: swap 减少 FOIT;子集化字体减小体积;预加载关键字体。

6.9 把重活移出主线程

  • 使用 Web Worker 处理计算密集任务,避免阻塞渲染。
  • Canvas 绘制用 OffscreenCanvas 放入 Worker(受支持的环境)。
  • 大量图片处理、数据解析优先在 Worker。

6.10 合成层数量要适度

  • 过多的图层会增加内存占用与合成开销。
  • 只为要做动画或滚动的关键元素创建合成层,谨慎使用 will-change/translateZ(0)
  • 在 DevTools 的 Layers/Performance 面板查看层数量与合成开销。

7. 每帧预算与浏览器帧循环

  • 60 FPS → 每帧约 16.67ms;120 Hz 屏幕 → 每帧约 8.33ms。
  • 一帧内需要完成:事件处理 → JS 执行 → 样式计算 → 布局 → 绘制 → 合成。
  • 动画与滚动流畅的关键,是让每帧内的总工作量别超过预算。把易抖动的工作放到合成阶段(transform/opacity)最稳妥。

requestAnimationFrame 用法示例(在下一帧绘制前批量提交写操作):

let pending = false;
const updates = [];

function scheduleUpdate(fn) {
  updates.push(fn);
  if (!pending) {
    pending = true;
    requestAnimationFrame(() => {
      // 批量写入,避免多次交错
      updates.forEach(u => u());
      updates.length = 0;
      pending = false;
    });
  }
}

// 使用
scheduleUpdate(() => {
  box.style.transform = 'translateX(100px)';
});

8. 开发者工具:测量胜于猜测

  • Chrome DevTools → Performance 面板:
    • 录制一段交互,查看 Main/Compositor 线程,定位 Style/Layout/Paint/Composite 的耗时。
  • Rendering 面板(Rendering 标签):
    • 启用 Paint flashing(绘制闪烁)、Layout Shift Regions、FPS meter。
  • Lighthouse / Web Vitals:
    • 关注 LCP、CLS、FID(或 INP)、TTFB 等指标。
  • Layers 面板:
    • 查看合成层数量、边界。
  • 结论:先量后调,避免拍脑袋优化。

9. 常见问题与误区

  • Q:display: none 和 visibility: hidden 有何差别?

    • A:display: none 会让元素不参与布局,切换回来会触发回流。visibility: hidden 保留占位,通常只会重绘。
  • Q:为什么改 top/left 做动画容易卡?

    • A:它会改变布局或至少影响绘制,浏览器每帧需要做更多工作。用 transform: translate(...) 可以走合成路径,代价更低。
  • Q:读取 getBoundingClientRect() 一定会回流吗?

    • A:只有在浏览器存在未处理的样式/布局变更时,读取会触发“强制同步布局”。如果布局是干净的,读取不一定会回流。但在高频场景中依然要避免读写交错。
  • Q:用 will-change 有没有副作用?

    • A:会申请更多内存和资源,层多了反而拖慢合成。只在真正需要动画的元素上、动画前后短时间使用。
  • Q:使用 3D 变换 translateZ(0) 就能提升性能吗?

    • A:它可能创建新图层,有助于走合成,但滥用会增加内存与合成成本。现代浏览器对图层管理更智能,优先用 will-change,并结合工具验证效果。
  • Q:Grid/Flex 会不会更“费性能”?

    • A:它们的布局算法复杂度可能更高,但实际性能取决于使用规模与场景。可读性与开发效率优先,遇到瓶颈再结合 DevTools 定位问题点。
  • Q:哪些属性会触发布局/绘制/合成?

    • A(经验):布局类(width/height/margin/padding/top/left/font-size/line-height/display/…)→ 回流;外观类(color/background/shadow/outline)→ 重绘;合成友好(transform/opacity)→ 合成。以实际浏览器实现为准,参考 MDN 与 CSS Triggers。

10. 小结记忆卡

  • 渲染管线:DOM/CSSOM → 样式计算 → 渲染树 → 布局(回流) → 绘制(重绘) → 合成。
  • 代价排序(通常):回流 > 重绘 > 合成。
  • 动画优先选:transform、opacity。
  • 避免读写交错,集中读、集中写,利用 rAF。
  • 使用 containment 与 content-visibility 优化大页面。
  • 工具先量后调:Performance、Rendering、Layers、Lighthouse。

11. 进一步学习资源