Lazy loaded image
技术分享
现代浏览器的工作原理
字数 24289阅读时长 61 分钟
2025-9-14
2025-9-14
type
status
date
slug
summary
tags
category
icon
password
Description
Web 开发者通常将浏览器视为一个黑盒,它能神奇地将 HTML、CSS 和 JavaScript 转换为交互式 Web 应用程序。实际上,像 Chrome(Chromium)、Firefox(Gecko)或 Safari(WebKit)这样的现代浏览器是极为复杂的软件。它协调网络通信、解析并执行代码、利用 GPU 加速渲染图形,并通过沙箱进程隔离内容以确保安全性。
本文深入探讨现代浏览器的工作原理——重点关注 Chromium 的架构和内部机制,同时指出其他引擎的差异之处。我们将探索从网络栈和解析流水线,到通过 Blink 进行渲染、通过 V8 执行 JavaScript、模块加载、多进程架构、安全沙箱机制以及开发者工具等各个方面。目标是提供一种开发者友好的解释,揭开浏览器幕后运行机制的神秘面纱。
notion image
让我们开始探索浏览器的内部世界。

网络与资源加载

notion image
每次页面加载都始于浏览器网络栈从网络获取资源。当你输入一个 URL 或点击一个链接时,浏览器的 UI 线程(运行在“浏览器进程”中)会发起一个导航请求。
浏览器进程是主要的控制进程,负责管理所有其他进程以及浏览器的用户界面。所有不属于特定网页标签页的操作均由浏览器进程控制。
步骤包括:
URL 解析与安全检查:浏览器解析 URL 以确定其协议(如 http、https 等)和目标域名。同时判断输入内容是搜索查询还是 URL(例如在 Chrome 的多功能地址栏中)。此处还会检查黑名单等安全功能,以避免访问钓鱼网站。
DNS 查询:网络栈将域名解析为 IP 地址(除非已缓存),这可能需要联系 DNS 服务器。现代浏览器可能会使用操作系统的 DNS 服务,或者在配置的情况下使用基于 HTTPS 的 DNS(DoH),但最终都会获取到主机的 IP 地址。
建立连接:如果与服务器之间没有打开的连接,浏览器会打开一个。对于 HTTPS URL,这包括 TLS 握手以安全交换密钥并验证证书。浏览器的网络线程会透明地处理 TCP/TLS 等协议的设置。
发送 HTTP 请求:建立连接后,浏览器会发送针对资源的 HTTP GET 请求(或其他方法)。现代浏览器默认使用 HTTP/2 或 HTTP/3(如果服务器支持),这允许在单个连接上多路复用多个资源请求。相比 HTTP/1.1 时代每个主机约 6 个并行连接的限制,这种方式提升了性能。例如,使用 HTTP/2 时,HTML、CSS、JS 和图片都可以在一条 TCP/TLS 连接上并发获取;而使用基于 QUIC UDP 的 HTTP/3 时,连接建立的延迟进一步降低。
接收响应:服务器会返回 HTTP 状态码和响应头,随后是响应体(HTML 内容、JSON 数据等)。浏览器读取响应流。如果 Content-Type 响应头缺失或不正确,浏览器可能需要探测 MIME 类型,以决定如何处理内容。例如,如果响应内容看起来像 HTML 但未被标记为 HTML,浏览器仍会尝试将其作为 HTML 处理(根据宽松的网页标准)。此处也有安全机制:网络层会检查 Content-Type,可能会阻止可疑的 MIME 类型不匹配或不允许的跨源数据(Chrome 的 CORB——跨源读取阻止——就是此类机制之一)。浏览器还会查询安全浏览服务(Safe Browsing)或类似服务,以拦截已知的恶意载荷。
重定向和后续步骤:如果响应是 HTTP 重定向(例如带有 Location 头部的 301 或 302),网络代码将在通知 UI 线程后,跟随重定向并重复请求新 URL。只有在获得包含实际内容的最终响应后,浏览器才会继续处理该内容。
所有这些步骤都发生在网络栈中,在 Chromium 中,网络栈运行于专用的网络服务(Network Service)中(现在通常是一个独立的进程,属于 Chrome 的“服务化”努力的一部分)。浏览器进程的网络线程负责协调底层的套接字通信工作,其背后使用的是操作系统的网络 API。重要的是,这种设计意味着渲染器(负责执行页面代码)不会直接访问网络——它需要请求浏览器进程来获取所需资源,从而提升了安全性。

推测性加载与资源优化

现代浏览器在网络阶段实现了复杂的性能优化。当你将鼠标悬停在链接上或开始输入网址时,Chrome 会主动进行 DNS 预取或预先建立 TCP 连接(使用 Predictor 或 preconnect 机制),以便在你点击时,部分延迟已经被消除。此外还有 HTTP 缓存:如果资源已被缓存且仍有效,网络栈可以直接从浏览器缓存中满足请求,避免网络请求。
预加载扫描器的工作原理:Chromium 实现了一个复杂的预加载扫描器,它会在主解析器之前对 HTML 标记进行分词处理。当主 HTML 解析器因 CSS 或同步 JavaScript 而被阻塞时,预加载扫描器会继续检查原始标记,以识别可并行获取的资源,例如图片、脚本和样式表。这一机制对现代浏览器的性能至关重要,且无需开发者干预即可自动运行。预加载扫描器无法发现通过 JavaScript 动态注入的资源,因此这类资源往往会被连续加载,而非并发加载。
早期提示(HTTP 103):早期提示允许服务器在生成主响应时使用 HTTP 103 状态码发送资源提示。这使得在服务器处理期间即可发送 preconnect 和 preload 提示, potentially 将最大内容渲染时间提前数百毫秒。早期提示仅适用于导航请求,并支持 preconnect 和 preload 指令,但不支持 prefetch。
推测规则 API:推测规则 API 是一项较新的网页标准,允许根据用户交互模式定义规则,以动态地预获取和预渲染 URL。与传统的链接预获取不同,该 API 可以预渲染包含 JavaScript 执行的完整页面,从而实现近乎即时的加载速度。该 API 使用 script 元素或 HTTP 头中的 JSON 语法来指定应进行推测性加载的 URL。Chrome 设有限制以防止过度使用,其容量设置根据紧急程度的不同而有所区分。
HTTP/2 和 HTTP/3:大多数基于 Chromium 的浏览器和 Firefox 完全支持 HTTP/2,而基于 QUIC 的 HTTP/3 也得到了广泛支持(Chrome 默认为支持的网站启用)。这些协议通过允许并发传输并减少握手开销来提升页面加载速度。从开发者的角度来看,这意味着你可能不再需要使用雪碧图或域名分片等技巧——浏览器可以在单个连接上高效地并行获取大量小文件。
资源优先级:浏览器还会对某些资源进行优先级排序。通常,HTML 和 CSS 优先级较高(因为它们会阻塞渲染),脚本的优先级可能为中等(如果正确标记为 defer/async,则可能为高),而图片的优先级可能较低。Chromium 的网络栈会分配权重,甚至可以取消或延迟请求,以优先处理初始渲染所需的资源。开发者可以使用 link rel=preload 和 Fetch Priority 来影响资源的优先级。
在网络阶段结束时,浏览器已经获得了页面的初始 HTML(假设这是一次 HTML 导航)。此时,Chrome 的浏览器进程会选择一个渲染进程来处理内容。Chrome 通常会与网络请求并行地(推测性地)启动一个新的渲染进程,以便在数据到达时能够立即投入使用。这个渲染进程是隔离的(稍后会详细介绍多进程架构),并将负责接管页面的解析和渲染工作。
当响应完全接收(或在流式传输过程中),浏览器进程会提交导航:它会通知渲染进程接收字节流并开始处理页面。此时,地址栏会更新,新站点的安全标识(如 HTTPS 锁等)也会显示出来。接下来的操作转移到渲染进程:解析 HTML、加载子资源、执行脚本以及绘制页面。

解析 HTML、CSS 和 JavaScript

