Skip to content

浏览器渲染管线:从字节到已呈现的一帧

返回客户端渲染专题 | 运行时与帧调度 | 内核渲染细节 | Flutter 对照 | 故障定位与练习

浏览器渲染不是把 HTML "画出来"。它是一条不断重算的因果链:网络和脚本改变文档状态,主线程把状态转换为可布局、可绘制的中间表示,栅格线程把绘制指令变成像素块,合成器在恰当的时间把这些块提交给显示系统。初始导航只是这条链的第一次完整运行;滚动、输入、动画和异步响应通常只重跑其中一段。

本文用 Blink/Chromium 的术语解释具体实现。HTML、CSS、DOM、CSSOM、布局和绘制是 Web 平台概念;LayoutObject、fragment、paint property tree、cc layer、Viz 和 compositor frame 是 Chromium/Blink 某一代实现的名字。 不要把后者误读为规范要求。

先放骨架

text
URL / 输入 / 网络响应 / 定时器 / 脚本
                 |
                 v
  导航与资源调度 -----> HTML 字节、CSS、字体、图片、JS
                 |              |
                 v              v
       HTML tokenizer/tree builder    CSS parser
                 |              |
                 +---- DOM --- CSSOM -+
                            |
                            v
           style invalidation -> style calculation
                            |
                            v
   layout tree / LayoutObject -> fragments + geometry
                            |
                            v
  paint property trees -> paint chunks / display items
                            |
                            v
       raster tasks -> tiles / GPU textures
                            |
                            v
 compositor frame -> display compositor -> scanout/presentation
                            |
                            v
                    用户看到一帧,继续输入

每个箭头都是一个边界。性能诊断和正确性诊断都应先问:哪个中间表示没有变、变错了,还是变了但没有及时交给下游?

阶段主要输入主要输出典型负责人(Chromium)用户侧症状
导航与发现URL、响应头、HTML token文档流与资源请求browser process、network service、renderer loader白屏、资源长期 pending、重定向循环
解析HTML/CSS 字节DOM、CSSOMrenderer main threadDOM 不完整,样式迟到
样式DOM、CSSOM、环境、伪状态computed styleBlink main thread样式未更新、选择器成本高
布局样式、约束、固有尺寸fragment 几何Blink main thread位置跳动、强制同步布局、CLS
绘制记录fragment、视觉属性display item / paint chunkBlink main threadrepaint 面积过大、内容不见
栅格绘制记录、tile 优先级bitmap 或 GPU tileraster worker、GPU process模糊、棋盘格、滚动露白
合成与呈现layer、surface、输入、vsynccompositor framecompositor thread、Viz、显示系统掉帧、滚动卡顿、输入延迟

0. 首次可见之前:导航、流和资源发现

一次顶层导航从浏览器进程接收 URL 开始。它负责权限、站点隔离、网络请求和 renderer 进程选择;renderer 拿到文档响应流后,HTML 解析器不必等到整个响应下载完毕。字节先按响应编码解码成字符,再被 tokenizer 切成 tag、文本和属性 token,tree builder 按 HTML 的容错建树规则构造 DOM。

text
document request
  -> response headers (MIME, charset, CSP, cache policy)
  -> response body stream
     -> decoder -> tokenizer -> tree builder -> DOM append
                                       |          |
                                       |          +-> script may mutate DOM
                                       +-> preload scanner discovers URL

为什么“发现资源”属于渲染路径

普通解析器会在走到 <link rel="stylesheet"><img><script> 时发起或委托发起请求。Blink 还有并行的 preload scanner:它在主 HTML parser 还未完整建树时,从 token 流推测可下载资源。这不是语义解析的替代品,而是用网络并行度换首帧时间。

