Appearance
浏览器渲染管线:从字节到已呈现的一帧
返回客户端渲染专题 | 运行时与帧调度 | 内核渲染细节 | 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、CSSOM | renderer main thread | DOM 不完整,样式迟到 |
| 样式 | DOM、CSSOM、环境、伪状态 | computed style | Blink main thread | 样式未更新、选择器成本高 |
| 布局 | 样式、约束、固有尺寸 | fragment 几何 | Blink main thread | 位置跳动、强制同步布局、CLS |
| 绘制记录 | fragment、视觉属性 | display item / paint chunk | Blink main thread | repaint 面积过大、内容不见 |
| 栅格 | 绘制记录、tile 优先级 | bitmap 或 GPU tile | raster worker、GPU process | 模糊、棋盘格、滚动露白 |
| 合成与呈现 | layer、surface、输入、vsync | compositor frame | compositor 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 结束以合并更新。但紧接着读取 offsetWidth、getBoundingClientRect() 或某些 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 boundsBlink 示例:LayoutObject 和 fragment
在 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/height 或 aspect-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,但不是同一阶段。
| 改动 | 至少需要重跑 | 可能止步的位置 | 不能从属性名直接保证的事 |
|---|---|---|---|
文本内容、width、display | style -> layout -> paint | 通常需要栅格和合成 | 实际失效范围 |
color、background | style -> paint | 新 paint record/tile 后合成 | 是否命中缓存或隔离表面 |
transform、opacity 动画 | style/动画采样 -> property update | 有时合成器可独立推进 | 是否真的可 compositor-only |
| scroll offset | scroll 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 thread | JS、DOM/CSSOM、style、layout、paint record、部分输入 | 长任务、同步 layout、重 selector/layout | 点击迟响应、动画不更新、长帧 |
| Compositor thread | 合成属性更新、scroll/部分动画、frame 构建 | 等 tile、复杂依赖、提交拥塞 | 滚动/变换不连续 |
| Raster workers | tile 栅格、图片/文字绘制工作 | CPU 饱和、raster 队列、内存回收 | 露白、清晰度延迟 |
| GPU process / driver | 纹理上传、draw、render pass、提交 | GPU 忙、显存不足、driver queue | GPU 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 中验证的证据。
资料与时效边界
- Web 平台的事件、DOM、CSS 和动画语义以 WHATWG HTML、DOM 与 CSS 规范为准(访问于 2026-07-29)。
- Chromium/Blink 的帧生命周期与合成实现参考 Life of a Frame 和 cc compositor overview(访问于 2026-07-29)。
- 性能证据的采集方式参考 Chrome DevTools Performance(访问于 2026-07-29)。
本章将 Chromium/Blink 作为解释主例,而非把其内部对象名视为标准。LayoutNG、paint property trees、Viz、分层和调度启发式都会随 Chromium 版本与运行平台变化;做源码级判断时,应以目标版本的源代码、trace 和上述文档为准。