当渲染进程接收到 HTML 内容后,其主线程会根据 HTML 规范开始解析。解析 HTML 的结果是生成 DOM(文档对象模型)——一个表示页面结构的对象树。解析过程是增量式的,可以与网络读取交错进行(浏览器以流式方式解析 HTML,因此即使 HTML 文件尚未完全下载,DOM 也可以开始构建)。
notion image
HTML 解析与 DOM 构建:HTML 解析根据 HTML 标准被定义为一个容错的过程,无论标记的结构多么不规范,它都会生成 DOM。这意味着即使你遗漏了闭合标签</p>,或嵌套标签不正确,解析器也会隐式地修复或调整 DOM 树,使其有效。例如,<p>Hello <div>World</div> 在 DOM 结构中会自动在<div>之前结束<p>标签。解析器会为 HTML 中的每个标签或文本创建 DOM 元素和文本节点,每个元素都按照源代码中的嵌套关系被放置在树形结构中。
一个重要的方面是,HTML 解析器在解析过程中可能会遇到需要获取的资源:例如,遇到 `<link rel="stylesheet" href="...">` 会促使浏览器请求 CSS 文件(在网络线程中),而遇到 `<img src="...">` 则会触发图片请求。这些操作会与解析过程并行执行。在这些资源加载的同时,解析器可以继续工作,但有一个重大例外:脚本。
处理 <script> 标签:当 HTML 解析器遇到 <script> 标签时,默认情况下会暂停解析,必须先执行该脚本才能继续。这是因为脚本可能会使用 document.write() 或其他 DOM 操作方法,从而改变仍在加载中的页面结构或内容。通过在该时刻立即执行脚本,浏览器可以保持相对于 HTML 的正确操作顺序。因此,解析器会将脚本交给 JavaScript 引擎执行,只有当脚本执行完成(并且其对 DOM 的修改已应用)后,HTML 解析才能继续。这种脚本执行阻塞行为正是为什么在页面头部引入大型 <script> 文件会减慢页面渲染速度的原因——HTML 解析必须等待脚本下载并执行完毕后才能继续。
然而,开发者可以通过属性来修改这种行为:在 `<script>` 标签中添加 defer 或 async(或使用现代的 ES 模块脚本),会改变浏览器的处理方式。使用 async 时,脚本文件会并行下载,并在准备就绪后立即执行,不会暂停 HTML 解析(解析过程不会等待,且脚本的执行顺序相对于其他 async 脚本无法保证)。使用 defer 时,脚本也会并行下载,但执行会推迟到 HTML 解析完成之后(并在那时按原始顺序执行)。在这两种情况下,解析器都不会阻塞等待脚本,通常对性能更有利。ES6 模块(使用 `<script type="module">`)也会自动延迟执行(并且可以使用 import 语句——我们将在后面单独讨论模块加载)。通过使用这些技术,浏览器可以继续构建 DOM 而不会长时间暂停,从而使页面加载更快。
CSS 解析与 CSSOM:除了 HTML 之外,CSS 文本也必须被解析成浏览器可处理的结构,通常称为 CSSOM(CSS 对象模型)。CSSOM 本质上是文档所应用的所有样式(规则、选择器、属性)的表示。浏览器的 CSS 解析器读取 CSS 文件(或<style>标签块),并将其转换为一系列 CSS 规则(以及大量布隆过滤器等,以加快样式解析速度)。随后,在 DOM 构建过程中(或当 DOM 和 CSSOM 都准备就绪后),浏览器将计算每个 DOM 节点的样式。这一步骤通常被称为样式解析或样式计算。浏览器结合 DOM 和 CSSOM,确定每个元素应应用哪些 CSS 规则,以及最终的计算样式是什么(在应用层叠、继承和默认样式之后)。输出结果通常被概念化为每个 DOM 节点与其计算样式的关联(即该元素最终确定的 CSS 属性,例如颜色、字体、大小等)。
值得注意的是,即使没有作者编写的 CSS,每个元素都有默认的浏览器样式(用户代理样式表)。例如,几乎在所有浏览器中,<h1> 都具有默认的字体大小和外边距。浏览器内置的样式规则具有最低的优先级,它们确保了合理的默认显示效果。开发者可以在开发者工具(DevTools)中查看计算后的样式,以确切了解元素最终应用了哪些 CSS 属性。样式计算步骤会综合所有适用的样式(用户代理样式、用户样式、作者样式)来确定每个元素的最终样式。
渲染阻塞行为:虽然 HTML 解析可以在 CSS 未完全加载时继续进行,但存在渲染阻塞关系:浏览器通常会等待 CSS 加载完成(对于位于 <head> 中的 CSS)后再执行首次渲染。这是因为应用不完整的样式表可能导致短暂显示无样式内容。实际上,如果 HTML 中存在未标记为 async/defer 的 <script>,且该脚本位于 CSS <link> 之前,则脚本会等待 CSS 加载完成后才执行(因为脚本可能通过 DOM API 查询样式信息)。一般原则是:将样式表链接放在 head 中(它们会阻塞渲染,但需要尽早加载),而将非关键或较大的脚本使用 defer/async 属性,或放置在页面底部,以避免延迟 DOM 解析。
现在浏览器已经具备了:(1) 从 HTML 构建的 DOM,(2) 解析后的 CSS 规则(CSSOM),以及 (3) 每个 DOM 节点的计算样式。这些内容共同构成了下一阶段——布局的基础。但在继续之前,我们需要更详细地考虑 JavaScript 方面,特别是 JS 引擎(以 Chrome 为例,即 V8)是如何执行代码的。我们之前提到了脚本阻塞,但当 JavaScript 运行时,具体会发生什么?我们将在后面的章节中专门介绍 V8 和 JavaScript 执行的内部机制。目前,我们假设脚本在运行过程中可能会修改 DOM 或 CSSOM(例如调用 document.createElement 或设置元素样式)。浏览器可能需要响应这些更改,重新计算样式或布局(如果频繁执行,可能会带来性能开销)。在解析过程中,脚本的初始运行通常包括设置事件处理器,或对 DOM 进行操作(例如模板处理)。之后,页面通常已完全解析,接下来进入布局和渲染阶段。

样式与布局

在此阶段,浏览器的渲染进程已经知道了 DOM 的结构以及每个元素的计算后样式。接下来的问题是:这些元素在屏幕上应该放在什么位置?它们的尺寸有多大?这就是布局(也称为“重排”或“布局计算”)的任务。在此阶段,浏览器会根据 CSS 规则(如文档流、盒模型、Flexbox 或 Grid 等)以及 DOM 层级结构,计算出每个元素的几何信息——即它们的大小和位置。
notion image
布局树构建:浏览器遍历 DOM 树并生成布局树(有时称为渲染树或帧树)。布局树的结构与 DOM 树类似,但会省略非视觉元素(例如 script 或 meta 标签不会产生盒子),并且在需要时可能会将某些元素拆分为多个盒子(例如,跨越多行的单个 HTML 元素可能对应多个布局盒子)。布局树中的每个节点都保存该元素的计算样式,并包含节点内容(文本或图像)以及影响布局的计算属性(如宽度、高度、内边距等)信息。
在布局阶段,浏览器会计算每个元素盒子的确切位置(x、y 坐标)和尺寸(宽度、高度)。这涉及 CSS 规范定义的各种算法:例如,在正常的文档流中,块级元素从上到下堆叠,默认占据全部宽度,而行内元素则在行内流动,并在需要时触发换行。Flexbox 或 Grid 等现代布局模式拥有各自的算法。渲染引擎必须考虑字体度量信息以进行换行(因此文本布局涉及对文本片段的测量),同时还要处理外边距(margin)、内边距(padding)、边框(border)等。该过程存在大量边界情况(例如外边距合并规则、浮动元素、脱离文档流的绝对定位元素等),使得布局成为一个出人意料地复杂的过程。即使是“简单”的从上到下的布局,也需要根据可用宽度和字体大小来确定文本的换行位置。浏览器引擎为此配备了专门的团队,并经过多年的开发,以确保布局的准确性和高效性。
关于布局树的一些细节:
  • display:none 的元素会完全从布局树中省略(它们不会产生任何盒子)。相比之下,仅仅是不可见的元素(例如 visibility:hidden)仍然会生成一个布局盒子(占据空间),只是后续不会被绘制。
  • 生成内容的伪元素(如 ::before 或 ::after)会被包含在布局树中(因为它们确实具有视觉盒子)。
  • 布局树的节点知道自身的几何信息。例如,一个 <p> 元素的布局节点会知道它相对于视口的位置和尺寸,并为其内部的每一行或行内盒子拥有子节点。
布局计算:布局通常是一个递归过程。浏览器从根节点(即<html>元素)开始,计算视口的大小(针对<html>/<body>),然后在其中布局子元素,依此类推。许多元素的尺寸取决于其子元素或父元素(例如,一个容器可能会扩展以容纳子元素,或者一个子元素可能是其父元素宽度的 50%)。布局算法通常需要进行多次遍历,以处理浮动元素或某些复杂的交互情况,但总体上它通常沿一个方向(自上而下)进行,必要时可能回溯。
至此阶段结束时,每个元素在页面上的位置和尺寸都已确定。我们可以从概念上将页面视为一堆盒子(内部包含文本或图像)。但我们尚未真正将任何内容绘制到屏幕上——这将是下一步,即绘制(painting)阶段的任务。
然而,有一个关键概念:布局可能是一项开销较大的操作,尤其是反复执行时。如果 JavaScript 随后更改了某个元素的大小或添加了内容,就可能迫使页面部分或全部重新布局。开发者常听到建议,应避免布局抖动(例如在修改 DOM 后立即用 JavaScript 读取布局信息,这可能导致同步重计算)。浏览器会通过标记布局树中哪些部分是“脏”的,仅重新计算这些部分,以此进行优化。但在最坏情况下,DOM 树较高层级的更改可能需要对大型页面的整个布局进行重新计算。因此,为了更好的性能,应尽量减少昂贵的样式和布局操作。
样式与布局回顾:总结来说,浏览器会根据 HTML 和 CSS 构建:
  • DOM 树 - 结构和内容
  • CSSOM - 解析后的 CSS 规则
  • 计算样式 - 将 CSS 规则匹配到每个 DOM 节点后的结果
  • 布局树 - 经过滤只保留视觉元素的 DOM 树,包含每个节点的几何信息
每个阶段都建立在前一个阶段的基础之上。如果任何一个阶段发生变化(例如脚本修改了 DOM 或某个 CSS 属性),后续阶段可能都需要更新。例如,如果你更改某个元素的 CSS 类,浏览器可能需要重新计算该元素的样式(如果继承关系发生变化,还包括其子元素),接着如果该样式变更影响了几何结构(例如 display 或尺寸),则可能需要重新进行布局,然后还需要重绘。这一链条意味着布局和绘制都依赖于最新的样式信息,依此类推。我们将在 DevTools 部分讨论这一过程对性能的影响(因为浏览器提供了工具来查看这些步骤何时发生以及耗时多久)。
完成布局后,我们进入下一个主要阶段:绘制。

