深入学习浏览器从接收 HTML 到最终呈现像素信息的核心步骤的关键渲染路径 (Critical Rendering Path, CRP) 。
现代浏览器渲染引擎通过一个高度复杂、多阶段、多线程协同的流程,将网络数据高效地转化为用户屏幕上的像素。本阶段将首先关注渲染的准备工作:输入处理、模型构建与资源管理。
阶段一:准备阶段 (Preparation Phase)
这个阶段的主要目标是生成浏览器渲染所需的两个核心数据结构:DOM Tree和CSSOM Tree , 并将其合并。
1. DOM Tree 构建
请求方法 GET, MIME 类型 text/html 得到响应 或 document.write() , 主线程负责将接收到的 HTML 字节 (Bytes) 流转换成 DOM (Document Object Model) Tree。
整个过程是一个经典的状态机算法 , 涉及五个核心步骤 , 每一步都将数据从一种形态转换为下一种形态 , 直到生成最终的 DOM Tree。
1. 字节流到字符 (Decoding)
浏览器会依序检查 HTTP 响应头中的 Content-Type (如 charset=utf-8) 、HTML 文档内部 <meta charset="UTF-8"> 标签以确定 字符 (Characters) 编码 , 然后将网络接收到的原始字节流转换成实际的字符序列。
为了启动解析过程 , 浏览器必须首先确定文档的字符编码 , 将原始字节流转化为统一的 Unicode 字符流 (Code Points) 。 如果字符编码信息缺失或定位较晚 , 浏览器可能需要重新扫描或重新解释字节流 , 这会在分词启动前就引起性能开销 , 从而延迟整个关键渲染路径 (Critical Rendering Path, CRP) 的启动。
通过在 HTTP 响应头中指定字符编码 , 可以最大程度地确保浏览器无需猜测或二次扫描即可立即启动解析 , 从而加速整个关键渲染路径。
2. 字符到令牌 (Tokenization)
分词器 (Tokenizer) 将字符流根据 HTML 语法规则 , 分解成一个个独立的、有意义的原子单元——令牌 (Tokens) 。
令牌包括文档类型声明 (Doctype tokens) 、开始和结束标签标记 (Start and end tag tokens) 、属性、注释和字符数据
<!-- 输入字符流 -->
<div class="main">Hello</div>
<!-- 输出令牌 -->
StartTag Token (div, class="main") Character Token (Hello) EndTag Token (div)
分词器有一定的容错能力 , 如果遇到不规范的 HTML (例如缺少闭合标签 </div>) , 分词器会尝试根据预设的规则进行自修正 , 生成缺失的令牌。
3. 词法分析和树构建 (Lexing & Tree Construction)
树构造器 (Tree Constructor) 根据令牌的类型和顺序 , 创建 DOM 树中的 节点 (Nodes) 对象 , 并将它们按照正确的父子关系组织起来。
注意:节点不单指 HTML 元素节点 , 还包括文本节点 (Text Node) 、注释节点 (Comment Node) 等。
树构造器执行如下操作:
- 接收到一个
StartTag Token时 , 它会创建一个对应的 DOM 节点对象 (例如HTMLDivElement) , 并将其添加到当前节点作为子节点。 - 当接收到
EndTag Token时 , 它会将指针移回到父节点 , 等待下一个令牌。 - 当接收到
Character Token时 , 它会创建 文本节点 (Text Node) 。
4. 样式表和脚本处理 (Parsing Blocking)
这是 HTML 解析中最复杂 , 也是性能优化的关键点。
-
遇到外部 CSS (
<link rel="stylesheet">):- HTML 解析不会暂停 (可以继续构建 DOM 树) 。但是 , 渲染会被阻塞。 主线程必须等待 CSS 文件下载并解析成完整的 CSSOM 树后 , 才能进入到下一步的 样式计算 和 布局。
-
遇到同步 JS (
<script src="...">):- HTML 解析立即暂停。主线程必须等待脚本文件下载完成 , 然后 JavaScript 引擎 (如 V8) 立即在主线程上执行脚本。
- 执行完毕后 , 主线程才会从暂停的地方恢复 HTML 解析。
5. DOM 树构建完成 (DOMContentLoaded)
上述解析设计有一个关键优势在于它支持增量解析。由于解析器是基于状态机工作的 , 它不需要等待整个 HTML 文档下载完成才开始工作。
它可以在接收到初始字节流后立即开始分词和树构建 , 使得渲染引擎能够尽早开始构造 DOM , 实现早期的视觉反馈 , 这对于提高首屏内容的绘制速度至关重要 。
当所有 HTML 令牌都被处理 , 并且 DOM 树构建完成后 , 浏览器会触发 DOMContentLoaded 事件。
此时 DOM 树已经可用 , 但不代表页面所有资源 (如图片、视频等) 都已加载完毕。
DOM 节点之间的关系相对独立 , 只有结构依赖。浏览器可以边接收 HTML 字节边构建 DOM 树 , 甚至可以先渲染部分已经构建好的树。
2. CSSOM Tree
在 DOM Tree 构建的同时 , 并行解析 CSS 生成 CSSOM (CSS Object Model) 树:
-
浏览器根据指定的编码将字节 转换为可识别字符。
-
字符流被分解成符合 CSS 语法的、有意义的代码块 , 这些代码块被称为 令牌 (Tokens)
h1 { color: red; }会被分解为h1、{、color、:、red、;、}等令牌。
-
浏览器将令牌组合成具有结构和上下文意义的 节点 (Nodes)
- 令牌
color、:、red会组合成一个声明 (Declaration) 节点; - 整个
h1 { ... }作为一个规则 (Rule) 节点。
- 令牌
-
浏览器利用这些节点构建一个树形结构 , 即 CSSOM Tree。
CSSOM Tree 不像 DOM Tree 的增量构建不同 , CSSOM 的构建具有内在的递归和完整性要求 , 由于 css 的 层叠 (Cascading) 和 继承 (Inheritance) 特性 , 父节点的样式会影响其所有子节点。
这种拓扑依赖性是导致 CSS 成为默认渲染阻塞资源的首要原因。
浏览器需要完整的 CSS 规则集 (用户代理默认样式、用户样式、外部样式表、内联样式等) , 并根据优先级确定最终生效的样式 , 形成一个表示最终计算样式的结构,计算样式步骤分为如下三个步骤:
步骤一: 样式匹配 (Selector Matching)
浏览器遍历 DOM 树,并针对每个元素,执行以下操作:
- 收集所有规则: 从 CSSOM 中收集所有可能应用于该元素的 CSS 规则集(基于选择器)。
- 选择器匹配: 确定哪些规则的选择器是真正匹配该元素的。
性能关注点: 选择器匹配是从右向左进行的(例如,匹配
div p.className时,浏览器先找到所有的.className,再找它们是不是p,最后看p是不是在div里)。因此,使用高效的选择器(如 ID 或类选择器,避免过于复杂的后代选择器)可以加速匹配过程。
步骤二: 层叠与冲突解决 (Cascading and Conflict Resolution)
对于一个元素,可能会有来自多个源的多个规则(例如,外部样式表、内联样式、用户代理默认样式)都定义了同一个属性(如 color)。
浏览器必须遵循 层叠规则 (Cascading Order) 确定哪个样式获胜:
- 来源与重要性: 确定规则的来源(作者、用户、用户代理)和是否有
!important标记。 - 特异性(Specificity): 计算每个匹配规则的特异性值(权重)。特异性高的规则获胜。
- 书写顺序: 如果特异性相同,则后出现的规则获胜。
步骤三: 最终计算值和继承 (Computed Value and Inheritance)
在确定了每个属性的获胜值后,浏览器进行值的转换和传播:
- 计算值转换: 将相对值转换为绝对值。例如,将
font-size: 1.2em或width: 50%转换为像素 (px) 值。这些转换后的绝对值被称为计算值 (Computed Value) 。 - 属性继承: 对于可继承属性(如
color、font-size),如果元素没有明确定义,则会继承其父级元素的计算值。这是自上而下传播的过程。
通过浏览器控制台可以查看最终计算样式结果。
面试题:为什么 CSS 会阻塞渲染?
理解 CSSOM 构建过程 , 就能明白为什么 CSS 文件被称为 “渲染阻塞资源” (Render Blocking Resource) :
- 不可跳过性: 浏览器无法在知道元素样式的情况下开始渲染。如果没有样式 , 渲染出的页面可能会出现 FOUC 即
闪烁的无样式内容 (Flash of Unstyled Content), 用户体验极差。 - 全局依赖性: CSS 语言允许后定义的样式覆盖前面定义的样式 (Cascading) 。这意味着 , 浏览器必须完全解析所有 CSS 文件 , 才能确定任何一个元素的最终样式 , 从而构建完整的 CSSOM Tree。
面试题:如何优化渲染阻塞?
- 关键 CSS : 提取首屏内容所需的最小 CSS 集合 , 将其内联到 HTML 文档
<head>中 , 确保浏览器能够快速构建出首屏所需的 CSSOM。 - 异步加载非关键 CSS: 对于非首屏或不影响初始渲染的 CSS , 可以使用
<link rel="preload" as="style" onload="this.rel='stylesheet'">或通过 JavaScript 动态加载 , 以避免阻塞渲染。
通过这种方式 , 我们能让浏览器尽快完成 CSSOM 的关键部分 , 加速首次内容绘制 (FCP) 时间。 示例:
<head>
<!-- 1. 内联关键 CSS -->
<style>
/* 首屏渲染必需的核心样式 */
.hero-banner {
background: #f0f0f0;
}
</style>
<!-- 2. 异步加载非关键 CSS -->
<link rel="preload" href="non-critical.css" as="style" onload="this.rel='stylesheet'" />
<noscript><link rel="stylesheet" href="non-critical.css" /></noscript>
</head>
3. Render Tree
当 CSSOM Tree 和 DOM Tree 都构建完成后 , 浏览器会将它们合并 , 生成 渲染树 (Render Tree) ,它包含了所有可见的 DOM 元素 , 以及它们最终的计算样式 (Computed Styles) 。
它是后续的布局 (Layout) 和绘制 (Paint) 过程的唯一输入。你可以将其理解为一个“屏幕上应该显示什么 , 以及它们应该以什么样式显示”的蓝图。
Render Tree 只包含屏幕上需要绘制出来的元素。以下类型的 DOM 节点会被排除:
-
非视觉元素: 如
<head>、<meta>、<script>、<link>等。 -
隐藏元素: 明确设置为
display: none;的元素及其所有子元素。- 注意: 设置为
visibility: hidden;或opacity: 0;的元素仍会被包含在 Render Tree 中。这是因为它们虽然不可见 , 但在布局阶段仍然会占据空间。
- 注意: 设置为
对于 DOM Tree 中每一个可见的节点 , 浏览器会遍历 CSSOM Tree , 根据 层叠 (Cascading) 规则、继承 (Inheritance) 规则以及选择器优先级 , 计算出该元素所有 CSS 属性的最终值。
这个最终值 (Computed Style) 会被附加到对应的 Render Object 上。
额外说明:CSSOM 树的构建
如果你的 CSS 文件很小 , 或者网络环境很好 , CSSOM Tree 很可能在 DOM Tree 完成构建之前就完成了 , DOMContentLoaded 触发时 , Render Tree 可以立即开始构建。
如果你的 CSS 文件很大 , 或者网络环境很差 , 在 DOM Tree 完成构建并触发 DOMContentLoaded 时 , CSSOM Tree 可能仍在构建中 , 虽然 DOMContentLoaded 已经触发 , 但浏览器仍需等待 CSSOM 完成 , 才能开始构建 Render Tree 进行渲染。
你需要关注的是 Render Tree 的构建完成时机 , 这通常与 首次内容绘制 (FCP) 时间相关 , 而不是 DOMContentLoaded。
一道面试题:Load 和 DOMContentLoaded 区别:
- DOMContentLoaded ≈ HTML 文档结构可用。
- load 事件 ≈ 所有资源 (图片、CSS、JS 等) 都已加载完成。
阶段二:布局与图层 (Layout & Layering Phase)
Render Tree 是这个阶段的输入 , 布局阶段会根据 Render Tree 中的每一个元素计算出精确的位置 (X, Y)和尺寸 (Width, Height) , 并为后续的绘制做准备。
1. 布局 (Layout / Reflow)
根据 Render Tree , 从其根节点开始进行遍历计算所有元素的精确几何尺寸和位置。这是一个耗时的操作 , 如果改变元素的尺寸或位置 , 就会触发此步骤 , 称为 重排 (Reflow) 。
因该
Reflow单词翻译 , 以及浏览器位置布局计算从上而下的流体布局规则 , 有时也称为 回流 (Reflow) , 二者只是翻译不同 , 概念上是一致的 , 后续统称 重排 (Reflow) 。
任何影响元素几何属性的改动都会触发重排 , 其性能开销很大 , 在标准的文档流中 , 一个元素的尺寸改变 (例如:增加一个 padding) 往往会影响其所有相邻元素和父级容器的尺寸。父级尺寸改变又会影响所有子级元素的相对尺寸 (例如:宽度为 50% 的子元素) 。
浏览器必须从引发改变的节点开始 , 沿着 DOM 树 (准确地说是 Render Tree) 向上找到其根 , 然后递归地向下重新计算受影响的每个节点的尺寸和位置。对于大型复杂页面 , 这可能涉及重新计算整个 Render Tree , 因此是最耗性能的渲染操作之一。
触发重排 (Reflow) 的常见操作
任何导致元素几何信息发生变化的 DOM/CSSOM 操作都会触发重排。这包括:
-
影响尺寸和位置的 DOM 操作
- 添加/删除可见的 DOM 元素:
appendChild,removeChild, 或者直接操作innerHTML。 - 改变元素内容:例如改变输入框的文本 , 或改变图片
src导致图片尺寸变化。
- 添加/删除可见的 DOM 元素:
-
影响尺寸和位置的 CSS 属性操作
- 盒模型属性:改变
width,height,padding,margin,border。 - 布局属性:改变
display,float,position(尤其是从static到relative/absolute),overflow。 - 字体和文本属性:
font-size,font-family,line-height, 这些都会影响包含它们的元素尺寸。
- 盒模型属性:改变
-
浏览器的自身行为
- 改变窗口大小 (Resizing) :会触发整个页面的重排。
- 改变字体大小:浏览器设置中改变默认字号。
- 激活伪类:例如在
:hover时改变了元素的尺寸。
-
获取元素的计算几何信息 (同步刷新样式) , 浏览器为了性能优化 , 通常会批量处理样式和布局更改。 当你通过 JavaScript 强制读取元素的以下属性时 , 浏览器必须立即应用所有待处理的样式更改 , 以确保你获取到最新的、正确的数值 , 这会强制触发一次同步的重排:
offsetTop,offsetLeft,offsetWidth,offsetHeightscrollTop,scrollLeft,scrollWidth,scrollHeightclientTop,clientLeft,clientWidth,clientHeightgetComputedStyle()(或旧版本的currentStyle)
2. 分层 (Layering)
现代浏览器引擎 (如 Blink/Chromium 或 Gecko) 并不会将整个页面绘制到一个单一的表面上。为了实现高效的动画和交互 , 根据特定规则 (如拥有 z-index、transform、opacity、will-change 属性或成为根元素) , 将 Render Tree 中的节点划分成多个独立的合成层 (Compositing Layers) 。
粗暴的理解为 PS 里的图层 , 能够快速理解它的优势。
分层机制的核心性能优势在于 损伤隔离 (Damage Isolation) 。每个合成层通常对应于 GPU 内存中的一个纹理 (Texture) 。
如果一个元素 (例如 , 正在进行 CSS 变换的模态框) 被提升为一个独立的层 A , 并且该元素发生了变化 , 浏览器只需要重新绘制和光栅化层 A。页面上的其他静态层 B 和 C 则保持不变 , 无需重新处理。
这种隔离将频繁变化的任务 (如动画和滚动) 的处理工作从 CPU 密集型的主线程 , 有效地转移到了 GPU 加速的合成器线程 (Compositor Thread) 上。这一步是性能优化的关键 , 独立的图层可以独立重绘和独立合成 , 避免影响整个页面。
浏览器会自动为特定的元素创建独立的层:
-
例如 3D 转换元素、视频元素、 元素 , 或者那些使用
opacity或transform进行动画的元素 或者使用 CSS 滤镜的元素。 -
需要 裁剪 (clip) 的地方也会被创建为图层:
overflow: hidden;/overflow: scroll;/overflow: auto;当内容超出边界需要被隐藏或允许滚动时 , 浏览器会创建一个新的堆叠上下文 (Stacking Context) 和一个渲染层 (Render Layer) , 以便在这个边界上应用剪裁。clip-path使用clip-path属性进行复杂的形状剪裁 , 也通常会导致浏览器创建一个新的渲染层来隔离这个复杂的绘制和剪裁任务。position: fixed;固定定位的元素也需要一个特殊的层来确保它们在视口滚动时保持不动 , 这本身就是一种相对于视口的“剪裁”和定位。
开发者也可以通过使用 CSS 属性如 will-change 或 transform: translateZ(0) 来手动触发图层提升。
虽然图层提升带来了性能隔离的巨大好处 , 但过度使用也会产生副作用。每个新的复合层都需要消耗额外的 GPU 内存来存储其纹理 , 并且增加了合成器线程管理图层树 (Layer Tree) 的开销,这种现象被称为图层爆炸 (Layer Explosion) 。
因此 , 必须在利用 GPU 加速的益处与控制内存和管理开销之间找到精确的平衡点。
阶段三:绘制与合成 (Paint & Compositing Phase)
在完成了布局和图层划分之后 , 接下来的阶段是将抽象的几何信息和样式规则转化为实际的像素点 , 并最终显示到用户的屏幕上。
1. 绘制 (Paint)
绘制 (Painting) 阶段的任务是根据渲染树的结构、布局计算的结果以及最终的样式 , 确定每个可见元素的像素外观 。
主线程负责生成各个图层上的绘制指令列表 (Paint Records) 或 显示列表 (Display List) 。这是一个由一系列抽象的、结构化的矢量图形指令 (例如 , “在坐标 (x,y) 处绘制一个红色矩形” , “用字体 A 绘制文本 'Hello'”) 组成的列表。
主线程只生成指令 , 不执行真正的像素填充。
这种将绘制工作抽象为指令列表的方式具有显著的优势:它将复杂的像素生成工作解耦 , 使得这些指令可以被高效地缓存、重复使用 , 并发送给专门的后台线程进行处理。
如果样式变动不影响布局 (如改变颜色) , 则只触发此步骤 , 称为 重绘 (Repaint) , 开销小于重排。
2. 分块 (Tiling)
将每个图层划分成更小的图块 (Tiles) , 这是为了实现增量渲染和内存优化。比如超大图层不会一次性全部处理 , 只处理视口周围的图块。
图块 (Tiling) 在浏览器渲染中的作用和原理 , 可以理解为瓦片地图 (Tile Maps , 如 Google Maps 或高德地图) 的基本概念高度相似 , 核心思想是一致的。
3. 光栅化 (Rasterization)
光栅化 (Rasterization) 是将绘图记录中描述的矢量指令转化为实际的位图数据 (即 GPU 纹理) 的过程。由于光栅化是计算密集型任务 , 现代浏览器架构采用了多线程处理机制:
光栅化线程 (Raster Threads) 或使用 GPU 进程。根据绘制指令列表 , 将图块中的内容转化为 GPU 可以处理的位图 (像素信息) 。这是真正的像素填充过程。现代浏览器通常使用 GPU 来加速此过程。
为了优化光栅化的性能 , 浏览器会根据图块的优先级 (Priority) 进行调度。优先级通常基于图块的位置 (是否在视口内) 和用户的交互行为 (如滚动或缩放) 来动态调整。
- 当前可见的视口 (Viewport) 内的 Tile。 最高优先级,必须立即光栅化以显示首帧内容或关键动画帧。
- 视口周围的一圈 Tile (Tile Border)。中优先级 ,预光栅化 (Pre-rasterization),用于处理平滑的滚动和轻微的平移操作。确保当用户轻微滚动时,新进入视口的区域已有缓存。
- 视口外较远的 Tile。低优先级 后台光栅化,用于处理长页面的快速滚动或加载。这些任务在不占用关键资源的前提下,在后台安静地进行。
- 那些虽然在层上但当前不可见的 Tile,例如被 display: none 隐藏的元素。 最低优先级,利用浏览器的空闲时间进行处理,防止浪费资源。
4. 合成 (Compositing)
合成器线程 (Compositor Thread) 的核心任务是管理页面的所有渲染层 (Render Layers) ,并在不涉及主线程 Layout 或 Paint 的情况下,高效地将这些层的光栅化位图 (即 GPU 纹理)合并成最终的屏幕帧。
它的主要职责包括:
- 管理图层树: 维护页面上所有独立复合层的层级关系、几何信息和 Z 轴顺序。
- 事件快速处理: 处理可合成 (Compositable) 的输入事件 (如滚动、手势),直接在线程内更新图层的位置和变换,从而完全绕过主线程。
- 发送合成指令: 将包含所有图层纹理、位置、透明度和变换信息的指令集 (通常是 Draw Quad 列表)发送给 GPU 进程。GPU 进程则负责调度 GPU 硬件执行最终的 合成 (Compositing) 操作,将这些独立的位图合并成最终的屏幕图像。
合成器线程最大的性能优势在于:当一个动态变化 (如 opacity 或 transform 动画)不影响元素的几何形状 (Layout) 或内部绘制 (Paint) 时,这些变化可以完全在合成器线程和 GPU 上完成,从而完全绕过耗时的浏览器主线程。这一特性是实现高帧率 (60fps)和消除卡顿 (Jank)的关键。
更新流程与渲染循环
一旦页面首次渲染完成 , 任何对 DOM 或 CSSOM 的修改都可能触发后续的更新流程。这个流程与初始渲染类似 , 但通常更为复杂 , 因为它涉及增量更新和性能优化。根据修改属性的不同 , 更新流程的计算成本差异巨大:
- 重排 (Reflow/Layout) : 成本最高。修改影响元素尺寸或位置的属性 (如 width, padding, top) , 必须重新计算整个或部分渲染树的几何结构 。
- 重绘 (Repaint) : 成本适中。修改影响元素外观但不影响其布局的属性 (如 color, background-color, visibility) 。此操作跳过了布局阶段 , 但仍需要主线程生成新的绘图记录和执行栅格化。
- 仅合成 (Compositing-Only) : 成本最低。修改仅影响复合层属性 (如 transform, opacity) 。此操作完全绕过主线程 , 由合成器线程和 GPU 处理。
浏览器的渲染工作与显示器的垂直同步信号 (Vsync) 严格同步。浏览器通过事件循环 (Event Loop) 来管理任务 (如 JavaScript 执行和事件处理) 。为了确保流畅的视觉体验 , 渲染引擎会尝试在 Vsync 发生前完成所有 Layout、Paint 和 Composite 工作。
requestAnimationFrame API 允许开发者将 JavaScript 代码的执行精确地安排在渲染更新之前。这确保了所有 DOM/样式更改都可以在浏览器执行批处理布局和绘制之前完成 , 从而将工作与显示器的刷新率对齐 , 有效减少感知延迟。
高性能动画与复合层优化
实现高帧率 (60fps) 动画的秘诀在于将动画操作限制在仅触发合成 (Compositing-Only) 的属性上 , 从而将工作从 CPU 转移到 GPU。
为了确保动画的高效性 , 开发者应严格遵循以下原则:
优先使用 transform 和 opacity: 这些属性操作的是现有复合层上的 GPU 纹理 , 只涉及低成本的合成阶段 。使用translate(x, y) 替代修改 top 或 left 属性 , 因为后者会触发成本高昂的 Reflow。
利用 will-change: 对于即将动画化的元素 , 提前设置 will-change: transform 或 will-change: opacity , 可以提示浏览器为其创建独立的复合层。这避免了动画开始时临时创建层带来的开销 , 但必须谨慎使用 , 以防止图层爆炸。
通过将动态操作限制在仅需要合成器线程处理的范围内 , 可以有效地将动画逻辑从主线程的约束中解耦 , 确保即使主线程被繁重的 JavaScript 任务阻塞 , 动画和滚动也能保持平滑。
End
本文为学习记录,旨在梳理浏览器渲染过程的核心概念。如有理解偏差之处,欢迎讨论与指正。 by:lichonglou