Skip to content

浏览器渲染原理:从 HTML 解析到合成层 ​

性能优化如果只背结论,很容易变成玄学:"动画要用 transform"、"不要频繁读取 offsetHeight"、"加 will-change 会更快"。这些结论背后都对应浏览器渲染流水线中的具体环节。

理解一次渲染从请求到像素的完整路径,才能判断某个优化是否真的有效,以及它的代价是什么。

从导航到首帧 ​

一次页面加载大致经历以下阶段:

  1. 导航与响应:浏览器发起请求,收到 HTML 响应流。
  2. 解析 HTML:主线程解析 HTML 构建 DOM;遇到 CSS 和同步脚本会阻塞后续解析。
  3. 预加载扫描器:主线程解析的同时,扫描器提前发现图片、脚本、字体等资源并发起请求。
  4. 构建 CSSOM:解析样式表,计算层叠与继承。
  5. 生成渲染树:DOM 与 CSSOM 结合,排除 display: none、head 等不参与渲染的节点。
  6. 布局(Layout / Reflow):计算每个盒子的几何位置与尺寸。
  7. 绘制(Paint):生成绘制指令,填充像素到图层。
  8. 合成(Composite):把图层交给合成线程,按正确顺序合成到屏幕。

其中第 2 到第 5 步受 JavaScript 执行影响。CSS 是阻塞渲染的资源,同步脚本是阻塞解析的资源,这是"首屏白屏"最常见的两类原因。

主线程与合成线程 ​

渲染主线程负责 JavaScript、样式计算、布局和绘制。当主线程执行长任务时,这些工作都无法进行,页面就会卡顿。

合成线程是独立的。对于 transform、opacity 这类可以在合成阶段处理的属性,浏览器可以跳过布局和绘制,直接由合成线程移动图层。这就是"动画用 transform 更流畅"的原因。

css
.card {
  transition: transform 200ms ease, opacity 200ms ease;
}

.card:hover {
  transform: translateY(-4px);
  opacity: 0.9;
}

如果改成 top、left、height,每一帧都要重新布局和绘制,主线程压力会显著增加:

css
/* 不推荐用于高频动画 */
.card:hover {
  top: -4px;
  height: 104%;
}

重排与重绘 ​

  • 重排(Reflow):元素的几何属性变化,需要重新计算布局。成本与受影响节点数量相关。
  • 重绘(Repaint):外观变化但布局不变,例如颜色、背景、阴影。
  • 只合成:transform、opacity 等属性,跳过布局与绘制。

触发重排的典型操作:

  • 修改宽高、内外边距、字体大小、定位。
  • 增删 DOM 节点。
  • 读取 offsetTop、offsetWidth、scrollTop、getBoundingClientRect() 等布局属性。
  • 修改 class 导致布局变化。

注意最后一条:读取布局属性本身会强制浏览器同步计算布局。如果在一个循环里反复"写样式、读布局",就会造成 layout thrashing。

js
// 错误:每次循环都强制同步布局
for (const item of items) {
  item.style.width = `${container.offsetWidth / items.length}px`
}

// 正确:先批量读取,再批量写入
const width = container.offsetWidth / items.length
for (const item of items) {
  item.style.width = `${width}px`
}

把动画交给合成线程 ​

高频动画优先使用 transform 和 opacity。如果需要更精细的控制,使用 Web Animations API:

js
element.animate(
  [
    { transform: 'translateY(8px)', opacity: 0 },
    { transform: 'translateY(0)', opacity: 1 }
  ],
  { duration: 240, easing: 'cubic-bezier(0.2, 0.8, 0.2, 1)', fill: 'both' }
)

requestAnimationFrame 适合在每帧渲染前更新状态,但不要在回调里做重计算:

js
let start

function tick(now) {
  start ??= now
  const progress = Math.min(1, (now - start) / 240)
  element.style.transform = `translateY(${8 * (1 - progress)}px)`
  if (progress < 1) requestAnimationFrame(tick)
}

requestAnimationFrame(tick)

will-change 与图层提升 ​

will-change 会提示浏览器提前为元素准备图层,减少动画开始时的抖动:

css
.animated-panel {
  will-change: transform;
}

但它不是越多越好。每个图层都要占用显存,过多图层会让合成成本上升,移动端尤其明显。原则是:

  • 只给确实会频繁动画的元素加。
  • 动画结束后考虑移除 will-change。
  • 不要给几十个列表项同时加。

另外,创建图层会改变包含块。给 position: fixed 元素的祖先加 transform,可能导致 fixed 定位失效,这是线上布局 bug 的常见来源。

用 contain 和 content-visibility 减少工作 ​

contain 告诉浏览器某个子树与外部布局无关,可以限制重排范围:

css
.widget {
  contain: layout paint style;
}

content-visibility: auto 让屏幕外的大块内容跳过渲染,适合长文档和复杂列表:

css
.section {
  content-visibility: auto;
  contain-intrinsic-size: auto 600px;
}

contain-intrinsic-size 用于提供占位尺寸,避免滚动条跳动。注意它不适用于需要精确滚动定位的场景,使用前要测量实际收益。

如何定位渲染问题 ​

Chrome DevTools 中几个直接相关的面板:

  • Performance:录制交互,查看主线程任务、布局与绘制耗时,找到长任务。
  • Rendering:开启 Paint flashing 观察重绘区域,Layer borders 查看合成层。
  • Layers:查看图层数量、尺寸和内存占用。
  • CSS Overview 与 Coverage:发现冗余样式和未使用 CSS。

怀疑布局抖动时,在 Performance 面板中搜索 "Layout",如果出现大量短促的强制布局记录,基本可以确认是读写交错导致的。

常见坑 ​

  1. 只优化动画,不优化首屏。首屏受资源加载、CSS 阻塞和 JavaScript 执行影响,与动画优化是两条链路。
  2. 盲目加 will-change。图层爆炸反而更卡。
  3. 在滚动回调里读取布局属性。滚动事件高频触发,读写交错会持续触发布局。
  4. 忽略字体加载导致的布局偏移。自定义字体应设置 font-display: swap 并匹配后备字体的度量。
  5. 用 display: none 切换大块内容。频繁切换会反复触发布局,考虑 content-visibility 或虚拟化。

小结 ​

渲染流水线的核心是"哪些工作可以跳过"。改动几何属性需要布局,改动外观需要绘制,改动合成属性可以交给合成线程。优化的本质,是让高频操作尽量落在流水线的后段,并减少主线程的长任务。

基于 VitePress 构建 · 持续更新中