§ 2.4 · Section

渲染管线

The Rendering Pipeline

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

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

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

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

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

"词法分析"这个词听着玄,换成大白话就是"把一长串字念断句"。浏览器拿到的就是一条没有停顿的字符流,它得先分出哪几个字是一个词、哪一段是一句话。相当于餐厅前台听到一句"两碗牛肉面中辣不要香菜一份小菜",得先在脑子里断成"两碗牛肉面 / 中辣 / 不要香菜 / 一份小菜"四个条目,才能往厨房下单。断句这一步做完了,后面才有得算。

"DOM 树"这个说法也可以翻译成人话:就是一张标明了"谁装在谁里面"的目录。好比一份仓库清单:3 号货架里有 5 个纸箱,第 2 个纸箱里有 12 个小盒,每个小盒里有若干零件。HTML 的嵌套关系就是这种"层层装进去"的关系,而树形结构是描述这种关系最省事的写法。

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

        ↓ 解析成

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

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

"继承"这个机制打个比方最好懂:相当于公司里的规章制度。总部发了一条"全体员工统一穿深色制服",各部门默认照办;某个部门另发一条"我们部门穿白衬衫",那这个部门就按自己的来,但它下面的小组仍然跟着部门走。CSS 的继承和优先级,本质就是这套"上级定调、下级可覆盖"的规则——而要高效地回答"这个人到底该穿什么",你必须知道他在组织架构的哪个位置,所以必须建成树。

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

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

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

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

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

换成大白话,布局干的活儿就是拿着尺子在工地上放线:把图纸上写的"客厅比卧室宽一点"这种说法,落实成"客厅从东墙 0 米量到 4.2 米,卧室从 4.2 米到 7.5 米"这样的确切数字。图纸上说的是关系,布局要输出的是坐标。这一步之前,浏览器只知道"这个元素占满一行",不知道这一行到底是 390 像素还是 1440 像素。

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

"高度耦合"翻译成人话就是"一个人挪窝,后面全排都要跟着挪"。想象一下银行窗口前排队的一排人:中间插进来一个人,他后面每一个人都得往后退一步;如果这一排还有个总人数上限,超出的人还得挪到隔壁队伍去。这就是为什么改一个元素的高度可能引发整页重算——不是浏览器笨,是队伍本身就是连着的。

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

这里面最费功夫的其实是文字折行。因为要决定"这一行能塞下几个字",浏览器必须逐个字量出宽度、累加、判断是否超出容器,超出了还要回退到上一个可断处。相当于往一个纸箱里码书:你得一本一本量厚度、边码边试,塞不下才知道该换箱。而中英文混排、连字符、标点不能出现在行首这些规则,会让这道题进一步变复杂。这也是为什么"改字号"这种看起来很小的动作,在渲染管线里属于最贵的那一档。

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

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

注意一个容易被跳过的细节:绘制这一步产出的还不是像素,而是一串"该怎么画"的指令清单。说白了就是先写好一张施工便签,再照着便签动手——便签上写着"这块刷米白""这里画一条细线""这儿写这几个字"。把"想清楚怎么画"和"真的画上去"分成两步,是为了让后面能跳过重复劳动:便签没变的地方,可以直接复用上一次的成果。

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

这个顺序其实就是装修的施工顺序:先刷墙漆,再贴墙纸,再装踢脚线,最后挂画和摆家具。谁后动手谁在上面——这就是 z-index 的全部含义。你不会先挂画再刷墙,浏览器也不会。

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

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

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

"变换矩阵"这个词最容易让人打退堂鼓,其实就是一张写着"往右挪多少、放大几倍、转多少度"的小卡片。合成器拿着这张卡片,把已经画好的那层贴图整体挪一挪、缩一缩,就完事了——它压根不去看这层贴图里画的是什么。好比在办公室里把一张已经打印好的海报换个位置贴,你不需要重新打印,只要撕下来挪个地方。

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

为什么这一步这么便宜?因为它干的活儿没有任何需要"想"的地方。几百万个像素,每一个都做同一道乘法,互不影响,可以同时开工。相当于一条传送带上几百个工位同时贴同一款标签——不需要商量,不需要等待,一趟过。而前面的布局那一步,本质上是一道"这个人挪了后面都要跟着挪"的连锁题,只能一步一步来。这就是"能不能并行"带来的量级差距。