首帧常被以下依赖阻塞:

  • 外部 stylesheet:在 CSSOM 尚未可用时,浏览器不能可靠计算依赖该样式的元素;它通常也会阻塞后续普通脚本执行。
  • 普通 <script src>:HTML parser 遇到它会暂停,先下载并执行脚本,因为脚本可调用 document.write() 改写后续输入。
  • defer 脚本:下载可并行,执行推迟到解析完成、DOMContentLoaded 之前。
  • async 脚本:下载和执行时机独立于文档顺序,适合无顺序依赖的脚本。
  • 字体、图片、iframe:通常不阻塞 DOM 建立,却会影响文字测量、布局高度、LCP 或后续绘制。

这几个规则描述的是平台可观察行为;“preload scanner”“resource priority”具体如何排序则是浏览器实现策略。不能从 DevTools 看到一个优先级就推断它是 Web 标准保证。

首次渲染不是一个固定时刻

浏览器可以在 DOM 还未完整、图片还未下载完时提交第一帧。条件是当前已知的 DOM、CSSOM 和资源足以形成可绘制内容。于是会出现三类不同问题:

现象常见因果链应检查的证据
长时间白屏文档响应慢,或 render-blocking CSS/脚本未完成Network waterfall、response headers、关键请求优先级
先无样式后跳变CSS 晚到或关键 CSS 未内联首帧截图、CSS request 时序、coverage
首屏出现后仍不断移动图片/字体/异步内容尺寸未知Layout Shift 事件、元素尺寸、font loading 状态

1. 解析的两个事实表:DOM 与 CSSOM

DOM:HTML 的语义树,不是屏幕树

DOM 节点保存文档结构、属性和文本,并承载脚本 API。它包含不显示的节点,例如 display:none 元素、<head>、注释(实现中还可能有内部节点);一个 DOM 节点也可能生成多个视觉片段。相反,伪元素 ::before::after 可参与视觉结果却不作为普通 DOM 节点存在。

HTML parser 的 tree builder 不是简单的 XML 栈机:错误嵌套、table 相关规则、template、脚本重入都会影响最终 DOM。这是为什么排查“浏览器为什么和我写的标签不一样”时应看 Elements 面板里的已解析 DOM,而非只看源文件。

CSSOM:规则、层叠和可计算样式的输入

CSS parser 将 stylesheet 构造成规则结构;CSSOM API 让脚本能读取、插入和修改规则。CSS 的价值不在于“每个节点有一份 CSS”,而在于选择器、层叠(cascade)、继承、初始值、媒体查询和自定义属性共同决定某个元素的 computed style。

text
author/user-agent/user styles
       + origin / !important / cascade layer / specificity / order
       + inheritance / custom property substitution / media environment
       -> element pseudo-state aware computed style

规范把这称作样式解析与层叠。Blink 会维护 selector matching、style sharing、style invalidation 等数据结构来避免每次都扫描全树;具体缓存规则会演进,不能把它们当作页面逻辑的前提。

2. 样式无效化与样式计算:改变不等于全页重算

一次 DOM mutation、class 切换、:hover 状态变化、viewport 改变或 media query 翻转,首先需要回答影响范围:哪些元素的 computed style 可能改变?这一步是 style invalidation。随后才对被标记的节点及必要后代做 style recalculation

text
button.classList.add('active')
  -> selector dependency says .active / ancestor-dependent selectors may match differently
  -> mark affected element(s) dirty
  -> recalc style
  -> compare old/new style: geometry changed? visual-only changed? compositable change?

选择器决定范围。.active 往往局部;.panel:has(.active)、祖先状态选择器或修改继承属性可能向上或向下扩大影响。自定义属性也有类似传播:改根节点上的 --theme-color,所有使用它且未覆写的后代都可能需要重新计算。

读写交错为何会卡

脚本写入 element.style.width 后,浏览器通常延迟样式和布局,等待本轮 JavaScript 结束以合并更新。但紧接着读取 offsetWidthgetBoundingClientRect() 或某些 computed style,读取必须返回当前值,浏览器就可能被迫在脚本中途同步完成 style/layout:

js
for (const row of rows) {
  row.style.width = `${row.offsetWidth + 1}px`; // 写 -> 读 -> 强制计算
}

