我们每天写 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 改变文档结构,浏览器不得不停下解析、执行脚本,再继续。这正是 defer 和 async 存在的意义:
<!-- 阻塞解析:不推荐放在 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 中充满了相对单位(%、em、vw)和相互依赖(父元素宽度影响子元素),这是一个计算量不小的过程。
布局的英文有两个词:初次计算叫 Layout,因某些改动而重新计算叫 Reflow(重排)。重排是性能大敌,因为它常常是”牵一发而动全身”的:改动一个元素的宽度,可能导致它的兄弟、子孙、甚至整个文档重新排布。
会触发重排的典型操作:
- 改变元素的几何属性:
width、height、padding、margin、border。 - 改变字体大小、增删 DOM 节点。
- 读取某些”强制同步布局”的属性(见下文)。
第四步:绘制(Paint)——决定画什么↗
布局算出了”盒子在哪里”,绘制阶段则决定”每个盒子长什么样”:颜色、背景、边框、阴影、文字……浏览器会生成一系列绘制指令,类似:
在 (0,0)-(320,48) 填充背景色 #3D6DA6
在 (16,12) 绘制文本 "Akira"
在 (0,47)-(320,48) 画一条边框线
只改变”外观”而不改变”几何”的属性——比如 color、background-color、box-shadow——会跳过布局,直接触发重绘(Repaint)。重绘比重排便宜,但也不是免费的。
第五步:合成(Composite)——最后一步魔法↗
现代浏览器不会把整个页面当成一整张图去画。它会把页面拆成若干图层(Layer),分别栅格化,再由合成器(Compositor)把这些图层”贴”到一起显示。
这一步是性能优化的黄金地带。因为某些属性的变化,可以只在合成阶段处理,完全跳过布局和绘制:
transformopacity
这意味着,用 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/offsetWidth、scrollTop、getComputedStyle()、getBoundingClientRect() 等。记住一个原则:把读和写分开,不要在循环里读写交替。
小结↗
回到最初的问题——一段 HTML 是怎么变成像素的?现在可以给出一条清晰的链路:
HTML/CSS 文本
→ 解析(DOM + CSSOM)
→ 样式(渲染树)
→ 布局(几何)
→ 绘制(外观指令)
→ 合成(图层 → 屏幕像素)
而前端性能优化,本质上就是想办法让改动尽可能停留在流水线靠后的阶段:
- 能用合成解决的(
transform/opacity),就不要惊动布局。 - 必须改布局的,就批量改,避免强制同步布局。
- 首屏关键路径上的 CSS 和 JS,越小越好。
理解了这条流水线,后面聊事件循环、聊 Core Web Vitals,就都有了共同的地基。