1. 解析 Parse · 一次性成本

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

2. 布局 Layout · 最贵

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

3. 绘制 Paint · 中等

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

4. 合成 Composite · 最便宜

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

Analogy · 餐厅从下单到上菜的四道工序

渲染管线这四步,跟一家餐厅从你开口点菜到菜端上桌的流程几乎一模一样。把它对上一遍,你就再也不会记混哪一步贵、哪一步便宜。

第一道 · 前台听单并写成菜单条(解析)。你说"两碗牛肉面中辣不要香菜",前台先把这句连成一串的话断成条目,再对照店里的规矩("中辣就是两勺辣油""不要香菜要在小票上标红")写成一张厨房看得懂的单子。HTML 就是你说的那句话,CSS 就是店里的规矩,最后那张单子就是渲染树。顺带说一句:如果店规还没从后厨拿过来,前台压根不敢下单——这就是"CSS 阻塞渲染"。

第二道 · 排灶台和出菜顺序(布局 · 最贵)。厨师长要决定哪口锅先用、哪道菜先下、每道菜占用多久。这一步之所以最费脑,是因为牵一发动全身——某桌加了一道汤,后面所有菜的出锅时间都得往后推,甚至要调换整个顺序。布局就是这一步:算清每个元素占多大位置、排在哪里,而任何一处变动都可能引发整片重排。

第三道 · 真正炒菜装盘(绘制 · 中等)。灶台排好了,开始下锅。这一步的成本比排灶台低——换个摆盘方式、多撒一撮葱花,不用重排整个灶台顺序,只要这一道菜重做一次即可。改颜色、改阴影、改圆角就属于这一档:位置没动,只是这一盘长得不一样了。

第四道 · 传菜员把盘子端上桌(合成 · 最便宜)。菜已经在盘子里了,传菜员做的事只是把盘子从窗口端到桌上、摆个位置、也许换个角度。他压根不看盘子里是什么。所以"把这盘菜往桌子中间挪一挪"这件事快到不计成本——这正是 transformopacity 的处境。

这个类比最有价值的地方,是它一次说清了"为什么从哪一步重来,后面都必须跟着重来"。灶台顺序变了,菜必须重炒,盘子必须重端;只是改个摆盘,灶台不用动,但菜得重装、盘子还得重端;只是挪个盘子位置,灶台和炒菜全都不用动。这就是回流(从第二道重来)、重绘(从第三道重来)、仅合成(只走第四道)三者的全部差别,也是所有前端性能优化的地基。

最后再补一个细节:为什么"读一下 offsetHeight 就会强制布局"?因为这相当于你在厨师长正准备把三张单子攒起来一起排灶台的时候,突然插一句"我这道菜几点能上?"——他为了给你一个准确答案,只能立刻把手头的活儿全排一遍。你问一次他排一次,你问一百次他排一百次,这就是布局抖动。

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

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

这两个词的中文翻译都有点误导,不妨记住它们的实际含义:回流 = "从排灶台那一步重做",重绘 = "从炒菜那一步重做"。回流一定包含重绘和合成(灶台变了,菜要重炒、盘子要重端);重绘一定包含合成,但可以跳过布局。越靠前的那一步被触发,要跟着重做的工序就越多,所以越贵。

触发回流的典型动作,值得单独列出来记住。判断的办法其实只有一句:问自己"这个改动会不会让别的元素也要挪窝"。会,就是回流;不会,最多是重绘。

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

浏览器其实很聪明,它会把连续的 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。正确做法是只给真正在动的那几个元素提层,动画结束后还应该撤掉。

"图层爆炸"的代价可以算得很具体。一个 1080p 的全屏图层,按每像素 4 字节算,就是 8 MB 显存;给一个 200 项的列表每项都提层,哪怕每项只占屏幕的十分之一,也是 200 × 0.8 MB = 160 MB。这好比装修时给每一面墙都单独砌一层隔断——每层都合理,加起来把屋子占满了。图层是一种"用显存换 CPU"的交易,交易本身没问题,但不能无限刷卡。正确的姿势是:只给真正在动的那一两个元素提层,动完就撤,跟用完东西放回原位是同一个道理。

哪些 CSS 属性触发哪一步

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