这不是“读取 API 本身慢”,而是读操作截断了批处理边界。先集中读取,再集中写入,或将视觉更新放到下一帧,能缩小这类强制同步工作的频率和范围。

3. 布局:从样式到几何,不只是盒模型

样式计算回答“应采用何种规则”;布局回答“在当前可用空间下,每个可见部分的坐标和尺寸是多少”。规范层有 block、inline、flex、grid、table、position、fragmentation 等格式化上下文。布局输入还包括 viewport、滚动容器、包含块、writing mode、字体度量、图像固有大小与约束。

text
computed style + available inline/block size + intrinsic size
       -> formatting-context algorithms
       -> box/line/fragment geometry
       -> overflow, scrollable area, hit-test bounds

在 Blink 的实现演进中,DOM 节点可对应 LayoutObject,但不是一一对应:不参与布局的节点没有可见布局对象,伪元素可以有布局对象,inline 内容和跨列/跨页内容可分裂成多个 fragment。现代 Blink 的 LayoutNG 以 fragment 表示一次布局结果,尤其适合行内排版、分页和多列等“一个逻辑对象有多个物理片段”的情况。

因此以下说法都过于粗糙:

  • “DOM 树等于 render tree”:DOM 不是纯视觉树,fragment 更不是一节点一盒。
  • “布局只算宽高”:行框、基线、换行、百分比解析、min/max 约束、滚动溢出和定位包含块都在这一阶段交织。
  • “改颜色不会触发布局”:通常如此,但最终取决于计算后的属性变化、资源状态和具体实现;应以 trace/Rendering 面板证据判断。

布局错误的可观察链

text
图片未声明尺寸
  -> 初始布局只能使用占位或未知固有尺寸
  -> 图片解码后获得真实尺寸
  -> layout invalidation
  -> 后方内容移动
  -> 用户点击目标偏移 / CLS 增加

同样的链也适用于 web font:fallback 字体先给出一组字形宽度,目标字体加载后度量不同,文本换行和块高改变。解决点不是“让浏览器少 layout”,而是让首轮布局就拥有稳定约束,例如图片的 width/heightaspect-ratio、适当的字体度量覆盖策略。

4. 绘制记录:几何如何成为可复放的视觉操作

布局的结果还不是像素。绘制阶段根据 fragment 的背景、边框、文字、阴影、裁剪、滤镜和层叠顺序生成绘制操作。Web 标准描述 paint order、stacking context、clip、opacity、transform 等最终视觉语义;Blink 需要将它们编码为可增量更新、可交给合成器的记录。

Chromium/Blink 中常会遇到这组实现概念:

text
fragments
  -> paint property trees
       transform tree: 坐标系与变换
       clip tree: 可见区域裁剪
       effect tree: opacity/filter/blend 等效果
       scroll tree: 滚动节点及其关系
  -> display items (绘制命令)
  -> paint chunks (共享属性状态的一组命令)

这不是要求 Web 作者手工“创建四棵树”。它们是浏览器用来表达属性继承和空间关系的内部模型。将 transform、clip、effect、scroll 分开,可以让后续阶段只改变一棵属性树的一部分,例如滚动时移动内容而不重画全部文字。

Paint 不是 GPU 合成

“paint”通常指记录或执行 2D 绘制操作,例如填充背景、绘制 glyph、border、图片。compositing 是按变换、裁剪和 z-order 将已准备的表面组合。两者可使用 GPU,但不是同一阶段。

改动至少需要重跑可能止步的位置不能从属性名直接保证的事
文本内容、widthdisplaystyle -> layout -> paint通常需要栅格和合成实际失效范围
colorbackgroundstyle -> paint新 paint record/tile 后合成是否命中缓存或隔离表面
transformopacity 动画style/动画采样 -> property update有时合成器可独立推进是否真的可 compositor-only
scroll offsetscroll tree/property update常可由 compositor 推进主线程监听器、复杂效果是否把它拉回主线程

"只改 transform 就一定 60 fps" 是常见误导。该属性常较易在合成器侧更新,但大图层栅格、GPU 内存压力、main-thread animation、同步布局读取或过多 surface 都可以让帧预算失守。

