§ 2.4 · Section

渲染管线

The Rendering Pipeline

浏览器拿到的是一堆纯文本——HTML 标签、CSS 规则,一个像素都没有。可几百毫秒后,屏幕上出现了完整的页面。中间那段黑盒,是一条固定顺序、四道工序的流水线:解析 → 布局 → 绘制 → 合成。搞懂这条流水线,你就能回答几乎所有界面性能问题——包括为什么改一个 CSS 属性会让整页重算,而改另一个几乎不花钱。

生活场景
🏗️ 从一张设计图到一栋房子

第一步 · 读图纸:施工队拿到建筑图和材料清单,先把它们整理成自己看得懂的清单和结构。
第二步 · 放线定位:在工地上拉线,量出每一面墙的准确位置、每个房间的实际尺寸——这一步最费功夫,因为一面墙挪动,后面所有房间都得重新量。
第三步 · 刷漆装饰:位置定了,开始刷墙、贴砖、装灯。改颜色不用重新拉线,重刷就行。
第四步 · 拍效果图:把各个楼层、各个图层的照片按顺序叠起来,合成一张最终的宣传图交给客户。

第一步 · 解析:把文本变成树

浏览器收到的 HTML 是一长串字符。它做的第一件事是词法分析:一个字符一个字符地扫过去,识别出"这是一个开标签"、"这是属性名"、"这是文本内容"。识别完再按嵌套关系拼成一棵树,这棵树叫 DOM 树(Document Object Model,文档对象模型)。

<body>
  <div class="card">
    <h2>标题</h2>
    <p>正文</p>
  </div>
</body>

        ↓ 解析成

body
└── div.card
    ├── h2
    │   └── "标题"
    └── p
        └── "正文"

CSS 走的是同一套流程,产物叫 CSSOM 树(CSS Object Model)。它记录的是所有样式规则,以及它们的继承与优先级关系。为什么 CSS 也要建成树?因为样式会继承——给 body 设了字号,里面的段落默认跟着变。只有树形结构才能高效地表达这种"上级影响下级"。

两棵树建好后,浏览器把它们合成为渲染树(Render Tree,Chrome 里叫 Layout Tree)。这一步做的是"给每个要显示的 DOM 节点,配上它最终生效的样式"。注意两个细节:

Analogy · 两份文件合成一份施工单

DOM 树像房子的结构图:几层楼、每层几间房、房间怎么嵌套。
CSSOM 树装修规范手册:所有卧室刷米白、所有窗框用铝合金、二楼的规则覆盖全局规则。
渲染树就是工头把两份文件对照一遍,写出的逐间施工单:201 房间,米白墙、铝合金窗、木地板。
display:none 的房间,工头直接从施工单里划掉了——不施工,也不给它留位置。

第二步 · 布局:算清每个元素在哪、多大

布局(Layout,在 Firefox 的术语体系里叫 Reflow)要回答的问题非常具体:渲染树里每一个元素,它的左上角坐标是多少,宽高各是多少。输入是"样式描述",输出是一堆精确的几何盒子。

这一步之所以昂贵,是因为元素之间高度耦合。一个段落多了一行字,它下面的所有兄弟元素都要往下挪;它的父容器高度要增加,父容器的父容器也可能跟着变;如果这是个 flex 布局,同一行的兄弟元素宽度还要重新分配。一处改动,可能引发整棵子树甚至整个文档的重新计算——这就是布局的成本所在。

布局要处理的事情包括:解析 %emvw 这类相对单位换算成实际像素;根据可用宽度决定文字在哪里折行;处理 flex / grid 的空间分配;计算外边距合并;确定绝对定位元素相对哪个祖先定位。任何"位置和尺寸相关"的信息,都在这一步产生。

第三步 · 绘制:往图层上刷颜色

位置定了,接下来是绘制(Paint)。这一步把每个元素变成一系列具体的绘图指令:"在 (120, 340) 到 (400, 380) 这个矩形里填 #f2ede4"、"在这里画一条 1px 的 #e2dccf 边框"、"用 16px Lora 在这个位置写这几个字"。

绘制有严格的顺序,大致是:背景色 → 背景图 → 边框 → 子元素 → 文字 → 轮廓。同时它还要处理层叠关系——z-index 高的后画,所以盖在上面。文字渲染在这一步尤其复杂:要找到字形轮廓、按当前字号栅格化、做亚像素抗锯齿。

关键点是:绘制不一定画在同一张画布上。浏览器会把页面拆成若干 图层(Layer),分别绘制。这是理解性能的枢纽,下面单独讲。

第四步 · 合成:把图层叠成最终画面

合成(Composite)是最后一步:把所有已经画好的图层,按正确的顺序、位置、透明度、变换矩阵叠在一起,得到一张完整的位图,交给屏幕显示。

这一步的特殊之处在于——它基本上是 GPU 的活,而且极其擅长。GPU 天生就是为"把一堆贴图按矩阵变换叠起来"设计的,这类操作可以大规模并行,快到几乎不计成本。这意味着:如果一个变化只需要重新合成,而不需要重新布局和绘制,它就几乎是免费的。这句话是所有前端动画性能优化的总纲。

1. 解析 Parse · 一次性成本

HTML → DOM 树,CSS → CSSOM 树,合并成渲染树。首屏加载时最重,之后只在 DOM 变动时局部重来。