看表之前先记住一条口诀,能省掉八成的查表次数:凡是会让别人挪窝的(宽高、间距、字号),都要从排灶台重来;凡是只改自己长相的(颜色、阴影、圆角),从炒菜重来;只有 transformopacity 是只挪盘子。

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 是在告诉合成器"我在文档流里没动,你只是把我这一层贴图挪个位置显示"。前者改的是事实,后者改的是呈现

这个区别用排队的场景一秒说清:left 相当于你真的从队伍第 5 位挤到了第 2 位——后面所有人都得重新站位;transform 相当于你原地没动,只是身子往前探了探——队伍里没人受影响。观众看到的结果差不多,但一个惊动了全排,一个谁都没惊动。做动画就是要选那个"谁都不惊动"的做法。

这张表还有一个很实用的验证方法:不用靠记,直接打开浏览器的开发者工具当场看。DevTools 的 Rendering 面板里有个 Paint flashing 开关,打开后凡是这一帧被重绘的区域都会闪一下绿框。你改一个属性,看它闪的范围有多大,就知道这一下到底惊动了多少内容——只闪一小块,说明只是重绘甚至合成;整屏都在闪,说明刚才那个改动拖动了布局,把大片区域都牵连进来了。这好比装修验收时拿手电筒贴着墙照一遍:照得见的地方就是真正动过的地方,照不见的地方说明压根没人去碰。有了这个开关,前面那套"口诀 + 查表"就不再是死记硬背,而是一条随时能自己验证的路——记错了就当场试出来,比背错一条规则强得多。

一帧的完整时间线

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

16.7 毫秒是个什么概念?比你眨一次眼快六倍。眨眼大约需要 100 毫秒,而浏览器要在这 100 毫秒里完整地把上面那一整套流程跑六遍。这就是为什么"少做一步"在渲染里价值这么大——你省下的不是几毫秒,是六十分之一秒里那本来就不够用的额度。

再补一个实用推论:这条时间线是有固定顺序的,所以"什么时候改样式"和"改什么样式"一样重要。如果你在一帧的 JS 阶段末尾去读布局属性,前面攒的所有修改都会被迫立刻结算;如果你把所有读操作放在最前、所有写操作放在最后,同样的代码逻辑可以少触发几十次布局。这好比办公室里收发文件——集中在上午一次性收完、下午一次性发完,比一整天来回跑省下的时间要多得多。

三个真实场景:从现象倒推到管线的哪一步

学这条管线的唯一目的,是遇到问题时能直接定位。下面三个场景是实际工作里出现频率最高的,每一个都能顺着管线倒推出病根。

场景一 · "页面刚打开有一两秒白屏,然后内容一下全出来。"病根在第一步。渲染树必须等 CSSOM 建好才能生成,所以只要有一个巨大的 CSS 文件还没下载完,页面就什么都不敢显示。相当于前台拿到了你的口头点单,但店里的规矩手册还锁在办公室没取来——它不敢下单,只能让你干等着。解法是把首屏必需的关键样式内联进 HTML,其余的异步加载。

场景二 · "滑动列表时明显一顿一顿的。"病根大概率在第二步。滚动回调里如果读了 getBoundingClientRect() 之类的属性,每一帧都会强制结算一次布局;如果列表项还用了 margin 做动画,那更是每帧全排重排。这就是那个不停被插话的厨师长:单子还没攒够就被迫排了一次灶台,一秒钟被迫排六十次。解法是事件回调加节流、读写分批、动画换成 transform

场景三 · "只改了一个小按钮的颜色,整屏都在闪。"病根在第三步的范围失控。改颜色本身只触发重绘,但如果这个按钮和大半个页面共用同一个图层,那"重绘这一块"实际上变成了"重绘这一整层"。好比只想给一面墙补个漆点,结果整间屋子的墙都重刷了一遍。用 DevTools 的 Paint flashing 一开就能看到:如果只改一个按钮却看到整屏闪绿框,就是这个问题。解法是让频繁变化的元素独立成层。

三个场景对应三步,规律也就出来了:白屏问题往第一步查,卡顿问题往第二步查,闪烁与范围失控问题往第三步查,而只走第四步的东西基本不会出问题。这套倒推法比逐行注释代码高效得多。

Recap · 收束

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

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