绘制、合成与 GPU 渲染

绘制是指将结构化的布局信息实际转化为屏幕上像素的过程。传统意义上,浏览器会遍历布局树,并为每个节点发出绘制指令(“绘制背景、绘制文本、在指定坐标绘制图像”)。现代浏览器在概念上仍然如此操作,但通常会将工作划分为多个阶段,并利用 GPU 来提升效率。
notion image
绘制 / 光栅化:在渲染器的主线程上,布局完成后,Chrome 会遍历布局树生成绘制记录(或称显示列表)。这基本上是一系列带有坐标的绘图操作,类似于画家规划如何绘制场景:例如“在坐标 (x,y) 处绘制一个宽度为 W、高度为 H、填充色为蓝色的矩形,然后在 (x2,y2) 处使用字体 XYZ 绘制文本‘Hello’,接着在……处绘制图像”等等。该列表按照正确的 z-index 顺序排列(以确保重叠元素正确绘制)。例如,如果某个元素的 z-index 更高,其绘制指令将排在后面(覆盖在较低 z-index 内容之上)。浏览器必须考虑层叠上下文、透明度等因素,以确保正确的绘制顺序。
在过去,浏览器可能会按顺序直接将每个元素绘制到屏幕上。但如果页面的某些部分发生变化,这种方法可能会效率低下(你将不得不重绘所有内容)。现代浏览器则通常会记录这些绘制指令,然后通过合成步骤来组装最终的图像,尤其是在使用 GPU 加速时。
分层与合成:合成为一种优化手段,将页面拆分为多个可独立处理的图层。例如,带有 CSS 变换或动画的定位元素可能会拥有自己的图层。这些图层就像独立的“画布草稿”——浏览器可以分别对每个图层进行光栅化(绘制),然后由合成器利用 GPU 将它们组合显示在屏幕上。
在 Chromium 的渲染流程中,生成绘制记录后,会有一个构建图层树的步骤(即确定哪些元素位于哪个图层上)。某些图层会自动创建(例如视频元素、canvas 元素,或具有特定 CSS 的元素会被提升为独立图层),开发者也可以通过使用 will-change 属性或 transform 等 CSS 属性来提示浏览器创建图层。图层的优势在于,对图层进行移动或透明度变化时,可以通过合成处理(即仅重新渲染或移动该图层),而无需重绘整个页面。然而,图层过多会占用大量内存并增加开销,因此浏览器会谨慎地进行图层创建。
确定图层后,Chrome 的主线程会将任务交给合成线程。合成线程运行在渲染进程中,但独立于主线程(因此即使主线程因 JavaScript 执行而繁忙时,合成线程仍可继续工作,这对实现流畅的滚动和动画非常有利)。合成线程的任务是接收这些图层,将它们光栅化(即将绘图内容转换为实际的像素位图),然后将它们合成为帧。
借助 GPU 的光栅化:光栅化工作也可以被分发。在 Chrome 中,合成线程将图层分解为更小的瓦片(例如 256x256 或 512x512 像素的块,开启 GPU 光栅化时这些瓦片通常更大,几乎总是如此)。然后将这些瓦片分派给多个光栅化工作线程(这些线程甚至可能跨多个 CPU 核心运行),以实现并发光栅化。每个光栅化工作线程接收一个瓦片——本质上是该图层区域的一系列绘制指令——并生成位图(像素数据)。重要的是,Skia(Chrome 的图形库)可以使用 CPU 或 GPU 进行光栅化;在 Chrome 中,这些光栅化线程通常使用 CPU 渲染像素,然后将其上传到 GPU 内存。Firefox 较新的 WebRender 采用了另一种我们稍后会提到的方法。光栅化后的瓦片作为纹理存储在 GPU 内存中。一旦所有需要的瓦片绘制完成,合成线程就基本准备好了一组带有纹理的图层。
合成器随后组装一个合成帧——基本上是一条发送给浏览器进程的消息,其中包含构成屏幕的所有图块(图层的切片)、它们的位置等信息。该合成帧通过 IPC 提交回浏览器进程,最终由浏览器的 GPU 进程(Chrome 中用于访问 GPU 的独立进程)接收并显示这些内容。浏览器进程自身的 UI(如标签栏)也是通过合成帧绘制的,所有这些帧在最后一步被混合在一起。GPU 进程接收这些帧,并利用 GPU(通过 OpenGL/DirectX/Metal 等)对它们进行合成——即快速地将每个纹理绘制到屏幕的正确位置,并应用变换等操作。最终结果就是你看到的显示画面。
这种流水线的优势在滚动或动画时尤为明显。例如,滚动页面通常只是改变较大页面纹理上的视口区域。合成器可以直接调整图层位置,并要求 GPU 重绘进入视野的新区域,而无需主线程重新绘制所有内容。如果动画仅涉及变换(比如移动一个独立图层的元素),合成器线程就可以每帧更新该元素的位置,生成新帧,而无需主线程参与,也无需重新计算样式和布局。这就是为什么推荐使用仅涉及“合成”的动画(如改变 transform 或 opacity,这些属性不会触发布局)以获得更好的性能——即使主线程繁忙,它们也能以 60 FPS 的流畅速度运行。相反,如果动画涉及 height 或 background-color 等属性,则可能每帧都强制重新布局或重绘,一旦主线程无法跟上,就会导致卡顿。
简而言之,Chrome 的渲染流程是:DOM → 样式 → 布局 → 绘制(记录显示项)→ 分层 → 光栅化(切片)→ 合成(GPU)。Firefox 的流程在显示列表阶段之前概念上类似,但通过 WebRender,它跳过了显式的分层构建,而是将显示列表发送到 GPU 进程,由 GPU 着色器处理几乎所有的绘制工作(比较部分会进一步说明)。WebKit(Safari)在 macOS 上也使用多线程合成器,并通过“CALayers”实现 GPU 渲染。因此,所有现代引擎都利用 GPU 进行渲染,尤其是在合成和光栅化图形密集部分时,以实现高帧率并减轻 CPU 负担。
在继续之前,让我们更详细地讨论一下 GPU 的作用。在 Chromium 中,GPU 进程是一个独立的进程,其职责是与图形硬件进行交互。它接收来自所有渲染器合成器以及浏览器 UI 的绘制指令(主要是高级指令,例如“在这些坐标处绘制这些纹理”),然后将其转换为实际的 GPU API 调用。通过将其隔离在独立进程中,即使存在缺陷的 GPU 驱动程序导致崩溃,也不会使整个浏览器崩溃——只会导致 GPU 进程崩溃,而该进程可以被重新启动。此外,这还提供了一层沙箱边界(由于 GPU 需要处理诸如 Canvas 绘图、WebGL 等潜在的不可信内容,驱动程序中曾出现过安全漏洞,将它们运行在独立进程之外可降低风险)。
合成的结果最终被发送到显示设备(浏览器运行的系统窗口或上下文)。对于每一帧动画(为了流畅效果,目标为每秒 60 帧,即每帧 16.7 毫秒),合成器都会尝试生成一帧画面。如果主线程正忙(例如 JavaScript 执行时间过长),合成器可能会跳过某些帧或无法及时更新,导致出现明显的卡顿。开发者工具可以在性能时间线上显示丢失的帧。使用诸如 requestAnimationFrame 之类的技术,可将 JavaScript 的更新与帧的边界对齐,从而帮助实现更流畅的渲染。
总之,浏览器的渲染引擎会将页面内容和样式精心分解为一组几何信息(布局)和绘制指令,然后利用图层和 GPU 合成技术,高效地将这些信息转化为你所看到的像素。这一复杂的渲染管线使得网页上丰富的图形和动画能够以交互式的帧率运行。接下来,我们将深入了解 JavaScript 引擎,探究浏览器是如何执行脚本的(到目前为止,我们一直将其视为一个黑盒)。

深入 JavaScript 引擎(V8)

JavaScript 驱动着网页的交互行为。在 Chromium 浏览器中,V8 引擎负责执行 JavaScript(以及 WebAssembly)。了解 V8 的工作原理有助于开发者编写高性能的 JavaScript 代码。虽然深入探讨 V8 的全部细节足以写成一本书,但我们将会聚焦于 JavaScript 执行流程中的几个关键阶段:代码的解析与编译、代码执行以及内存管理(垃圾回收)。我们还将介绍 V8 如何处理现代特性,例如即时(JIT)编译的多层优化以及 ES 模块。
notion image

现代 V8 解析与编译流水线

