浏览器渲染流水线:从 HTML 到屏幕像素

一段 HTML 文本是怎么变成屏幕上一个个亮起来的像素的?理解这条流水线,是理解前端性能的第一步。

我们每天写 HTML、CSS 和 JavaScript,但很少停下来想一个问题:浏览器拿到这些文本之后,究竟做了什么,才让屏幕上亮起了一个个像素?

这条从”文本”到”像素”的路径,通常被称为渲染流水线(Rendering Pipeline)。理解它,几乎是理解所有前端性能问题的前提——因为绝大多数卡顿、掉帧、白屏,都能对应到流水线上的某个环节。

流水线的五个阶段

现代浏览器的渲染大致可以拆成五步。可以把它想象成一条工厂流水线,原料是文本,成品是像素:

阶段输入输出做的事
解析HTML / CSS 文本DOM 树、CSSOM 树把字符流变成结构化的树
样式DOM + CSSOM渲染树(Render Tree)计算每个可见节点的最终样式
布局渲染树盒模型几何信息算出每个元素的位置和大小
绘制布局结果绘制指令列表生成”在哪里画什么”的指令
合成绘制指令屏幕像素分层、栅格化并合成到屏幕

下面逐个拆开看。

第一步:解析——从字符到树

浏览器拿到的 HTML 只是一长串字符。解析器(Parser)会把它变成 DOM 树:一个由节点构成的、能被 JavaScript 操作的对象模型。

与此同时,CSS 会被解析成 CSSOM 树。这里有个容易被忽视的关键点:

CSS 默认会阻塞渲染。浏览器必须构建完 CSSOM,才能进入下一步;否则页面可能先以无样式状态闪现,再突然套上样式(也就是 FOUC,无样式内容闪烁)。

这也是为什么我们把 <link rel="stylesheet"> 放在 <head> 里,并且要尽量减小首屏 CSS 的体积——它直接卡在关键路径上。

JavaScript 则更”霸道”:默认情况下,<script> 会同时阻塞解析。因为脚本可能调用 document.write 改变文档结构,浏览器不得不停下解析、执行脚本,再继续。这正是 deferasync 存在的意义:

<!-- 阻塞解析:不推荐放在 head 中不带任何属性 -->
<script src="app.js"></script>

<!-- async:下载不阻塞解析,下载完立即执行(顺序不保证) -->
<script async src="analytics.js"></script>

<!-- defer:下载不阻塞解析,等 DOM 解析完再按顺序执行 -->
<script defer src="app.js"></script>

对大多数应用脚本,defer 是更安全的默认选择:它既不阻塞解析,又能保证执行顺序。

第二步:样式计算——每个节点长什么样

有了 DOM 和 CSSOM,浏览器会把它们合并成渲染树。注意,渲染树只包含可见节点:

  • display: none 的元素不在渲染树里(它不占据空间)。
  • visibility: hidden 的元素渲染树里(它虽然看不见,但仍占据布局空间)。

这个区别在性能上是有意义的:切换 display 会触发后续的布局,而它俩的语义也完全不同。

样式计算这一步,浏览器要为每个节点解析所有匹配的规则、处理层叠与继承,最终得出一份”计算后样式”(Computed Style)。选择器越复杂、规则越多,这一步就越慢——不过对绝大多数页面,样式计算都不是瓶颈,真正贵的是接下来两步。

第三步:布局(Layout / Reflow)——算几何

布局阶段回答一个问题:每个元素到底在哪里、有多大?

浏览器从渲染树的根节点开始,递归计算每个盒子的位置和尺寸。因为 CSS 中充满了相对单位(%emvw)和相互依赖(父元素宽度影响子元素),这是一个计算量不小的过程。

布局的英文有两个词:初次计算叫 Layout,因某些改动而重新计算叫 Reflow(重排)。重排是性能大敌,因为它常常是”牵一发而动全身”的:改动一个元素的宽度,可能导致它的兄弟、子孙、甚至整个文档重新排布。

会触发重排的典型操作:

  1. 改变元素的几何属性:widthheightpaddingmarginborder
  2. 改变字体大小、增删 DOM 节点。
  3. 读取某些”强制同步布局”的属性(见下文)。

第四步:绘制(Paint)——决定画什么

布局算出了”盒子在哪里”,绘制阶段则决定”每个盒子长什么样”:颜色、背景、边框、阴影、文字……浏览器会生成一系列绘制指令,类似:

在 (0,0)-(320,48) 填充背景色 #3D6DA6
在 (16,12) 绘制文本 "Akira"
在 (0,47)-(320,48) 画一条边框线

只改变”外观”而不改变”几何”的属性——比如 colorbackground-colorbox-shadow——会跳过布局,直接触发重绘(Repaint)。重绘比重排便宜,但也不是免费的。

第五步:合成(Composite)——最后一步魔法

现代浏览器不会把整个页面当成一整张图去画。它会把页面拆成若干图层(Layer),分别栅格化,再由合成器(Compositor)把这些图层”贴”到一起显示。

这一步是性能优化的黄金地带。因为某些属性的变化,可以只在合成阶段处理,完全跳过布局和绘制

  • transform
  • opacity

这意味着,用 transform: translateX() 做位移动画,远比用 left 做位移动画流畅——后者每一帧都要重排、重绘,前者只需要合成器移动一个已经栅格化好的图层。

/* 慢:每帧触发 Layout + Paint */
.box { left: 0; transition: left 0.3s; }
.box:hover { left: 100px; }

/* 快:只触发 Composite,可以跑满 60fps */
.box { transform: translateX(0); transition: transform 0.3s; }
.box:hover { transform: translateX(100px); }

一个必须记住的陷阱:强制同步布局

浏览器很聪明,它会把多次样式改动”攒起来”,在合适的时机一次性重排(这叫布局的批处理)。但如果你在改动之后、立刻读取一个依赖布局的属性,浏览器为了给你一个准确的值,不得不立即重排——这就是”强制同步布局”(Forced Synchronous Layout),也叫 Layout Thrashing。

// 反例:读写交替,每次读取都强制一次重排
for (const box of boxes) {
  const width = container.offsetWidth; // 读(强制重排)
  box.style.width = width + 'px';      // 写(让布局失效)
}

// 正解:先批量读,再批量写
const width = container.offsetWidth;   // 只读一次
for (const box of boxes) {
  box.style.width = width + 'px';      // 只写
}

会触发强制同步布局的”读操作”包括 offsetTop/offsetWidthscrollTopgetComputedStyle()getBoundingClientRect() 等。记住一个原则:把读和写分开,不要在循环里读写交替。

小结

回到最初的问题——一段 HTML 是怎么变成像素的?现在可以给出一条清晰的链路:

HTML/CSS 文本
  → 解析(DOM + CSSOM)
  → 样式(渲染树)
  → 布局(几何)
  → 绘制(外观指令)
  → 合成(图层 → 屏幕像素)

而前端性能优化,本质上就是想办法让改动尽可能停留在流水线靠后的阶段

  • 能用合成解决的(transform/opacity),就不要惊动布局。
  • 必须改布局的,就批量改,避免强制同步布局。
  • 首屏关键路径上的 CSS 和 JS,越小越好。

理解了这条流水线,后面聊事件循环、聊 Core Web Vitals,就都有了共同的地基。


More like this