2. 布局 Layout · 最贵

算出每个元素的坐标和尺寸。牵一发动全身,代价随元素数量增长。

3. 绘制 Paint · 中等

生成绘图指令并栅格化到图层。改颜色、阴影走这一步,成本比布局低。

4. 合成 Composite · 最便宜

GPU 把图层叠起来。只走这一步的变化(transform / opacity)几乎不花钱。

回流与重绘:一定要分清的两个词

这条流水线有个重要特性:它是单向的,而且一旦从某一步重来,后面的所有步骤都必须跟着重来。这就是"回流"和"重绘"两个概念的由来。

触发回流的典型动作,值得单独列出来记住:

布局抖动:一个非常常见的性能陷阱

浏览器其实很聪明,它会把连续的 DOM 修改攒起来批处理,等一帧结束时统一算一次布局。但如果你在修改之后立刻读取布局属性,这个优化就被打破了——浏览器必须马上算完才能回答你。改一次、读一次、再改一次、再读一次,就形成了 Layout Thrashing(布局抖动)。

// ✗ 坏写法:读写交替,每轮循环都强制一次布局
const items = document.querySelectorAll('.item');
for (const el of items) {
  // 读(强制布局) → 写(作废布局) → 下一轮再读……
  el.style.width = el.offsetWidth + 10 + 'px';
}

// ✓ 好写法:先全部读完,再全部写
const widths = [];
for (const el of items) {
  widths.push(el.offsetWidth);        // 阶段一:只读
}
items.forEach((el, i) => {
  el.style.width = widths[i] + 10 + 'px';  // 阶段二:只写
});

规则很简单:把读操作和写操作分成两批,不要交替。100 次读写交替可能触发 100 次布局,分批后只触发 1 次。

图层:为什么页面不是画在一张纸上

图层(Layer / Compositing Layer)是把页面拆成多张独立画布的机制。默认情况下大部分内容画在同一个图层里,但满足某些条件的元素会被"提升"到自己的独立图层。

提升的好处非常直接:这个元素动的时候,其他图层完全不需要重画,只要 GPU 重新叠一次。就像做动画片,背景画一次不动,只把角色画在透明赛璐珞片上逐帧移动——不用每帧重画整个背景。

常见的图层提升条件包括:使用了 3D 变换(transform: translateZ(0)translate3d);声明了 will-change: transform / opacityposition: fixed 元素;videocanvas 元素;应用了 CSS 滤镜;opacity 参与动画的元素。

但图层不是免费的:每个图层都要在显存里存一份自己的位图。一个全屏图层在高清屏上可能占几十 MB。图层过多会导致显存暴涨、合成本身变慢,在低端设备上甚至直接崩溃。这个反模式有个名字叫"图层爆炸"——比如给列表里的每一项都加上 will-change。正确做法是只给真正在动的那几个元素提层,动画结束后还应该撤掉。

哪些 CSS 属性触发哪一步

这张表是本节最实用的产出。改属性之前先看一眼它落在哪一列,性能预期就有数了。

CSS 属性布局绘制合成成本
width / height
top / left(定位)
margin / padding
font-size / font-family
display
border-width
color
background-color
box-shadow中(模糊半径大时较贵)
border-radius
visibility
transform
opacity
filter低(但 GPU 负担随强度上升)

看懂这张表,一条实践准则就自然浮现了——做动画只用 transformopacity

/* ✗ 每帧都触发布局 → 绘制 → 合成 */
.box { transition: left .3s, width .3s; }
.box:hover { left: 100px; width: 320px; }

/* ✓ 每帧只触发合成,GPU 直接搞定 */
.box { transition: transform .3s, opacity .3s; }
.box:hover { transform: translateX(100px) scale(1.2); opacity: .85; }

顺带说一个反直觉的点:transform: translateX(100px)left: 100px 视觉结果几乎一样,但代价差了一个数量级。因为 left 是在告诉布局引擎"我在文档流里的位置变了,你重新算",而 transform 是在告诉合成器"我在文档流里没动,你只是把我这一层贴图挪个位置显示"。前者改的是事实,后者改的是呈现

一帧的完整时间线

把上面的知识按时间轴排一遍,一帧里实际发生的事是这样的:先执行 JS(事件回调、定时器、requestAnimationFrame);然后处理 CSS 动画和过渡的当前值;接着如果有几何变动就执行布局;然后绘制发生变化的图层;最后合成上屏。如果这一整套在 16.7 毫秒内跑完,用户看到流畅画面;跑不完,这一帧就被丢掉,用户看到卡顿。下一节会专门算这笔时间账。

Recap · 收束

一帧画面走四道工序:解析(HTML→DOM,CSS→CSSOM,合成渲染树)、布局(算出每个元素在哪多大)、绘制(生成绘图指令刷到图层上)、合成(GPU 把图层叠成最终画面)。这条流水线是单向的,从哪一步重来,后面的步骤就必须全部跟着重来——所以改几何属性触发回流(三步全走,最贵),改颜色只触发重绘(跳过布局),改 transform / opacity 只触发合成(几乎免费)。记住两条最实用的:做动画只用 transform 和 opacity,以及批量 DOM 操作要先读完再写,别读写交替

☰ 主页
学海无涯 · 界面篇 · § 2.4