notion image
后台编译:从 Chrome 66 开始,V8 在后台线程上编译 JavaScript 源代码,使典型网站在主线程上的编译时间减少了 5% 到 20%。自版本 41 起,Chrome 已通过 V8 的 StreamedSource API 支持在后台线程上解析 JavaScript 源文件。V8 可以在网络下载到第一个代码块时立即开始解析,并在文件流式传输的同时并行持续解析。几乎所有脚本的编译工作都在后台线程完成,仅在脚本执行前于主线程进行短暂的 AST 内部化和字节码最终化步骤。目前,顶层脚本代码和立即调用的函数表达式在后台线程上编译,而内部函数仍会在首次执行时在主线程上惰性编译。
解析与字节码:当遇到 `<script>` 标签(无论是 HTML 解析过程中还是后续加载的脚本)时,V8 首先会解析 JavaScript 源代码,生成代码的抽象语法树(AST)表示。预解析器是解析器的一个副本,它仅执行最少的操作来跳过函数。它验证函数的语法是否正确,并生成外部函数正确编译所需的所有信息。当一个经过预解析的函数稍后被调用时,它将按需进行完整解析和编译。
V8 并不直接从 AST 进行解释执行,而是使用一个名为 Ignition(发布于 2016 年)的字节码解释器。Ignition 将 JavaScript 编译为紧凑的字节码格式,这种格式本质上是一系列针对虚拟机的指令。这种初始编译速度非常快,且生成的字节码层级相对较低(Ignition 是基于寄存器的虚拟机)。其目标是以极小的前期开销快速启动代码执行(这对页面加载时间至关重要)。
AST 内部化过程:AST 内部化涉及在 V8 堆上分配字面量对象(字符串、数字、对象字面量模板),供生成的字节码使用。为了支持后台编译,该过程被移至编译流水线的后期阶段,即字节码编译之后,因此需要修改代码以访问嵌入在 AST 中的原始字面量值,而非堆上已内部化的值。
显式编译提示:V8 引入了一项名为“显式编译提示”的新功能,允许开发者通过预编译指示 V8 在加载时立即解析和编译代码。带有此提示的文件将在后台线程上进行编译,而延迟编译则发生在主线程上。对主流网页的实验显示,在 20 个案例中有 17 个性能得到提升,前景解析和编译时间平均减少了 630 毫秒。开发者可通过在 JavaScript 文件中使用特殊注释添加显式编译提示,从而在后台线程上对关键代码路径启用预编译。
扫描器和解析器优化:V8 的扫描器已得到显著优化,整体性能全面提升:单个令牌扫描速度提升约 1.4 倍,字符串扫描提升 1.3 倍,多行注释扫描提升 2.1 倍,标识符扫描则根据标识符长度提升 1.2 至 1.5 倍。
当脚本运行时,Ignition 会解释字节码并执行程序。解释执行通常比优化后的机器码慢,但它使引擎能够立即开始运行,同时收集有关代码行为的分析信息。随着代码的运行,V8 会收集其使用情况的数据:变量的类型、哪些函数被频繁调用等。这些信息将在后续步骤中用于提升代码的运行速度。

即时编译层级

V8 不止于解释执行。它采用多层即时(Just-In-Time)编译器来加速频繁运行的代码。其核心思想是对执行频繁的代码投入更多编译资源,以提升其运行速度,同时避免浪费时间优化仅执行一次的代码。
  1. Ignition(解释字节码)。
  1. Sparkplug:V8 的基线 JIT 编译器,名为 Sparkplug(大约在 2021 年推出)。Sparkplug 会快速将字节码编译为机器码,但不会进行重度优化。这生成的原生代码比解释执行更快,但 Sparkplug 不做深度分析——其目标是启动速度几乎与解释器一样快,同时生成的代码运行速度稍快一些。
  1. Maglev:2023 年,V8 推出了 Maglev,这是一种中层优化编译器,目前已正式投入使用。Maglev 生成代码的速度比 Sparkplug 慢近 20 倍,但比 TurboFan 快 10 到 100 倍,有效填补了那些执行频率中等、但尚未达到 TurboFan 优化程度的函数的性能空白。当函数有一定热度但不足以触发 TurboFan 优化,或 TurboFan 的编译成本过高时,Maglev 便会发挥作用。从 Chrome M117 开始,Maglev 能够处理许多此类情况,通过弥合基础层与最高层级 JIT 之间的差距,使那些在“温热”代码(既非冷代码,也非极热代码)上花费较多时间的 Web 应用启动更快。
  1. TurboFan:当函数或循环被执行多次时,V8 会启动其最强大的优化编译器。TurboFan 会获取代码,并利用收集到的类型反馈生成高度优化的机器代码,应用高级优化技术(如函数内联、消除边界检查等)。如果假设条件成立,该优化后的代码可以运行得更快。
因此,V8 现在实际上拥有四个执行层级:Ignition 解释器、Sparkplug 基线 JIT、Maglev 优化 JIT 和 TurboFan 优化 JIT。这类似于 Java 的 HotSpot 虚拟机拥有多个 JIT 级别(C1 和 C2)。引擎可以根据执行情况动态决定哪些函数需要优化以及何时优化。如果某个函数突然被调用一百万次,它很可能会被 TurboFan 优化,以实现最高速度。
英特尔还开发了基于性能分析的分层技术(Profile-Guided Tiering),提升了 V8 的效率,在 Speedometer 3 基准测试中性能提高了约 5%。最近的 V8 更新还包括静态根优化(static roots optimization),该优化允许在编译时准确预测常用对象的内存地址,显著提升了访问速度。
JIT 优化的一个挑战在于 JavaScript 是动态类型的。V8 可能会在某些假设下优化代码(例如,这个变量始终是整数)。如果后续调用违反了这些假设(比如变量变成了字符串),那么优化后的代码就不再有效。此时 V8 会执行去优化:回退到一个优化程度较低的版本(或基于新的假设重新生成代码)。该机制依赖“内联缓存”和类型反馈来快速适应变化。去优化的存在意味着,如果你的代码类型不可预测,峰值性能有时可能无法持续;但通常情况下,V8 会尽量处理常见的模式(例如,函数始终接收相同类型的对象)。

字节码清除与内存管理

V8 实现了字节码清除机制,即如果某个函数在多次垃圾回收后仍未被使用,其字节码将被回收。当该函数再次执行时,解析器会利用之前存储的结果更快地重新生成字节码。这一机制对内存管理至关重要,但在某些边缘情况下可能导致解析不一致。
内存管理(垃圾回收):V8 使用垃圾回收器自动管理 JavaScript 对象的内存。多年来,V8 的垃圾回收器已演变为被称为 Orinoco GC 的系统,它是一种分代式、增量式和并发式的垃圾回收器。关键点:
  • 分代式:V8 按对象的存活时间将其分代。新创建的对象被分配到新生代(也称为“育婴室”),这些对象会通过一种非常快速的清理算法频繁回收(将存活的对象复制到新的空间,其余空间被回收)。在新生代中经历足够多次回收周期后仍存活的对象会被晋升到老生代。
  • 标记-清除/整理:对于老生代,V8 使用带有整理功能的标记-清除垃圾回收器。这意味着它会偶尔暂停 JavaScript 的执行(短暂的“停顿”),从根对象(如全局对象)开始遍历并标记所有可达对象,然后清除不可达的对象以回收内存。此外,它还可能进行内存整理(移动对象以减少内存碎片)。不过,Orinoco 使得大部分标记过程可以并发执行——它能在后台线程上完成大部分标记工作,同时 JavaScript 代码仍在运行,从而最大限度地减少停顿时间。
  • 增量式垃圾回收:V8 尽可能将垃圾回收任务拆分为多个小片段执行,而不是一次性长时间暂停。这种增量方式将工作分散开来,以避免页面卡顿。例如,它可以在脚本执行之间插入少量标记工作,利用空闲时间完成任务。
  • 并行垃圾回收(Parallel GC):在多核设备上,V8 可以在多个并行线程中执行部分垃圾回收操作(如标记或清理)。
总体效果是,多年来 V8 团队已大幅减少了垃圾回收的暂停时间,使得即使在大型应用中,垃圾回收也几乎难以察觉。新生代垃圾回收(Minor GC,即新对象清理)通常非常迅速。老年代垃圾回收(Major GC)发生频率较低,且现在大多是并发执行的。如果你打开 Chrome 的任务管理器或 DevTools 的内存面板,可能会看到 V8 的堆内存被划分为“新生区(Young space)”和“老生区(Old space)”,这正反映了其分代设计。
对开发者而言,这意味着无需手动进行内存管理,但仍需保持警惕:例如,尽量避免在紧密循环中创建大量短暂存在的对象(尽管 V8 在处理短暂对象方面表现相当出色),并意识到持有大型数据结构会使其长期驻留在内存中。可以使用 DevTools 等工具强制触发垃圾回收,或记录内存使用情况的分析报告,以查看内存的使用状况。
V8 和 Web API:值得一提的是,V8 覆盖了核心 JavaScript 语言和运行时(执行、标准 JS 对象等),但许多“浏览器 API”(如 DOM 方法、alert()、网络请求 XHR/fetch 等)并不属于 V8 本身。这些功能由浏览器提供,并通过绑定机制暴露给 JavaScript。例如,当你调用 document.querySelector 时,其底层会进入引擎对 C++ 实现的 DOM 的绑定。V8 负责调用 C++ 代码并获取返回结果,同时有大量机制来提升这一边界的执行效率(Chrome 使用 IDL 生成高效的绑定)。
在了解了浏览器如何获取资源、解析 HTML/CSS、计算布局、使用 GPU 绘制以及运行 JavaScript 之后,我们现在对页面加载和渲染的整个过程有了完整的认识。但还有更多内容值得探索:ES 模块是如何处理的(因为模块拥有自己的加载机制)、浏览器的多进程架构是如何组织的,以及沙箱和站点隔离等安全功能是如何工作的。

模块加载与导入映射