5. 栅格化:将记录转换为可采样的像素

display item 或其下游绘制记录在大多数情况下不会整页直接变成一张位图。Chromium 会按 viewport、滚动预测和内存预算把可绘制内容分成 tile,创建 raster task。raster worker 使用 Skia 等绘制库在 CPU 或 GPU 路径上把操作栅格化为位图,再上传或保存在 GPU 可采样的资源中。

text
paint chunk / layer content
  -> choose tile grid and priority (visible first, near-viewport next)
  -> raster task on worker
  -> bitmap/GPU resource
  -> tile ready for compositor sampling

这里“主线程绘制”和“栅格线程绘制”容易混淆:主线程主要产生绘制记录与更新属性;raster worker 消耗记录来产出像素。实际线程与进程划分会随平台、GPU 后端和安全隔离改变,故应把它理解为职责而非固定线程名。

滚动很快时若新进入 viewport 的 tile 尚未完成,用户可能短暂看到低分辨率内容、空白格或 checkerboarding。它不是 DOM 少了节点,而是下游像素供给没有赶上 compositor 的可见区域需求。

6. 合成、提交与显示:谁决定这一帧能否赶上 vsync

合成器接收可合成的图层、property tree 变化和 ready tile,生成一个 compositor frame:其中有 quads、render pass、surface 引用、damage 信息与同步令牌等。Chromium 的 Viz display compositor 将来自 renderer 的 frame 与浏览器 UI、其他 surface 组合,向平台图形系统提交;显示系统再在合适的垂直同步附近 scan out 或 presentation。

text
renderer compositor
  -> CompositorFrame (surfaces, render passes, quads, resources)
  -> Viz display compositor
  -> GPU command submission / platform compositor
  -> display presentation

不要把“请求了 requestAnimationFrame”和“屏幕已经出现新帧”混为一谈。requestAnimationFrame 回调是在为下一次绘制准备更新;后面仍可能有主线程、栅格、GPU、合成队列和显示节奏的延迟。用户只在 presentation 后真正可见。

主线程、合成器、栅格、GPU 的分工

责任面通常负责被什么阻塞时最明显可观测后果
Main threadJS、DOM/CSSOM、style、layout、paint record、部分输入长任务、同步 layout、重 selector/layout点击迟响应、动画不更新、长帧
Compositor thread合成属性更新、scroll/部分动画、frame 构建等 tile、复杂依赖、提交拥塞滚动/变换不连续
Raster workerstile 栅格、图片/文字绘制工作CPU 饱和、raster 队列、内存回收露白、清晰度延迟
GPU process / driver纹理上传、draw、render pass、提交GPU 忙、显存不足、driver queueGPU frame time 高、帧错过 presentation

这张表是职责模型,不意味着每个平台严格按四个可见线程运行。诊断时应以 Chrome tracing/Performance panel 中实际轨道为准。

7. 增量更新:日常交互的真正主路径

初始加载建立所有表示;之后的交互应尽量缩小到受影响的边界。下面四条链比“re-render”这个笼统词更可操作。

A. 输入改变文字

text
key event
  -> event loop task -> JS/framework state
  -> DOM text mutation
  -> style usually unchanged, but inline layout dirty
  -> line breaking / fragment geometry changes
  -> paint record -> raster damaged tile -> composite -> present

可能症状:输入框落后、光标和字符不同步。先看 main thread 是否被长任务占用;若布局昂贵,再看文本是否触发大量祖先/兄弟的重排,而不是先归因给 GPU。

B. Hover 只变透明度

text
pointer move -> :hover state -> style recalc
  -> effect-tree opacity update
  -> compositor can sample existing texture with new opacity
  -> composite -> present

这可避开 layout 和重栅格,但前提是已有内容资源可用、动画/样式路径能下放、没有其他主线程依赖。若 pointer listener 中做重计算,仍会先输在 main thread。

C. 滚动

