揭秘浏览器原理:HTML 代码是如何被解析渲染的?
当你在浏览器的地址栏中输入一个 URL 并按下回车键,或者点击一个链接时,屏幕上瞬间呈现出丰富多彩的网页内容。这一切看起来理所当然,但在通过光纤传输的 0 和 1 的背后,浏览器正在进行着一场精妙绝伦的“数字交响乐”。从枯燥的代码文本到用户可视化的交互界面,这一过程被称为关键渲染路径(Critical Rendering Path, CRP)。
本文将深入浏览器内核深处,剥开 Chrome(Blink 引擎)与 Safari(WebKit 引擎)等现代浏览器的外壳,详细拆解 HTML 代码是如何经过解析、构建、布局、绘制,最终合成显示在屏幕上的全过程。
第一章:浏览器的多进程架构
在深入代码解析之前,我们需要先理解现代浏览器的运行环境。早期的浏览器是单进程的,一个页面崩溃会导致整个浏览器卡死。而现代浏览器(以 Chrome 为代表)采用了多进程架构。
当浏览器准备渲染一个页面时,主要涉及以下几个核心进程的协作:
- 浏览器主进程(Browser Process):负责协调、主控,包括地址栏、书签、后退按钮等 UI,以及管理各个子进程的创建和销毁。
- 网络进程(Network Process):负责通过网络协议(DNS、TCP、TLS、HTTP)下载资源。
- GPU 进程:负责 3D 绘制以及将最终的位图显示在屏幕上。
- 渲染进程(Renderer Process):这是本文的主角。默认情况下,Chrome 会为每个 Tab 标签页开启一个独立的渲染进程。它的核心任务就是将 HTML、CSS 和 JavaScript 转换为用户可以与之交互的网页。
当网络进程将获取到的 HTML 数据(Payload)通过进程间通信(IPC)传递给渲染进程时,渲染流程正式开始。
第二章:构建 DOM 树 —— HTML 的解析
渲染进程接收到的是字节流(Bytes)。计算机只认识 0 和 1,而我们需要的是结构化的文档对象模型(DOM)。从字节到 DOM,需要经历四个关键步骤:解码、分词、构建树。
1. 解码(Conversion)
浏览器首先根据 HTTP 响应头中的 Content-Type 和 charset(例如 UTF-8),将接收到的原始字节数据转换成特定的字符。
2. 分词(Tokenization)
这是词法分析阶段。浏览器按照 W3C 的 HTML 标准,将字符流解析为一个个“令牌”(Token)。
HTML 标准定义了一个复杂的状态机来处理这些字符。例如,当解析器遇到 < 字符时,它进入“标签打开状态”;遇到 a-z 字符,进入“标签名称状态”;直到遇到 >,一个完整的标签令牌(Start Tag Token)生成。
常见的令牌类型包括:
* 开始标签令牌(如 <div>)
* 结束标签令牌(如 </div>)
* 文本令牌(标签内部的文字)
3. 词法分析与节点创建(Lexing & Node Creation)
生成的令牌会被转换成定义了属性和规则的“对象”,即节点(Node)。
4. DOM 树构建(DOM Tree Construction)
这是最关键的一步。由于 HTML 标签是嵌套的(例如 body 包含 div,div 包含 p),浏览器必须通过一个树状结构来反映这种关系。
解析器会维护一个开放元素栈(Stack of Open Elements)。
* 当遇到一个“开始标签”令牌时,解析器创建相应的 DOM 节点,并将其加入树中,同时也压入栈中。
* 当遇到一个“文本”令牌时,直接将节点加入到当前栈顶元素的子节点列表。
* 当遇到一个“结束标签”令牌时,解析器会将栈顶元素弹出,表示该元素解析完成。
容错机制:HTML 与 XML 不同,它具有极强的包容性。如果你忘记写 </li> 或者 </b>,浏览器并不会报错,而是通过内置的容错机制自动闭合标签。这就是为什么即使代码写得很烂,网页依然能勉强显示的原因。
5. 预加载扫描器(Preload Scanner)
在构建 DOM 树的同时,浏览器还有一个“预加载扫描器”在后台运行。主解析器可能被脚本阻塞,但扫描器会快速“浏览”剩余的 HTML 文档,寻找 <img>, <link>, <script> 等标签,并发送请求给网络进程预先下载这些资源。这一机制显著提高了网页加载速度。
第三章:CSSOM 树的构建 —— 样式的计算
光有 DOM 树是不够的,DOM 树只包含了页面的结构,没有包含样式信息。浏览器需要解析 CSS 来确定每个节点长什么样。
1. 解析 CSS
与 HTML 类似,浏览器会将 CSS 文件、<style> 标签内容以及内联 style 属性转换成 CSSOM(CSS Object Model)。
CSS 的解析过程同样涉及字节 -> 字符 -> 令牌 -> 节点 -> CSSOM 树。
2. 样式计算(Style Calculation)
CSSOM 也是树状结构,但这棵树反映的是样式的继承(Inheritance)和层叠(Cascade)规则。
* 继承:如果 body 设置了 font-size: 16px,那么其子元素 div 默认也会拥有这个字号,除非被覆盖。
* 层叠:浏览器需要计算权重(Specificity)。一个元素可能同时被多个规则命中(例如类选择器、ID 选择器、标签选择器)。浏览器必须依据 CSS 优先级规则,计算出每个节点的最终计算样式(Computed Style)。
重要提示:CSS 解析是阻塞渲染的。在 CSSOM 构建完成之前,浏览器不会渲染任何内容。这是为了防止“无样式内容闪烁”(FOUC)。如果没有 CSSOM,页面就是一堆乱糟糟的文本,用户体验极差。因此,将 CSS 放在 <head> 中是最佳实践。
第四章:JavaScript 的执行 —— 解析的拦路虎
在解析 HTML 的过程中,如果遇到了 <script> 标签,情况会变得复杂。
1. 解析阻塞
当 HTML 解析器遇到 <script> 标签(且没有 defer 或 async 属性)时,它会暂停 DOM 的构建,将控制权移交给 JavaScript 引擎(如 Chrome 的 V8)。
为什么?因为 JavaScript 可能会通过 document.write() 改变 HTML 结构,或者查询某些 DOM 节点。如果不暂停,可能会导致竞态条件或 DOM 不一致。
2. V8 引擎的工作
- 解析(Parsing):V8 将 JS 代码解析为抽象语法树(AST)。
- 编译(Compilation):JIT(即时编译)技术将 AST 转换为字节码或机器码。
- 执行(Execution):执行代码。
3. CSS 也会阻塞 JS
这是一个有趣的依赖链:虽然 JS 阻塞 HTML 解析,但如果 JS 脚本中试图访问元素的样式(例如 getComputedStyle),那么 JS 必须等待 CSSOM 构建完成才能执行。
所以,逻辑链是:CSS 下载/解析 -> CSSOM Ready -> JS 执行 -> DOM 继续解析。这也是为什么脚本文件过大或 CSS 文件过大会显著拖慢首屏时间。
第五章:生成渲染树(Render Tree)
现在,我们有了 DOM 树(内容)和 CSSOM 树(样式)。浏览器需要将它们合并,生成一棵用于渲染的树,称为渲染树(Render Tree)。
1. 遍历可见节点
渲染树只包含可见的节点。浏览器从 DOM 树的根节点开始遍历:
* 不可见标签(如 <head>, <script>, <meta>)会被忽略。
* CSS 设置为 display: none 的节点会被忽略。这些节点虽然在 DOM 中,但在渲染树中不存在。
* 注意:visibility: hidden 的节点会出现在渲染树中,因为它虽然看不见,但占据空间布局。
2. 应用样式
对于每一个可见节点,浏览器会找到其在 CSSOM 中对应的样式规则,并应用到该节点上。此时,每一个渲染树节点(通常称为 Renderer 或 Render Object)都包含了所有的视觉信息(颜色、尺寸、边框等)。
第六章:布局(Layout/Reflow)
拥有了渲染树,浏览器知道了有哪些节点以及它们的样式,但还不知道它们在屏幕上的确切位置和大小。计算这些几何信息的过程称为布局(在 Firefox 中称为 Reflow,即回流)。
1. 盒子模型(Box Model)
布局阶段的核心是计算盒子模型。浏览器会根据 CSS 的 width, height, margin, padding, border 等属性,结合视口(Viewport)的大小,计算出每个元素在页面坐标系中的精确 (x, y) 坐标和具体的像素宽高。
2. 布局流(Flow Layout)
布局通常是从上到下、从左到右进行的。
* 块级元素:通常垂直排列,独占一行。
* 行内元素:水平排列,直到一行排满换行。
3. 增量布局与全局布局
- 全局布局:当整棵渲染树发生变化(如改变窗口大小、改变字体大小)时触发,性能开销巨大。
- 增量布局:浏览器是智能的。如果是通过 JS 修改了某个局部的样式(且不影响周围元素),浏览器会标记该节点为“脏(Dirty)”,只重新计算该部分。
回流(Reflow)的代价:布局是一个计算密集型的操作。频繁触发回流(例如在循环中读取 offsetWidth 属性导致强制同步布局)是导致页面卡顿的常见原因。
第七章:分层(Layering)与绘制(Paint)
布局完成后,我们有了几何信息,但屏幕上依然是一片空白。下一步是绘制,也称为栅格化(Rasterization)的前奏。
1. 建立图层树(Layer Tree)
在现代浏览器中,页面并不是画在同一张画布上的。为了处理复杂的 3D 变换、z-index 叠放上下文、透明度或者滚动条,浏览器会将页面拆分为多个图层(Compositing Layers)。
并不是每个节点都有自己的图层。只有满足特定条件的元素(如拥有 will-change 属性、使用 <video>、canvas、3D transform 或固定定位 fixed)才会被提升为独立图层。
图层树的构建是为了实现合成(Compositing)优化,避免牵一发而动全身。
2. 生成绘制列表(Paint Records)
主线程会遍历布局树,为每个图层创建一组绘制指令。这就像是一个绘画清单:“先画一个白色的背景矩形,再在 (10, 10) 处画一段黑色的文字…”。
这个过程并不真正执行像素操作,只是生成指令序列。
第八章:栅格化(Rasterization)与合成(Compositing)
到目前为止,所有的操作(DOM, Style, Layout, Layer, Paint Record)都发生在渲染进程的主线程中。一旦主线程完成了这些,它会将控制权交给合成线程(Compositor Thread)。
这是现代浏览器高性能的关键:主线程可能会被 JS 阻塞,但合成线程可以独立运行,这就保证了即使 JS 卡死,页面的滚动和动画往往还能响应。
1. 栅格化(Rasterization)
绘制列表只是指令,屏幕需要的是像素。将指令转换为位图(Bitmap)的过程叫栅格化。
* 分块(Tiling):由于页面可能很长,合成线程会将图层切分成一个个小的“图块”(Tiles),通常是 256×256 或 512×512。
* GPU 加速:合成线程会将这些图块发送给 GPU 进程。GPU 是处理图形的大师,它并发地执行栅格化任务,将图块生成位图,并存储在 GPU 显存中。这被称为快速栅格化。
2. 合成(Compositing)
当所有可见的图块都被栅格化后,合成线程会生成一个叫做 “绘制四边形”(Draw Quad) 的命令。这个命令包含了图块在内存中的位置以及在页面合成时的坐标。
3. 显示(Display)
合成线程将 Draw Quad 命令提交给浏览器进程(Browser Process)。浏览器进程中的 viz 组件(Visualizer)接收命令,将其发送给 GPU,GPU 执行最终的屏幕绘制,将像素点亮。
至此,经过这一系列复杂的工序,用户终于在屏幕上看到了一帧完整的画面。
第九章:重排(Reflow)与重绘(Repaint)
理解了上述流程,我们就能深刻理解前端性能优化的两个核心概念。
1. 重排(Reflow / Layout)
当我们修改了元素的几何属性(如 width, height, margin, left)或内容(文字数量、字体大小)时,浏览器需要重新计算布局树。
重排必然导致重绘。因为位置变了,肯定需要重新画。流程是:
JS -> Style -> Layout -> Layer -> Paint -> Composite
这是开销最大的路径。
2. 重绘(Repaint)
当我们只修改了外观属性,不影响布局(如 color, background-color, box-shadow)时,浏览器跳过布局阶段,直接开始绘制。
流程是:
JS -> Style -> Paint -> Composite
开销中等。
3. 合成(Composite Only)—— 性能的圣杯
如果我们修改的是可以直接由 GPU 处理的属性(主要是 transform 和 opacity),现代浏览器可以跳过布局和绘制,直接在合成线程处理。
流程是:
JS -> Style -> Composite
主线程几乎不参与,直接由合成线程调度 GPU 操作。这就是为什么使用 transform: translate() 做动画比使用 left/top 做动画要流畅得多的根本原因。
第十章:总结
从输入 URL 到页面展示,浏览器完成了一项惊人的工程壮举。让我们回顾一下这个完整的流水线:
- 解析 HTML,构建 DOM 树。
- 解析 CSS,构建 CSSOM 树。
- 合并 DOM 和 CSSOM,生成渲染树(Render Tree)。
- 布局(Layout),计算每个节点的几何信息。
- 分层(Layer),根据层叠上下文拆分图层。
- 绘制(Paint),生成绘制指令列表。
- 分块与栅格化(Tiling & Rasterization),合成线程将图层分块并利用 GPU 生成位图。
- 合成与显示(Composite & Display),将位图合成并输出到屏幕。
理解这一原理不仅仅是为了通过面试,更是为了编写高性能 Web 应用的基石。当我们知道 <script> 会阻塞解析,知道 transform 可以避开重排,知道 display: none 与 visibility: hidden 在渲染树中的区别时,我们要写的代码就不再是简单的字符堆砌,而是对浏览器渲染引擎的精准指挥。
在这个毫秒必争的时代,对浏览器原理的透彻理解,是区分“代码搬运工”与“高级工程师”的分水岭。