JavaScript 模块(ES6 模块)相比传统的 <script> 标签,引入了一种不同的加载和执行模型。模块是显式导入/导出值的文件,而不是可能创建全局变量的大型脚本文件。让我们来看看浏览器(特别是 Chrome 中的 V8)是如何加载模块的,以及动态 import() 和导入映射(import maps)等功能是如何发挥作用的。
静态模块导入:当浏览器遇到 `<script type="module" src="main.js">` 时,会将 main.js 视为一个模块入口点。加载过程如下:浏览器会先获取 main.js,然后将其解析为 ES 模块。在解析过程中,会查找所有 import 语句(例如 import { foo } from './utils.js';)。浏览器不会立即执行代码,而是构建一个模块依赖图。它会开始获取所有被导入的模块(本例中为 utils.js),并递归地解析每个模块的导入项,发起获取请求,依此类推。这一过程是异步进行的。只有当整个模块依赖图中的所有模块都被获取并解析完成后,浏览器才会开始求值(evaluate)这些模块。模块脚本本质上是延迟执行的——浏览器会等到所有依赖都准备就绪后才执行模块代码,并按照依赖顺序执行(确保如果模块 A 导入了 B,则 B 会先于 A 执行)。
正是由于这种静态导入机制,ES 模块在某些情况下无法从 file:// 路径加载,除非显式允许;同时也正是因此,默认情况下跨源脚本需要 CORS 才能加载 ES 模块——浏览器实际上是在主动链接并加载多个文件,而不仅仅是将一个 <script> 标签插入页面。
动态导入(import()):除了静态的 import 语句外,ES2020 还引入了 import(moduleSpecifier) 作为表达式。这使得代码可以动态加载模块(返回一个解析为模块导出内容的 Promise)。例如,你可以根据用户操作执行 const module = await import('./analytics.js'),从而实现应用程序的代码分割。在底层,import() 会触发浏览器去获取所请求的模块(及其依赖项,如果尚未加载),然后实例化并执行它,并将模块命名空间对象作为 Promise 的结果返回。V8 与浏览器在此协作:浏览器的模块加载器负责获取和解析,而 V8 则在准备就绪后负责编译和执行。动态导入的强大之处在于,它也可以用于非模块脚本中(例如,内联脚本可以动态导入一个模块)。它本质上赋予开发者按需加载 JavaScript 的能力。与静态导入的区别在于,静态导入会在执行前预先解析(在任何模块代码运行之前,整个模块依赖图都会被加载),而动态导入则更像在运行时加载一个新的脚本(只是具有模块语义和 Promise 机制)。
导入映射(Import maps):浏览器中使用 ES 模块的一个挑战是模块说明符的问题。在 Node 或打包工具中,你通常通过包名来导入(例如 import { compile } from 'react')。但在网页上,如果没有打包工具,'react' 并不是一个有效的 URL——浏览器会将其视为相对路径(这将导致失败)。这时就需要导入映射。导入映射是一个 JSON 配置,用于告诉浏览器如何将模块说明符解析为真实的 URL。它通过 HTML 中的 <script type="importmap"> 标签提供。例如,一个导入映射可以指定说明符 "react" 对应 "https://cdn.example.com/react@19.0.0/index.js"(指向实际脚本的完整 URL)。之后,当任何模块执行 import 'react' 时,浏览器就会利用该映射找到对应的 URL 并加载它。本质上,导入映射使得“裸”说明符(如包名)能够在 Web 上运行,方法是将其映射到 CDN URL 或本地路径。
导入映射(import maps)对无打包开发模式带来了革命性变化。自 2023 年起,所有主流浏览器(Chrome 89+、Firefox 108+、Safari 16.4+,涵盖三大渲染引擎)均已支持导入映射。它们在本地开发或简单应用中特别有用,可让你直接使用模块而无需构建步骤。对于生产环境,大型应用通常仍会进行打包以提升性能(减少请求次数),但随着浏览器及 HTTP/2/3 协议的不断优化,直接提供大量小型模块正变得越来越可行。
因此,浏览器中的模块加载器包含以下几个部分:一个模块映射表(用于追踪已加载的模块),可能存在的导入映射(用于自定义模块解析),以及获取和解析模块的逻辑。模块代码在获取并编译后,会在严格模式下执行,并拥有独立的顶层作用域(除非显式挂载到 window 对象,否则不会泄漏变量)。导出内容会被缓存,因此当其他模块后续导入同一模块时,不会重新执行,而是复用已评估的模块记录。
还有一个需要提及的方面是,ES 模块与普通脚本不同,它会延迟执行,并且对给定的依赖图按顺序执行。如果 main.js 导入了 util.js,而 util.js 又导入了 dep.js,那么执行顺序将是:dep.js 优先,然后是 util.js,最后是 main.js(深度优先,后序遍历)。这种确定性的执行顺序在某些情况下可以避免对 DOMContentLoaded 这类事件的需求,因为当你的主模块运行时,其所有导入的模块都已经被加载并执行完毕。
从 V8 的角度来看,模块由相同的编译管道处理,但会创建独立的 ModuleRecords。引擎会确保模块的顶层代码只有在所有依赖项就绪后才会执行。V8 还必须处理循环模块导入(这是允许的,可能导致部分初始化的导出)。具体细节遵循规范——本质上,引擎会先创建所有模块实例,然后通过为循环依赖提供占位符来解决循环问题,最后按照尊重依赖关系的顺序执行(该规范算法是模块图的“DAG”拓扑排序)。
总之,浏览器中的模块加载是网络(获取模块文件)、模块解析器(使用 import maps 或标准的 URL 解析)以及 JavaScript 引擎(按正确顺序编译和执行模块)之间协调配合的过程。它比传统的 <script> 加载更复杂,但带来了更模块化、更易于维护的代码结构。对开发者而言,关键要点是:使用模块来组织代码,若想使用裸导入(bare imports)则可配合 import maps,并且要知道可以通过 import() 动态加载所需模块。浏览器会负责处理所有繁重工作,确保一切按正确的顺序执行。
现在我们已经了解了单个页面内部的工作机制,接下来让我们从宏观角度出发,探讨支持多个页面、标签页和 Web 应用同时运行且互不干扰的浏览器架构。这就引出了多进程模型。

浏览器多进程架构

现代浏览器(Chrome、Firefox、Safari、Edge 等)均采用多进程架构,以实现稳定性、安全性和性能隔离。与早期浏览器将整个程序运行在一个庞大的进程中不同,现代浏览器将不同功能模块分配到独立的进程中运行。Chrome 于 2008 年率先采用这一架构,随后 Firefox 和 Safari 等浏览器也以不同形式跟进。本文将重点介绍 Chromium 的架构,并指出其与 Firefox 和 Safari 的差异。
在 Chromium(Chrome、Edge、Brave 等)中,有一个核心的浏览器进程。该浏览器进程负责用户界面(地址栏、书签、菜单等所有浏览器界面元素)以及协调资源加载和导航等高层级任务。当你打开 Chrome 时,在操作系统任务管理器中看到的一个条目就是浏览器进程。它同时也是生成其他进程的父进程。
然后,对于每个标签页(有时是标签页中的每个站点),Chrome 会创建一个渲染进程。渲染进程会为该标签页的内容运行 Blink 渲染引擎和 V8 JavaScript 引擎。通常情况下,每个标签页至少会获得一个渲染进程。
notion image
如果你打开了多个互不相关的网站,它们会分别运行在不同的进程中(站点 A 在一个进程,站点 B 在另一个进程,以此类推)。Chrome 甚至会将跨源的 iframe 隔离到独立的进程中(详见站点隔离机制)。渲染进程是沙箱化的,无法直接随意访问你的文件系统或网络——它必须通过浏览器进程来执行这些需要特权的操作。
Chrome 中的其他关键进程包括:
  • GPU 进程:专门用于与 GPU 通信的进程(如前所述)。所有来自渲染器的渲染和合成请求都会发送到 GPU 进程,由其实际发出图形 API 调用。该进程是独立且沙箱化的,因此即使 GPU 崩溃也不会导致渲染器崩溃。
  • 网络进程:(在较旧版本的 Chrome 中,网络功能是浏览器进程中的一个线程,但现在通常通过“服务化”成为独立进程)。该进程负责处理网络请求、DNS 等,并可单独进行沙箱隔离。
  • 实用工具进程:用于 Chrome 可能卸载的各种服务(如音频播放、图像解码等)。
  • 插件进程:在 Flash 和 NPAPI 插件时代,插件在独立的进程中运行。如今 Flash 已被弃用,因此这一部分已不那么重要,但浏览器架构仍保留了插件不在主浏览器进程中运行的能力。
  • 扩展程序进程:Chrome 扩展程序(本质上是可作用于网页或浏览器的脚本)也在独立的进程中运行,与网站隔离以确保安全性。