text
input event -> hit test scroll node
  -> compositor scroll offset update (fast path)
  -> reveal existing tiles / request new tiles
  -> composite -> present

若页面注册非被动的 touchmove/wheel listener,或滚动效果依赖主线程,浏览器可能必须等待 main thread 判断是否 preventDefault(),快速路径被破坏。滚动卡顿也可能是可见区 tile 跟不上,而非 JavaScript 慢;二者的 trace 证据不同。

D. 服务端确认到达

text
network callback -> task -> merge acknowledged state
  -> UI marks "saved" -> normal incremental rendering chain

这说明 客户端专题的第四边界:画面上的“已保存”是本地状态描述,只有回调确实代表服务端持久化/事务提交时,才可把它解释为共享事实。渲染管线可以让它更快可见,不能替业务协议提供确认语义。

8. 用中间表示定位失败

需要把现象转成一次可复现、可验证的排查时,可直接进入故障定位与练习,按状态、更新计算、宿主呈现和共享事实四个边界收集证据。

看到的现象最先验证的表示/边界常见根因不应直接得出的结论
Elements 中 DOM 已变,画面没变computed style、layout、paint invalidation被覆盖样式、元素无可见 fragment、clip/opacity、未提交帧“框架没有更新 DOM”
样式变了但位置不对layout fragment 的几何和包含块百分比约束、flex/grid 算法、字体度量、绝对定位参照“CSS parser 坏了”
点击后很久才反馈main thread task 与 input delay长 JS、同步 style/layout、排队的事件“GPU 渲染慢”
快速滚动出现空白tile priority 与 raster 完成情况栅格来不及、图片解码、显存压力“列表没有数据”
动画偶尔跳帧从 animation sampling 到 presentation 的整条帧链main/raster/GPU 任一阶段超预算“加 will-change 必然解决”
页面显示成功但刷新后不存在本地状态到共享事实边界optimistic update 未回滚、确认语义错误“渲染成功等于保存成功”

9. 学习时应保留的两套地图

平台语义地图

text
HTML -> DOM
CSS -> CSSOM -> cascade/computed style
computed style + formatting context -> layout
layout + paint order -> paint
visual result + input model -> interactive document

它帮助判断跨浏览器应当一致的行为,应该优先查 HTML/CSS/DOM 等标准与 Web Platform Tests。

Chromium 实现地图

text
parser / preload scanner
  -> Blink DOM + style invalidation + LayoutNG fragments
  -> paint property trees + display items / paint chunks
  -> compositor layers / tiles / raster tasks
  -> CompositorFrame -> Viz -> GPU/display

它帮助读 trace、理解 Chrome DevTools 和定位 Chromium 性能问题。实现名会改,关键是保留每一层的输入、表示、失效条件和下游责任。

10. 下一步:把管线放回运行时与跨端对照

本章把“状态已经需要新画面”之后的宿主流水线拆开了。还缺两个会改变实际帧时序的部分:

  • 浏览器运行时与帧调度:输入如何进入事件循环,task、microtask、requestAnimationFrame、lifecycle 怎样决定何时触发此处的更新。
  • 浏览器内核渲染细节:选择器失效、LayoutNG fragment、property tree、layerization、scroll 和 trace 字段如何进一步验证。
  • Flutter 对照:Flutter 的 Widget/Element/RenderObject/Layer/Scene 为什么能映射到这条因果链,又为何没有 DOM/CSSOM 这两个中间表示。
  • 故障定位与练习:将用户现象拆回本章的中间表示,并用浏览器证据验证判断。

练习的验收标准不是背出阶段名,而是能任选一次“点击、样式变化、滚动或网络确认”,画出它经历的中间表示,并指出一个可在 DevTools 中验证的证据。

资料与时效边界

本章将 Chromium/Blink 作为解释主例,而非把其内部对象名视为标准。LayoutNG、paint property trees、Viz、分层和调度启发式都会随 Chromium 版本与运行平台变化;做源码级判断时,应以目标版本的源代码、trace 和上述文档为准。

学习规划与研究资料库