简化的视图是:一个浏览器进程协调多个渲染进程(每个标签页或每个站点实例一个),外加一个 GPU 进程以及一些用于其他服务的进程。Chrome 的任务管理器(在 Windows 上按 Shift+Esc,或通过“更多工具”>“任务管理器”)实际上会列出每种进程类型及其内存使用情况。
多进程的优势:主要优势包括:
  • 稳定性:如果某个网页(渲染进程)崩溃或内存泄漏,不会导致整个浏览器崩溃——你可以关闭该标签页,其余部分仍能正常运行。在单进程浏览器中,一个有问题的脚本可能导致整个浏览器崩溃。当某个标签页的进程终止时,Chrome 可以单独显示“哎呀,崩溃了”错误,并允许你独立重新加载该标签页。
  • 安全性(沙箱机制):通过在受限的进程中运行网页内容,浏览器可以限制该代码在系统上的操作权限。即使攻击者发现了渲染引擎中的漏洞,他们也会被限制在沙箱内——渲染进程通常无法读取你的文件、随意建立网络连接或启动程序。它必须向浏览器主进程请求诸如文件访问等操作,而这些请求可以被验证或拒绝。这种沙箱机制在操作系统层面得到强制执行(根据不同平台使用作业对象、seccomp 过滤器等技术)。
  • 性能隔离:一个标签页中的繁重工作(如大型 Web 应用或无限循环)通常仅限于该标签页的渲染进程。其他标签页(不同的进程)可以保持响应,因为它们的进程不会被阻塞。此外,操作系统可以将不同进程调度到不同的 CPU 核心上,因此在多核系统中,两个高负载页面能够并行运行,比它们作为单个进程中的线程运行效果更好。
  • 内存分段:每个进程都有自己的地址空间,因此内存不会被共享。这可以防止一个网站窥探另一个网站的数据,同时也意味着当关闭标签页时,操作系统能够高效地回收该进程的全部内存。缺点是由于资源和进程的重复(每个渲染进程都会加载自己的一份 JavaScript 引擎等),带来了一定的开销。
站点隔离:最初,Chrome 的模型是每个标签页使用一个进程。随着时间推移,他们演进为每个站点使用一个进程(尤其是在 Spectre 漏洞之后——详见下文关于安全性的部分)。截至 2024 年,站点隔离在桌面平台的 99% Chrome 用户中默认启用,而对 Android 的支持仍在持续优化中。这意味着,如果你打开了两个都指向 example.com 的标签页,Chrome 可能会决定为它们共用一个进程(以节省内存,因为它们属于同一站点,合并运行风险较低)。但如果一个标签页是 example.com,且其中包含一个 evil.com 的 iframe,默认情况下,Chrome 会将 evil.com 的 iframe 放入与主页面分离的独立进程(以保护 example.com 的数据)。这种强制机制就是 Chrome 所称的“严格站点隔离”(Strict Site Isolation),大约从 Chrome 67 版本起默认启用。站点隔离由于增加了进程数量,会导致 Chrome 多消耗 10-13% 的系统资源,但带来了关键的安全优势。
Firefox 的架构称为 Electrolysis(e10s),历史上曾为所有标签页使用单个内容进程(多年来 Firefox 是单进程的,直到 2017 年左右才启用少量内容进程)。从 2021 年起,Firefox 默认使用多个内容进程(默认为 8 个用于网页内容)。随着 Project Fission(站点隔离)的推进,Firefox 正朝着类似的方式隔离站点——它可以为跨站点的 iframe 启动新的进程,并且从 Firefox 108 版本开始默认启用站点隔离,从而可能像 Chrome 一样,每个站点拥有一个独立进程。Firefox 还拥有一个 GPU 进程(用于 WebRender 和合成)以及一个独立的网络进程,这与 Chrome 的架构划分类似。因此实际上,Firefox 现在采用了一种非常类似于 Chrome 的模型:一个主进程、一个 GPU 进程、一个网络进程、多个内容(渲染)进程,以及一些工具进程(用于扩展、媒体解码等,例如媒体插件可以独立运行)。
Safari(WebKit)同样转向了多进程模型(WebKit2),其中每个标签页的内容都位于独立的 WebContent 进程中,由一个中心化的 UI 进程进行控制。Safari 的 WebContent 进程也被沙盒化,无法直接访问设备或文件,必须通过 UI 进程进行。Safari 还有一个共享的网络进程(可能还有其他辅助进程)。因此,尽管具体实现方式不同,其核心理念是一致的:将每个网页的代码隔离在独立的沙盒环境中。
一个重要的点是进程间通信(IPC):这些进程如何相互通信?浏览器使用 IPC 机制(在 Windows 上,通常是命名管道或其他操作系统 IPC;在 Linux 上,可能是 Unix 域套接字或共享内存;Chrome 拥有自己的 IPC 库 Mojo)。例如,当网络响应到达网络进程时,需要通过浏览器进程的协调,将其传递到正确的渲染进程。同样,当你执行 DOM 的 fetch()操作时,JavaScript 引擎会调用网络 API,后者向网络进程发送请求,依此类推。IPC 增加了复杂性,但浏览器对此进行了大量优化(例如,使用共享内存高效传输图像等大量数据,以及发送异步消息以避免阻塞)。
进程分配策略:Chrome 并不会总是为每个标签页创建全新的进程——这是有限制的(特别是在内存较低的设备上,可能会为同一站点的标签页复用进程)。当你打开另一个指向相同网站的标签页时,Chrome 会复用现有的渲染进程,以节省内存(这就是为什么有时同一网站的两个标签页会共享一个进程)。Chrome 对总进程数也有上限(该上限会根据内存容量动态调整)。当达到进程数量上限时,可能会开始将多个不相关的网站放入同一个进程中,不过如果启用了站点隔离,Chrome 会尽量避免混合不同站点。在 Android 上,由于内存限制,Chrome 使用的进程更少(通常最多只有 5 到 6 个用于内容的进程)。
Chromium 中还有一个概念叫做服务化(servicification):将浏览器组件拆分为可以独立运行在不同进程中的服务。例如,网络服务(Network Service)被设计为一个独立模块,能够以进程外的方式运行。其核心思想是模块化——在性能强大的系统上,每个服务都可以独立运行在各自的进程中;而在资源受限的设备上,则可以将部分服务合并到同一个进程中,以降低开销。Chrome 可以在运行时或构建时决定如何部署这些服务。如代码片段所述,在高端设备上可能会将所有组件完全分离(UI、网络、GPU 等各自独立),而在低端设备(如 Android)上则可能将浏览器和网络服务合并到一个进程中,以减少资源消耗。
关键要点:Chromium 的架构设计旨在将浏览器 UI 和每个网站运行在不同的沙箱中,使用进程作为隔离边界。Firefox 和 Safari 也已采用类似的设计。这种架构显著提升了安全性和稳定性,但代价是占用更多内存。Web 内容进程被视为不可信的,这也正是站点隔离(下一节内容)发挥作用的地方——它进一步在独立进程中将不同来源(origin)彼此隔离。

站点隔离与沙箱机制

站点隔离和沙箱是建立在多进程基础之上的安全特性。它们旨在确保即使恶意代码在浏览器中运行,也难以窃取其他站点的数据或访问你的系统。
站点隔离:我们已经提到过这一点——它意味着不同的网站(更严格地说是不同的站点)在不同的渲染进程中运行。2018 年 Spectre 漏洞曝光后,Chrome 加强了其站点隔离机制。Spectre 表明,恶意 JavaScript 有可能读取本不应访问的内存(通过利用 CPU 的推测执行机制)。如果两个站点运行在同一个进程中,恶意站点就可能利用 Spectre 窥探敏感站点(例如你的银行网站)的内存。唯一可靠的解决方案就是完全不让它们共享进程。因此,Chrome 将站点隔离设为默认设置:每个站点都拥有独立的进程,包括跨源的 iframe。Firefox 也推出了 Fission 项目(在最近版本中默认启用),目标与此相同——他们强调将每个站点隔离在独立进程中以提升安全性。这与过去的情况有显著不同:以前如果你有一个主页面和多个来自不同域名的 iframe,它们可能全部运行在同一个进程中(尤其是在同一个标签页中时)。 现在,这些 iframe 会被隔离,例如,一个位于正常网站页面中的 <iframe src="https://evil.com"> 将被强制放入不同的进程中,从而防止低级别攻击在它们之间泄露信息。
从开发者的角度来看,站点隔离大多是透明的。一个影响是,嵌入的 iframe 与其父页面之间的通信现在可能会跨越进程边界,因此它们之间的 postMessage 等操作在底层是通过 IPC 实现的。但浏览器会使其无缝衔接;作为开发者,你只需像往常一样正常使用这些 API 即可。
沙盒机制:每个渲染进程(以及其他辅助进程)都在一个权限受限的沙箱中运行。例如,在 Windows 上,Chrome 使用作业对象(job object)并降低权限,使得渲染器无法调用大多数访问系统的 Win32 API。在 Linux 上,它使用命名空间(namespaces)和 seccomp 过滤器来限制系统调用。渲染器基本上只能进行计算和内容渲染,如果试图打开文件、摄像头或麦克风,将会被阻止(除非通过正确的通道,经由浏览器进程向用户请求权限)。WebKit 的文档明确指出,WebContent 进程无法直接访问文件系统、剪贴板、设备等资源——必须通过 UI 进程进行请求,由其进行中介管理。因此,例如当某个网站试图使用你的麦克风时,权限提示是由浏览器 UI(浏览器进程)显示的,若获得允许,实际的录音操作将在受控的进程中进行。沙箱是一道关键的防御防线。即使攻击者发现了在渲染器中执行原生代码的漏洞,他们仍会面临沙箱的阻隔——必须另寻漏洞(即“逃逸”)才能突破沙箱并访问系统。 这种分层方法(称为站点隔离 + 沙箱)是浏览器安全领域的尖端技术。
Firefox 的沙箱机制现在也非常严格(早期 e10s 时代较弱,但后来得到了加强)。Firefox 的内容进程也无法直接访问太多系统资源;此外,Firefox 还对 GPU 进程进行了沙箱隔离,以应对图形驱动程序的问题。
进程外 iframe(OOPIF):在 Chrome 的站点隔离实现中,他们提出了“OOPIF”这一术语,即进程外 iframe。从用户角度来看,没有任何变化,但在 Chrome 的内部架构中,页面的每个帧可能由不同的渲染进程支持。顶级帧和同站点帧共享一个进程,跨站点帧则使用不同的进程。所有这些进程通过浏览器进程协调,共同协作以渲染单个标签页的内容。这相当复杂,但 Chrome 拥有一棵能够跨越多个进程的帧树。这意味着你的一个标签页可能运行着 N 个进程(一个用于主文档,其他每个跨站点子文档各有一个进程)。它们通过 IPC 进行通信,例如 DOM 事件跨越边界,或涉及跨上下文的某些 JavaScript 调用。在 Spectre 漏洞之后,Web 平台(通过 COOP/COEP、SharedArrayBuffer 等规范)正在结合这些限制不断发展。
内存和性能开销:由于使用了更多的进程,站点隔离确实会增加内存使用。Chrome 开发者指出,在某些情况下可能会带来 10-20% 的内存开销。他们通过一种称为“尽力而为的进程合并”(best-effort process consolidation)的机制来缓解部分问题,即对同站点使用合并进程,并通过限制可创建的进程数量(我们前面提到过)来控制资源消耗。Firefox 最初由于内存顾虑并未对每个站点进行隔离,但在 Spectre 漏洞出现后,他们找到了更高效的方法,例如限制为最多 8 个特权进程,并采用按需创建进程的策略。Safari 历来拥有较强的进程模型,但我不确定它是否对跨站点的 iframe 进行隔离;WebKit2 确实会对顶级页面进行隔离。苹果的关注点通常也在于隐私保护(例如智能防跟踪功能会分区 Cookie 等),但这属于另一个层面的机制。
出于隐私考虑,跨站点预取功能受到限制,目前仅在用户对目标站点未设置任何 Cookie 的情况下才会生效,以防止网站通过预取那些可能永远不会被访问的页面来追踪用户活动。
总而言之,站点隔离确保了最小权限原则的实施:来自源 A 的代码无法访问源 B 的数据,除非通过具有明确授权的 Web API(例如 postMessage 或已分区的存储)。而沙箱机制则确保即使代码存在恶意行为,也无法直接访问你的系统。这些措施大大增加了浏览器被攻击的难度——攻击者通常需要多个连续的漏洞利用(一个用于突破渲染器,另一个用于逃逸沙箱)才能造成严重破坏,这显著提高了攻击门槛。
作为 Web 开发者,你可能不会直接感受到站点隔离的存在,但你正受益于它所带来的更安全的网络环境。需要注意的一点是,跨源交互可能会带来轻微的额外开销(由于进程间通信 IPC),并且一些优化手段(例如进程内脚本共享)在不同源之间不再可行。不过,浏览器正在持续优化进程间的通信,以尽量减少对性能的影响。
现在,在了解完安全性机制之后,让我们转向工具和性能分析——也就是我们开发者如何窥探这一流程,并对其进行测量或调试。

比较 Chromium、Gecko 和 WebKit

我们主要描述了 Chrome/Chromium 的行为(使用 Blink 引擎处理 HTML/CSS,V8 处理 JS,通过 Aura/Chromium 架构实现多进程)。其他主要引擎——Mozilla 的 Gecko(用于 Firefox)和 Apple 的 WebKit(用于 Safari)——具有相同的基本目标和大致相似的处理流程,但在某些方面存在显著差异和历史分歧。
共有的概念:所有引擎都会将 HTML 解析为 DOM,将 CSS 解析为样式数据,计算布局,并进行绘制/合成。它们都配备了带有即时编译(JIT)和垃圾回收机制的 JavaScript 引擎。所有现代浏览器引擎都采用多进程(或至少是多线程)架构,以实现并行处理和安全性。

CSS/样式系统的差异

一个有趣的差异在于渲染引擎如何实现 CSS 样式计算:
  • Blink(Chromium):使用基于 C++ 的单线程样式引擎(历史上源自 WebKit)。它对 DOM 树进行顺序样式计算。虽然曾引入增量样式失效优化,但总体上仍由单个线程完成工作(动画部分有一些轻微的并行化处理)。
  • Gecko(Firefox):在 Quantum 项目(2017 年)中,Firefox 集成了 Stylo——一个用 Rust 编写的新 CSS 引擎,支持多线程。Firefox 能够利用所有 CPU 核心并行计算不同 DOM 子树的样式。这极大地提升了 Gecko 中 CSS 的性能。因此,Firefox 的样式重计算可能使用 4 个核心来完成 Blink 在单核上的工作。这是 Gecko 方法的一个优势(但增加了复杂性)。
  • WebKit(Safari):WebKit 的样式引擎与 Blink 一样是单线程的(因为 Blink 于 2013 年从 WebKit 分支出来,两者在此之前共享架构)。WebKit 实现了一些有趣的功能,例如为 CSS 选择器匹配提供字节码 JIT。它可能会将 CSS 选择器转换为字节码,并通过 JIT 编译匹配器以提升速度。Blink 未采用该方案(它使用迭代匹配)。
因此,在 CSS 方面,Gecko 通过 Rust 实现了并行样式计算,这一点独具特色。而 Blink 和 WebKit 则依赖于优化的 C++,以及可能的一些 JIT 技巧(如 WebKit 的情况)。

布局与图形

三大引擎均实现了 CSS 盒模型和布局算法。某些特定功能可能会先在一个引擎中实现(例如,曾经 WebKit 在 CSS Grid 支持方面领先,随后 Blink 迎头赶上——它们通常通过标准组织共享代码)。
Firefox(Gecko)通过引入 WebRender 作为其合成器/光栅化器,实现了一项重大变革。WebRender 现在已成为 Firefox 的默认渲染引擎,并显著提升了性能,尤其是在处理图形密集型网页内容方面。WebRender(同样使用 Rust 编写)基本上是直接在 GPU 上渲染显示列表,利用 GPU 处理形状、文本等的图元分割(tessellating)工作。这相当于将更多的绘制任务转移到 GPU 上。在 Chrome 的渲染流程中,光栅化仍然主要在 CPU 上完成,然后以位图形式发送到 GPU。而 WebRender 则试图避免为整个图层生成位图,转而直接在 GPU 上绘制矢量图形(除了文本字形会缓存为图集纹理)。这意味着当只有部分内容发生变化时,Firefox 无需重新光栅化整个页面,而是可以通过 GPU 快速重绘,从而有可能以高性能动画渲染更多内容。这类似于游戏引擎每一帧通过 GPU 调用重绘场景的方式。其缺点是实现和调优较为复杂,且可能对 GPU 造成更大压力。但随着 GPU 性能的不断提升,这种方案具有前瞻性。 Chrome 团队曾考虑过类似的方法(“SKIA GPU”路径),但尚未进行全面的 WebRender 风格重构。
Safari(WebKit)采用的方法更类似于早期的 Chrome:它将合成器转换为图层(称为 CALayer,因为在 Mac 和 iOS 上使用的是 Core Animation 图层)。Safari 很早就转向了 GPU 合成(2009 年的 iPhone OS 和 Safari 4 已对某些 CSS 属性(如变换)实现了硬件加速合成)。尽管 Safari 和 Chrome 走了不同的技术路线,但概念上两者都采用分块(tiling)和合成(compositing)机制。Safari 还将大量工作卸载到 GPU 上(并使用分块技术,尤其是在 iOS 上,分块绘制对实现流畅滚动至关重要)。
移动端优化:每个引擎都有针对移动端的特殊处理。例如,WebKit 为滚动引入了图块覆盖(tile coverage)的概念(历史上在 iOS 的 UIWebView 中使用)。Android 上的 Chrome 使用“分块”技术,并尽量减少光栅化任务以达到更高的帧率。Firefox 的 WebRender 则源自以移动端为优先的 Servo 项目。

JavaScript 引擎

  • V8(Chromium)如前所述:截至 2023 年,包括 Ignition、Sparkplug、TurboFan 和 Maglev。
  • SpiderMonkey(Firefox):历史上它包含一个解释器、一个基线 JIT(Baseline JIT)以及一个优化型 JIT(IonMonkey)。近期的工作(Warp)改变了 JIT 层级的工作方式,可能简化了 IonMonkey,并使其更接近 TurboFan 的思路,即利用缓存的字节码和类型信息。SpiderMonkey 还采用了一种不同的垃圾回收机制(同样是分代式,自 2012 年起称为增量式 GC,现在主要是增量和并发的)。
  • JavaScriptCore(Safari):如前所述,它有四个层级(LLInt、Baseline、DFG、FTL)。它使用不同的垃圾回收器(WebKit 的 GC 历史上是名为 Butterfly 或 Boehm 变体的分代标记-清除机制,现在使用 bmalloc 等)。JSC 的 FTL 层使用 LLVM 进行优化,这一点很独特(V8 和 SM 都有自己的编译器,而 JSC 在一个层级上利用了 LLVM)。这种方式可以生成非常快速的代码,但编译开销较大。JSC 通常更注重在某些基准测试中达到峰值性能(在某些测试中表现突出,但 V8 往往会迎头赶上;两者互相超越)。
就 ES 特性而言,由于 test262 以及彼此之间的竞争,三大引擎在跟进最新标准方面都基本保持同步。

多进程模型的差异

  • Chrome:每个标签页通常是独立的,站点隔离在源级别实现,进程数量较多(可能达到数十个)。
  • Firefox:默认进程较少(8 个内容进程处理所有标签页,若需要为跨站点 iframe 启用 Fission 则会增加更多)。因此,并非每个标签页对应一个进程;标签页会共享一个内容进程池。这意味着在打开大量标签页时,Firefox 可能内存占用更低,但也意味着一个内容进程崩溃可能导致多个标签页关闭(尽管它会尽量按站点分组,例如将所有 Facebook 标签页放在同一个进程中等)。
  • Safari:可能每个标签页(或每几个标签页)对应一个进程——在 iOS 上,WKWebView 明确隔离了每个 WebView。Safari 桌面版历史上也是每个标签页独立进程。目前尚不清楚是否已对跨源 iframe 进行隔离——苹果几乎没有谈论过 Spectre 缓解措施,但至少在顶级页面上,Safari 确实实现了按域名划分进程。
进程间协调:所有浏览器引擎都需要解决类似的问题,例如如何在多进程环境中实现 alert()(该函数会阻塞 JavaScript)——通常由浏览器主进程显示 alert 的 UI 并暂停对应的脚本上下文。还有如何处理 prompt/confirm、如何实现模态对话框等。不同浏览器存在细微差异(例如 Chrome 并不会真正阻塞线程来执行 alert,而是在渲染进程中启动一个嵌套的运行循环;而 Firefox 可能仍会冻结该标签页的进程)。
崩溃处理:Chrome 和 Firefox 都具备崩溃报告机制,能够重启崩溃的内容进程,并在标签页中显示错误信息。Safari 的 Web 内容进程崩溃后,通常会在内容区域显示一个更简单的错误消息。

功能实现的差异

某些 Web 平台功能是特定引擎独有的:例如,Chrome 拥有一个实验性的 document.transition API,用于实现无缝的 DOM 过渡,这依赖于 Blink 的架构。Firefox 可能会采用不同的方式实现,或稍后才支持。但最终,这些功能会逐渐在标准中统一。
开发者工具:Chrome 的 DevTools 非常先进。Firefox 的 DevTools 也相当优秀(早期就具备一些独特功能,例如 CSS Grid 高亮器、形状编辑器)。Safari 的 Web 检查器尚可,但在某些方面功能不够全面。这些差异在开发者针对不同浏览器进行调试时可能显得尤为重要。

性能权衡

历史上,Chrome 因其多进程架构和 V8 引擎而在 JavaScript 及整体性能方面表现更优,因而备受赞誉。Firefox 通过 Quantum 项目弥补了许多差距,在图形性能方面有时甚至超越 Chrome(WebRender 在处理复杂页面时可能非常快速)。Safari 在苹果硬件上的图形性能和低功耗表现通常较为出色(他们对功耗进行了大量优化)。
内存:Chrome 因内存占用较高而闻名(源于其众多进程)。Firefox 在内存使用上则力求更为保守。Safari 在 iOS 上因设备 RAM 有限,必须高效使用内存,因此他们在 WebKit 中进行了大量内存优化。
外部贡献者:一个有趣的现象是,这些引擎的许多改进来自外部团队,例如 Igalia(比如在 WebKit 和 Blink 中实现 CSS Grid)。因此,某些功能有时会几乎同时出现在不同浏览器中。
从 Web 开发者的角度来看,这些差异通常表现为:
  • 由于不同引擎在实现某个 CSS 特性或 API 时可能存在细微差异或漏洞,因此需要在所有引擎上进行测试。
  • 性能可能有所不同(例如,由于 JIT 启发式策略的差异,某个 JavaScript 工作负载在一种引擎中可能比在另一种引擎中更快)。
  • 某些 API 可能在某个浏览器中不可用(例如 Safari 通常最后才支持一些新 API,如 WebRTC 或 IndexedDB 版本等,尽管最终会支持)。
但我们讨论的核心概念(网络 -> 解析 -> 布局 -> 渲染 -> 合成 -> JavaScript 执行)适用于所有浏览器,只是内部实现方式或名称有所不同:
  • 在 Gecko 中:解析 -> 帧树 -> 显示列表 -> WebRender 场景或图层树(如果禁用 WebRender)-> 合成。
  • 在 WebKit 中:解析 -> 渲染树 -> 图形层 -> 合成(通过 CoreAnimation)。
所有浏览器都有类似的子系统(DOM、样式、布局、图形、JS 引擎、网络、进程/线程)。
了解这些有助于调试:例如,如果某个内容在 Safari 中卡顿但在 Chrome 中没有,可能是 WebKit 的绘制方式不同。或者如果 CSS 在 Firefox 中运行缓慢,可能是触发了 Stylo 未并行化的路径(不过这种情况很少见)。
总而言之,尽管 Chromium、Gecko 和 WebKit 有不同的实现方式,甚至各自拥有一些独特的创新(如 Gecko 的并行 CSS、WebRender 的 GPU 渲染等),它们却越来越多地实现相同的网页标准,甚至在许多方面展开合作。对开发者而言,网站能在所有平台上正常运行才是最重要的;而引擎的选择更多影响的是平台供应商和开放网络的多样性。在底层,每个引擎独特的架构可能导致不同的性能表现或缺陷,因此在不同浏览器中进行测试并使用各自的性能诊断工具(例如 Firefox 的性能工具与 Chrome 的性能工具)会很有帮助。本文无法详尽列出所有差异,但希望能让你对整体情况有所了解:这些引擎在高层设计上趋于一致(多进程架构、相似的处理流程),但在具体技术方案上仍各有不同。

结论与延伸阅读

我们已经走过了现代浏览器中一个网页的整个生命周期——从输入 URL 开始,经过网络通信与导航、HTML 解析、样式计算、布局、绘制,再到 JavaScript 执行,最终由 GPU 将像素呈现在屏幕上。我们了解到,浏览器本质上就像是一个微型操作系统:管理着进程、线程、内存以及一系列复杂的子系统,以确保网页内容快速加载并安全运行。对于 Web 开发者而言,理解这些内部机制有助于弄清楚为何某些最佳实践(例如减少重排、使用异步脚本)对性能至关重要,以及为何存在某些安全策略(例如不在 iframe 中混合不同源的内容)。
开发者需要记住的几个关键点:
优化网络使用:减少网络往返次数和文件体积 = 更快的首屏渲染。浏览器本身具备许多优化能力(如 HTTP/2、缓存、推测性加载),但你仍应积极使用资源提示(resource hints)和高效的缓存策略。网络栈虽然高性能,但延迟始终是性能杀手。
合理构建 HTML/CSS 以提高效率:结构良好的 DOM 和简洁的 CSS(避免过深的树结构或过于复杂的选择器)有助于解析和样式系统的工作。需理解 CSS 和 DOM 共同构建计算样式,然后布局阶段计算几何信息——频繁的 DOM 操作或样式更改可能触发这些重新计算。
批量执行 DOM 更新:避免反复引起样式重算和布局抖动。使用 DevTools 的 Performance 面板来检测脚本是否导致了过多的布局或绘制操作。
使用适合合成的 CSS 进行动画:对 transform 或 opacity 的动画会脱离主线程并在合成器中运行,从而实现流畅的动画效果。尽量避免动画涉及布局相关的属性。
注意 JavaScript 执行:尽管 JavaScript 引擎速度极快,但长时间的任务仍会阻塞主线程。应将耗时操作拆分为小任务(以保持页面响应性),在某些情况下可考虑使用 Web Worker 处理后台任务。同时请注意,繁重的 JavaScript 可能导致垃圾回收(GC)暂停(如今暂停时间通常很短,但如果内存急剧膨胀仍可能发生)。
善用安全特性:积极采用安全功能,例如在适当情况下使用 iframe sandbox 或 rel=noopener,因为你现在知道浏览器本来就会对这些进行隔离;与浏览器协作是明智之举。
善用开发者工具:性能面板和网络面板尤其宝贵,能让你准确了解浏览器正在执行的操作。如果页面运行缓慢或出现卡顿,这些工具通常能指出问题根源(例如耗时的布局、缓慢的绘制等)。
对于那些希望进一步深入研究的人,Pavel Panchekha 和 Chris Harrelson 编写的《浏览器工程》(browser.engineering)是一份极佳的资源。
这本书本质上是一本免费的在线书籍,引导你逐步构建一个简单的网页浏览器,以通俗易懂的方式涵盖网络、HTML/CSS 解析、布局等内容。它可以作为我们所讨论内容的深度补充材料,通过实例巩固你的知识。此外,Chrome 团队的系列文章“深入现代浏览器内部”提供了配有图表的易读概览。V8 博客(v8.dev)和 Mozilla 的 Hacks 博客则是了解引擎最新进展(例如新的 JIT 编译器层级或 WebRender 内部机制)的绝佳资源。
总之,现代浏览器是软件工程的杰作。它们成功地将所有复杂性抽象化,使得我们开发者通常只需编写 HTML/CSS/JS,便可以信任浏览器来处理其余工作。然而,通过深入探究其内部机制,我们能够获得宝贵的洞察,帮助我们构建出性能更高、更稳健的应用程序。我们能理解为何某些技术可以改善用户体验(例如避免阻塞主线程,或减少不必要的 DOM 复杂性),因为我们看到了浏览器在底层需要如何运作。下一次当你调试网页,或疑惑为何 Chrome 或 Firefox 以某种方式运行时,你将拥有一个关于浏览器内部机制的心理模型来指导你。