现代浏览器的工作原理

3 阅读1小时+

作者:Addy Osmani

注: 对于那些渴望深入了解浏览器工作原理的人来说,Pavel Panchekha 和 Chris Harrelson 编写的 Browser Engineering 是一个极佳的资源。请务必去看看。本文是对浏览器工作原理的一个概览。

Web 开发者通常将浏览器视为一个黑盒,它能神奇地将 HTML、CSS 和 JavaScript 转换为交互式的 Web 应用程序。事实上,像 Chrome (Chromium)、Firefox (Gecko) 或 Safari (WebKit) 这样的现代 Web 浏览器是一个极其复杂的软件。它协调网络通信、解析并执行代码、利用 GPU 加速渲染图形,并在沙箱进程中隔离内容以确保安全。

本文深入探讨了现代浏览器的工作原理——重点关注 Chromium 的架构和内部机制,同时指出其他引擎的不同之处。我们将探索从网络栈和解析流水线到通过 Blink 进行的渲染过程、通过 V8 进行的 JavaScript 引擎、模块加载、多进程架构、安全沙箱以及开发者工具。目标是提供一个对开发者友好的解释,揭开幕后发生的各种事情。

让我们开始浏览器内部机制的探索之旅。

网络与资源加载

每一次页面加载都始于浏览器的网络栈从 Web 获取资源。当你输入一个 URL 或点击一个链接时,浏览器的 UI 线程(运行在“浏览器进程”中)会启动一个导航请求。

浏览器进程是主要的控制进程,负责管理所有其他进程以及浏览器的用户界面。发生在特定网页标签页之外的所有事情都由浏览器进程控制。

具体步骤包括:

  1. URL 解析和安全检查:浏览器解析 URL 以确定协议(http、https 等)和目标域名。它还会判断输入是搜索查询还是 URL(例如在 Chrome 的地址栏中)。这里可能会检查黑名单等安全功能,以避免钓鱼网站。
  2. DNS 查询:网络栈将域名解析为 IP 地址(除非已有缓存)。这可能涉及联系 DNS 服务器。现代浏览器可能会使用操作系统的 DNS 服务,甚至配置 DNS over HTTPS (DoH),但最终它们会获得主机的 IP。
  3. 建立连接:如果不存在与服务器的开放连接,浏览器会打开一个。对于 HTTPS URL,这包括 TLS 握手,以安全地交换密钥并验证证书。浏览器的网络线程透明地处理 TCP/TLS 设置等协议。
  4. 发送 HTTP 请求:连接建立后,会发送一个 HTTP GET 请求(或其他方法)来获取资源。如果服务器支持,现在的浏览器默认使用 HTTP/2 或 HTTP/3,这允许在同一个连接上多路复用多个资源请求。这通过避免 HTTP/1.1 中每个主机约 6 个并行连接的旧限制来提高性能。例如,使用 HTTP/2,HTML、CSS、JS、图像都可以在一个 TCP/TLS 链路上并发获取;而使用 HTTP/3(基于 QUIC UDP),设置延迟会进一步降低。
  5. 接收响应:服务器返回 HTTP 状态码和头部信息,随后是响应体(HTML 内容、JSON 数据等)。浏览器读取响应流。如果 Content-Type 头部缺失或不正确,浏览器可能需要嗅探 MIME 类型,以决定如何处理内容。例如,如果响应看起来像 HTML 但没有标记,浏览器仍会尝试将其视为 HTML(遵循宽容的 Web 标准)。这里也有安全措施:网络层会检查 Content-Type,并可能拦截可疑的 MIME 不匹配或不允许的跨源数据(Chrome 的 CORB——跨源读取拦截——就是这样一种机制)。浏览器还会咨询安全浏览(Safe Browsing)或类似服务,以拦截已知的恶意负载。
  6. 重定向及后续步骤:如果响应是 HTTP 重定向(例如带有 Location 头部的 301 或 302),网络代码将遵循重定向(在通知 UI 线程后)并向新 URL 重复请求。只有在获得包含实际内容的最终响应后,浏览器才会继续处理该内容。

所有这些步骤都发生在网络栈中,在 Chromium 中,网络栈运行在专门的网络服务(Network Service)中(现在通常是一个独立的进程,作为 Chrome “服务化”努力的一部分)。浏览器进程的网络线程协调底层的套接字通信,并在底层使用操作系统的网络 API。重要的是,这种设计意味着渲染器(将执行页面代码的进程)不会直接访问网络——它请求浏览器进程获取所需内容,这在安全上是一个进步。

推测性加载与资源优化

现代浏览器在网络阶段实现了复杂的性能优化。当你悬停在链接上或开始输入 URL 时,Chrome 会主动执行 DNS 预取或打开 TCP 连接(使用预测器或预连接机制),这样如果你点击,部分延迟就已经被消除了。此外还有 HTTP 缓存:如果资源已缓存且新鲜,网络栈可以从浏览器缓存中满足请求,从而避免网络往返。

  • 预解析扫描器(Preload scanner)运作:Chromium 实现了一个复杂的预解析扫描器,它在主解析器之前对 HTML 标记进行令牌化。当主 HTML 解析器被 CSS 或同步 JavaScript 阻塞时,预解析扫描器会继续检查原始标记,以识别可以并行获取的图像、脚本和样式表等资源。这一机制对现代浏览器的性能至关重要,且无需开发者干预即可自动运行。预解析扫描器无法发现通过 JavaScript 注入的资源,这使得此类资源更有可能被串行加载而非并发加载。
  • 早期提示(Early Hints, HTTP 103)早期提示允许服务器在生成主响应时发送资源提示,使用 HTTP 103 状态码。这使得预连接和预加载提示可以在服务器“思考时间”内发送,从而可能将最大内容绘制(LCP)提高数百毫秒。早期提示仅适用于导航请求,支持预连接和预加载指令,但不支持预取。
  • 推测规则 API(Speculation Rules API)推测规则 API 是一项最近的 Web 标准,允许根据用户交互模式定义动态预取和预渲染 URL 的规则。与传统的链接预取不同,该 API 可以预渲染整个页面,包括执行 JavaScript,从而实现近乎瞬时的加载时间。该 API 在脚本元素或 HTTP 头部中使用 JSON 语法来指定应推测加载的 URL。Chrome 设有限制以防止过度使用,并根据紧急程度设置不同的容量。
  • HTTP/2 和 HTTP/3:大多数基于 Chromium 的浏览器和 Firefox 都完全支持 HTTP/2,HTTP/3(基于 QUIC)也得到了广泛支持(Chrome 为支持的网站默认启用)。这些协议通过允许并发传输和减少握手开销来改善页面加载。从开发者的角度来看,这意味着你可能不再需要精灵图(sprite sheets)或域名发散(domain sharding)技巧——浏览器可以在一个连接上高效地并行获取许多小文件。
  • 资源优先级:浏览器还会对某些资源进行优先级排序。通常,HTML 和 CSS 是高优先级(因为它们阻塞渲染),脚本可能是中等(如果标记为 defer/async 则适当调整),图像可能较低。Chromium 的网络栈分配权重,甚至可以取消或推迟请求,以优先处理初始渲染所需的内容。开发者可以使用 link rel=preloadFetch Priority 来影响资源优先级。

在网络阶段结束时,浏览器已经获得了页面的初始 HTML(假设这是一个 HTML 导航)。此时,Chrome 的浏览器进程会选择一个渲染器进程(Renderer Process)来处理内容。Chrome 通常会与网络请求并行启动一个新的渲染器进程(推测性地),以便在数据到达时做好准备。这个渲染器进程是隔离的(稍后会详细介绍多进程架构),并将接管页面的解析和渲染工作。

一旦响应被完全接收(或在流式传输过程中),浏览器进程就会提交导航:它通知渲染器进程接收字节流并开始处理页面。此时,地址栏会更新,并显示新站点的安全指示器(HTTPS 锁等)。现在,行动转移到了渲染器进程:解析 HTML、加载子资源、执行脚本并绘制页面。

解析 HTML、CSS 和 JavaScript

当渲染器进程接收到 HTML 内容时,其主线程开始根据 HTML 规范对其进行解析。解析 HTML 的结果是 DOM(文档对象模型)——一个代表页面结构的树状对象。解析是增量式的,可以与网络读取交错进行(浏览器以流式方式解析 HTML,因此即使在整个 HTML 文件下载完成之前,DOM 就可以开始构建)。

  • 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 解析才能恢复。这种脚本执行阻塞行为就是为什么在 head 中包含大型脚本文件会减慢页面渲染的原因——在脚本下载并运行之前,HTML 解析无法继续。

CSS 解析与 CSSOM

在处理 HTML 的同时,CSS 文本也必须被解析成浏览器可以处理的结构——通常称为 CSSOM(CSS 对象模型)。CSSOM 基本上是应用于文档的所有样式(规则、选择器、属性)的表示。浏览器的 CSS 解析器读取 CSS 文件(或 <style> 块)并将其转换为 CSS 规则列表(以及大量的布隆过滤器等,以加速样式匹配)。然后,在构建 DOM 的过程中(或者当 DOM 和 CSSOM 都准备就绪时),浏览器将为每个 DOM 节点计算样式。这一步通常被称为样式匹配(style resolution)或样式计算(style calculation)。浏览器结合 DOM 和 CSSOM 来确定每个元素适用的 CSS 规则以及最终的计算样式(在应用层叠、继承和默认样式之后)。输出通常被概念化为每个 DOM 节点与计算样式的关联(该元素的解析后的最终 CSS 属性,例如元素的颜色、字体、大小等)。

值得注意的是,即使没有任何作者定义的 CSS,每个元素都有默认的浏览器样式(用户代理样式表)。例如,在几乎所有浏览器中,<h1> 都有默认的字体大小和边距。浏览器内置的样式规则以最低优先级应用,它们确保了一些合理的默认呈现。开发者可以在 DevTools 中查看计算样式,以了解元素最终具有哪些 CSS 属性。样式计算步骤使用所有适用的样式(用户代理、用户样式、作者样式)来最终确定每个元素的样式。

  • 渲染阻塞行为:虽然 HTML 解析可以在 CSS 未完全加载的情况下进行,但存在一种渲染阻塞关系:浏览器通常会等待 CSS 加载完成(针对 <head> 中的 CSS)才进行首次渲染。这是因为应用不完整的样式表可能会导致样式未定义内容的闪烁(FOUC)。在实践中,如果 HTML 中一个未标记为 async/defer 的脚本出现在 CSS <link> 之前,它还会等待 CSS 加载完成后再执行脚本(因为脚本可能会通过 DOM API 查询样式信息)。作为经验法则,将样式表链接放在 head 中(它们阻塞渲染但需要尽早加载),并将非关键或大型脚本标记为 defer/async 或放在底部,这样它们就不会延迟 DOM 解析。

现在浏览器拥有了:(1) 从 HTML 构建的 DOM 树,(2) 解析后的 CSS 规则 (CSSOM),以及 (3) 每个 DOM 节点的计算样式。这些共同构成了下一阶段的基础:布局。但在继续之前,我们应该更详细地考虑 JavaScript 方面——特别是 JS 引擎(在 Chrome 中是 V8)如何执行代码。我们提到了脚本阻塞,但当 JS 运行时会发生什么?我们将在后面的章节专门讨论 V8 和 JS 执行的内部机制。目前,假设脚本运行时可能会修改 DOM 或 CSSOM(例如调用 document.createElement 或设置元素样式)。浏览器可能必须通过根据需要重新计算样式或布局来响应这些更改(如果重复执行,可能会产生性能成本)。解析期间脚本的初始运行通常包括设置事件处理程序或操作 DOM(例如模板化)。之后,页面通常会被完全解析,我们进入布局和渲染阶段。

样式与布局

在这个阶段,浏览器的渲染器进程知道了 DOM 的结构和每个元素的计算样式。接下来的问题是:所有这些元素在屏幕上的什么位置?它们有多大?这就是布局(Layout,也称为“重排”或“布局计算”)的工作。在这个阶段,浏览器根据 CSS 规则(流、盒模型、flexbox 或 grid 等)和 DOM 层级结构计算每个元素的几何形状——它们的大小和位置。

  • 布局树构建:浏览器遍历 DOM 树并生成一棵布局树(有时称为渲染树或框架树)。布局树在结构上与 DOM 树相似,但它忽略了非视觉元素(例如 scriptmeta 标签不会产生盒子),并且如果需要,可能会将某些元素拆分为多个盒子(例如,跨越多行的单个 HTML 元素可能对应多个布局盒)。布局树中的每个节点都持有该元素的计算样式,并具有节点内容(文本或图像)以及影响布局的计算属性(如宽度、高度、内边距等)等信息。

在布局过程中,浏览器计算每个元素盒子的确切位置(x, y 坐标)和大小(宽度、高度)。这涉及 CSS 规范定义的算法:例如,在正常的文档流中,块级元素自上而下堆叠,默认情况下每个占用全部宽度,而行内元素在行内流动并根据需要换行。现代布局模式如 flexbox 或 grid 有各自的算法。引擎必须考虑字体度量来换行(因此文本布局涉及测量文本运行),并且必须处理外边距、内边距、边框等。存在许多边缘情况(例如外边距折叠规则、浮动、从流中移除的绝对定位元素等),使得布局成为一个出人意料的复杂过程。即使是“简单”的自上而下布局,也必须计算文本中的换行,这取决于可用宽度和字体大小。浏览器引擎拥有专门的团队和多年的开发经验来准确高效地处理布局。

关于布局树的一些细节:

  • display: none 的元素会从布局树中完全移除(它们不产生任何盒子)。相比之下,仅仅是不可见的元素(例如 visibility: hidden)会获得一个布局盒(占用空间),只是稍后不进行绘制。
  • 生成内容的伪元素(如 ::before::after)包含在布局树中(因为它们确实有视觉盒子)。
  • 布局树节点知道它们的几何形状。例如,一个 <p> 元素的布局节点将知道它相对于视口的位置及其尺寸,并为其内部的每一行或行内盒提供子节点。

布局计算:布局通常是一个递归过程。从根节点(<html> 元素)开始,浏览器计算视口的大小(针对 <html>/<body>),然后在其内部布局子元素,依此类推。许多元素的大小取决于它们的子元素或父元素(例如,容器可能会扩展以适应子元素,或者子元素可能是其父元素宽度的 50%)。布局算法通常需要为浮动或某些复杂的交互进行多次传递,但通常它沿一个方向(自上而下)进行,并在需要时可能进行回溯。

到这一阶段结束时,页面上每个元素的位置和大小都是已知的。我们可以从概念上将页面视为一堆盒子(内部有文本或图像)。但我们仍然没有在屏幕上实际绘制任何东西——那是下一步,绘制

然而,有一个关键概念:布局可能是一项昂贵的操作,尤其是重复执行时。如果 JavaScript 稍后更改了元素的大小或添加了内容,它可能会强制对页面的部分或全部进行重新布局。开发者经常听到关于避免“布局抖动”(layout thrashing)的建议(例如在修改 DOM 后立即在 JS 中读取布局信息,这会强制进行同步重新计算)。浏览器尝试通过标记布局树中哪些部分是“脏”的并仅重新计算这些部分来进行优化。但在最坏的情况下,DOM 高层的更改可能需要为大型页面重新计算整个布局。这就是为什么为了获得更好的性能,应尽量减少昂贵的样式/布局操作。

样式和布局回顾:总结一下,浏览器从 HTML 和 CSS 构建了:

  1. DOM 树——结构和内容。
  2. CSSOM——解析后的 CSS 规则。
  3. 计算样式——将 CSS 规则匹配到每个 DOM 节点的结果。
  4. 布局树——过滤掉非视觉元素的 DOM 树,带有每个节点的几何形状。

每个阶段都建立在前一个阶段的基础上。如果任何阶段发生变化(例如,如果脚本更改了 DOM 或修改了 CSS 属性),后续阶段可能需要更新。例如,如果你更改了元素上的 CSS 类,浏览器可能会为该元素(以及如果继承发生变化时的子元素)重新计算样式,然后如果该样式更改影响了几何形状(如 display 或大小),则可能必须重新进行布局,然后必须重新绘制。这条链意味着布局和绘制依赖于最新的样式,依此类推。我们将在 DevTools 部分讨论这方面的性能影响(因为浏览器提供了工具来查看这些步骤何时发生以及耗时多久)。

布局完成后,我们进入下一个主要阶段:绘制

绘制、合成与 GPU 渲染

绘制(Painting)是将结构化的布局信息转化为屏幕上实际像素的过程。传统上,浏览器会遍历布局树并为每个节点发出绘制命令(“绘制背景,在这些坐标绘制文本,绘制图像”)。现代浏览器在概念上仍然这样做,但它们通常将工作拆分为多个阶段,并利用 GPU 来提高效率。

  • 绘制 / 光栅化:在渲染器的主线程上,布局完成后,Chrome 通过遍历布局树生成绘制记录(paint records,或显示列表)。这基本上是一个带有坐标的绘制操作列表,就像艺术家规划如何绘画场景一样:例如,“在 (x,y) 处绘制宽度为 W、高度为 H、填充颜色为蓝色的矩形,然后在 (x2,y2) 处使用字体 XYZ 绘制文本 'Hello',然后在……处绘制图像”等等。这个列表遵循正确的 z-index 顺序(以便重叠元素正确绘制)。例如,如果一个元素具有更高的 z-index,其绘制命令将排在后面(覆盖在)较低 z-index 的内容之上。浏览器必须考虑堆叠上下文、透明度等,以获得正确的顺序。

过去,浏览器可能只是按顺序直接在屏幕上绘制每个元素。但如果页面的某些部分发生变化,这种方法可能会效率低下(你必须重新绘制所有内容)。现代浏览器通常会记录这些绘制命令,然后使用合成(compositing)步骤来组装最终图像,尤其是在使用 GPU 加速时。

  • 分层与合成:合成是一种优化技术,将页面拆分为多个可以独立处理的层。例如,具有 CSS 变换或动画的定位元素可能会获得自己的层。层就像独立的“草稿画布”——浏览器可以分别光栅化(绘制)每个层,然后合成器可以在屏幕上将它们混合在一起,通常使用 GPU。

在 Chromium 的流水线中,生成绘制记录后,会有一个步骤来构建层树(这对应于哪些元素在哪个层上)。某些层是自动创建的(例如视频元素、画布或具有某些 CSS 的元素将被提升为层),开发者可以通过使用 will-changetransform 等 CSS 属性来提示浏览器创建一个层。层之所以有用,是因为层上的移动或透明度更改可以被合成(即仅重新渲染或移动该层),而无需重新绘制整个页面。然而,过多的层可能会占用大量内存并增加开销,因此浏览器会谨慎选择。

确定层之后,Chrome 的主线程会将其交给合成器线程(Compositor thread)。合成器线程运行在渲染器进程中,但与主线程分离(因此即使主 JS 线程繁忙,它也可以继续工作,这对于平滑滚动和动画非常有用)。合成器线程的工作是接收这些层,将它们光栅化(将绘图转换为实际的像素位图),并将它们合成帧。

合成器随后组装一个合成器帧(compositor frame)——这基本上是发给浏览器进程的一条消息,其中包含构成屏幕的所有四边形(层的切片)、它们的位置等。这个合成器帧通过 IPC 提交回浏览器进程,最终浏览器的 GPU 进程(Chrome 中用于访问 GPU 的独立进程)将接收这些信息并进行显示。浏览器进程自身的 UI(如标签栏)也是通过合成器帧绘制的,它们都在最后一步混合在一起。GPU 进程接收到这些帧,并使用 GPU(通过 OpenGL/DirectX/Metal 等)将它们合成——基本上是在屏幕上的正确位置绘制每个纹理,应用变换等,速度非常快。结果就是你看到的最终图像。

当你滚动或进行动画处理时,这种流水线的优势就显现出来了。例如,滚动页面大多只是在较大的页面纹理上更改视口。合成器只需移动层的位置并请求 GPU 重新绘制进入视图的新部分,而无需主线程重新绘制所有内容。如果动画只是一个变换(例如移动一个属于自己层的元素),合成器线程可以在每一帧更新该元素的位置并生成新帧,而无需涉及主线程或重新运行样式和布局。这就是为什么推荐使用“仅合成”(compositing-only)的动画(更改 transformopacity,它们不会触发布局)以获得更好的性能——即使主线程繁忙,它们也可以以 60 FPS 平滑运行。相比之下,对 heightbackground-color 等属性进行动画处理可能会强制每一帧重新布局或重新绘制,如果主线程跟不上,就会产生卡顿。

简而言之,Chrome 的渲染流水线是:DOM → 样式 → 布局 → 绘制(记录显示项) → 分层 → 光栅化(切片) → 合成 (GPU)。Firefox 的流水线在显示列表阶段之前在概念上是相似的,但通过 WebRender,它跳过了显式的层构建,而是将显示列表发送到 GPU 进程,后者随后使用 GPU 着色器处理几乎所有的绘图。WebKit (Safari) 也使用多线程合成器和通过 macOS 上的“CALayers”进行 GPU 渲染。因此,所有现代引擎都利用 GPU 进行渲染,特别是用于合成和光栅化图形密集型部分,以实现高帧率并减轻 CPU 的负担。

在继续之前,让我们更详细地讨论一下 GPU 的角色。在 Chromium 中,GPU 进程是一个独立的进程,其工作是与图形硬件交互。它接收来自所有渲染器合成器以及浏览器 UI 的绘制命令(大多是高级命令,如“在这些坐标绘制这些纹理”)。然后它将其转换为实际的 GPU API 调用。通过将其隔离在一个进程中,一个导致崩溃的错误 GPU 驱动程序不会拖垮整个浏览器——只会导致 GPU 进程崩溃,而它可以重新启动。此外,它还提供了一个沙箱边界(由于 GPU 处理潜在的不受信任内容,如画布绘图、WebGL 等,驱动程序中曾出现过安全漏洞——在进程外运行它们可以降低风险)。

合成的结果最终被发送到显示器(浏览器运行的操作系统窗口或上下文)。对于每个动画帧(目标是 60fps 或每帧 16.7ms 以获得平滑结果),合成器旨在生成一个帧。如果主线程繁忙(例如 JavaScript 执行时间过长),合成器可能会跳帧或无法更新,从而导致可见的卡顿。开发者工具可以在性能时间线中显示掉帧情况。requestAnimationFrame 等技术将 JS 更新与帧边界对齐,以帮助实现平滑渲染。

总结一下,浏览器的渲染引擎仔细地将页面内容和样式分解为一组几何形状(布局)和绘图指令,然后使用层和 GPU 合成高效地将其转换为你看到的像素。这种复杂的流水线使得 Web 上丰富的图形和动画能够以交互式帧率运行。接下来,我们将窥探 JavaScript 引擎,以了解浏览器如何执行脚本(到目前为止我们一直将其视为黑盒)。

深入 JavaScript 引擎 (V8)

JavaScript 驱动了网页的交互行为。在 Chromium 浏览器中,V8 引擎执行 JavaScript(和 WebAssembly)。了解 V8 的工作原理可以帮助开发者编写高性能的 JS。虽然详尽的深入探讨可以写成一本书,但我们将重点关注 JS 执行流水线的关键阶段:解析/编译代码、执行代码以及管理内存(垃圾回收)。我们还将注意到 V8 如何处理现代特性,如即时编译(JIT)分层和 ES 模块。

现代 V8 解析与编译流水线

  • 后台编译:从 Chrome 66 开始,V8 在后台线程上编译 JavaScript 源代码,这减少了在主线程上花费的编译时间,在典型网站上减少了 5% 到 20%。自 41 版本以来,Chrome 就支持通过 V8 的 StreamedSource API 在后台线程上解析 JavaScript 源文件。V8 可以在从网络下载第一个数据块后立即开始解析 JavaScript 源代码,并在流式传输文件时并行继续解析。几乎所有的脚本编译都发生在后台线程上,只有简短的 AST 内部化和字节码最终确定步骤在脚本执行前发生在主线程上。目前,顶级脚本代码和立即调用的函数表达式在后台线程上编译,而内部函数在首次执行时仍在主线程上延迟编译。
  • 解析与字节码:当遇到 <script> 时(无论是在 HTML 解析期间还是稍后加载),V8 首先解析 JavaScript 源代码。这会生成代码的**抽象语法树(AST)**表示。预解析器(preparser)是解析器的一个副本,它执行跳过函数所需的最低限度工作。它验证函数在语法上是否有效,并生成外部函数正确编译所需的所有信息。当稍后调用预解析的函数时,它会被完全解析并按需编译。

V8 不直接从 AST 进行解释,而是使用一个名为 Ignition 的字节码解释器(2016 年引入)。Ignition 将 JavaScript 编译成紧凑的字节码格式,这基本上是虚拟机的一系列指令。这种初始编译非常快,且字节码相当底层(Ignition 是一个基于寄存器的虚拟机)。目标是快速开始执行代码,并将前期成本降至最低(这对页面加载时间很重要)。

  • AST 内部化过程:AST 内部化涉及在 V8 堆上分配字面量对象(字符串、数字、对象字面量模板),供生成的字节码使用。为了实现后台编译,这一过程被移到了编译流水线的后期,即字节码编译之后,这需要修改以访问嵌入在 AST 中的原始字面量值,而不是堆上的内部化值。
  • 显式编译提示(Explicit Compile Hints):V8 引入了一项名为“显式编译提示”的新功能,允许开发者通过预先编译(eager compilation)指示 V8 在加载时立即解析和编译代码。带有此提示的文件在后台线程上编译,而延迟编译则发生在主线程上。对热门网页的实验显示,在 20 个案例中有 17 个性能得到了提升,前台解析和编译时间平均减少了 630 毫秒。开发者可以使用特殊注释向 JavaScript 文件添加显式编译提示,以便为关键代码路径启用后台线程上的预先编译。
  • 扫描器和解析器优化:V8 的扫描器得到了显著优化,带来了全面的改进:单令牌扫描提高了约 1.4 倍,字符串扫描提高了 1.3 倍,多行注释扫描提高了 2.1 倍,标识符扫描根据长度提高了 1.2-1.5 倍。

当脚本运行时,Ignition 解释字节码并执行程序。解释通常比优化的机器码慢,但它允许引擎开始运行,并收集有关代码行为的分析信息。随着代码运行,V8 收集有关其使用方式的数据:变量类型、哪些函数被频繁调用等。这些信息将用于在后续步骤中使代码运行得更快。

JIT 编译分层

V8 并不止步于解释。它采用了多层即时(Just-In-Time)编译器来加速热点代码。其核心思想是:在运行频繁的代码上投入更多的编译精力以使其更快,同时不浪费时间优化只运行一次的代码。

  1. Ignition(解释字节码)。
  2. Sparkplug:V8 的基准 JIT,称为 Sparkplug(约 2021 年推出)。Sparkplug 接收字节码并快速将其编译为机器码,不进行繁重的优化。这会产生比解释更快的原生代码,但 Sparkplug 不进行深度分析——它的目的是几乎像解释器一样快速启动,但生成的代码运行得更快一些。
  3. Maglev:2023 年,V8 引入了 Maglev,这是一个中层优化编译器,目前已积极部署。Maglev 生成代码的速度比 Sparkplug 慢近 20 倍,但比 TurboFan 快 10 到 100 倍,有效地为那些中等热度但不足以进行 TurboFan 优化的函数填补了空白。当 TurboFan 的编译成本过高时,Maglev 也会发挥作用。从 Chrome M117 开始,Maglev 可以处理许多情况,通过弥合基准 JIT 和最高层 JIT 之间的差距,为在“温”代码(不冷也不超热)中花费时间的 Web 应用带来更快的启动速度。
  4. TurboFan:随着函数或循环被多次执行,V8 将启用其最强大的优化编译器。TurboFan 利用收集到的类型反馈生成高度优化的机器码,应用高级优化(内联函数、消除边界检查等)。如果假设成立,这种优化后的代码可以运行得非常快。

因此,V8 现在实际上有四个执行层:Ignition 解释器、Sparkplug 基准 JIT、Maglev 优化 JIT 和 TurboFan 优化 JIT。这类似于 Java 的 HotSpot VM 具有多个 JIT 级别(C1 和 C2)。引擎可以根据执行概况动态决定优化哪些函数以及何时优化。如果一个函数突然被调用了一百万次,它很可能会被 TurboFan 优化以获得最大速度。

英特尔还开发了配置文件引导的分层(Profile-Guided Tiering),增强了 V8 的效率,在 Speedometer 3 基准测试中带来了约 5% 的提升。最近的 V8 更新包括静态根优化(static roots optimization),允许在编译时准确预测常用对象的内存地址,从而显著提高访问速度。

JIT 优化面临的一个挑战是 JavaScript 是动态类型的。V8 可能会在某些假设下优化代码(例如,这个变量始终是整数)。如果稍后的调用违反了这些假设(例如变量变成了字符串),优化后的代码就是无效的。V8 随后会执行去优化(deoptimization):它回退到较低优化版本(或根据新假设重新生成代码)。这一机制依赖于“内联缓存”和类型反馈来快速适应。去优化的存在意味着如果你的代码具有不可预测的类型,有时无法维持峰值性能,但通常 V8 会尝试处理典型模式(例如函数始终被传递相同类型的对象)。

字节码刷新与内存管理

V8 实现了字节码刷新(bytecode flushing):如果一个函数在多次垃圾回收后仍未使用,其字节码将被回收。再次执行时,解析器使用之前存储的结果更快地重新生成字节码。这一机制对内存管理至关重要,但在边缘情况下可能导致解析不一致。

内存管理(垃圾回收):V8 使用垃圾回收器自动管理 JS 对象的内存。多年来,V8 的 GC 已演变为所谓的 Orinoco GC,它是一个分代的、增量的且并发的垃圾回收器。关键点包括:

  • 分代式:V8 按年龄隔离对象。新对象分配在“新生代”(或“托儿所”)。这些对象通过非常快速的清除算法(scavenging algorithm)频繁回收(将存活对象复制到新空间并回收其余部分)。存活了足够多周期的对象会被提升到“老生代”。
  • 增量式 GC:V8 尽可能以小片断而非一次大停顿来执行垃圾回收。这种增量方法将工作分散开来以避免卡顿。例如,它可以在脚本执行之间交错进行一些标记工作,利用空闲时间。
  • 并行 GC:在多核机器上,V8 也可以在并行线程中执行 GC 的某些部分(如标记或清理)。

最终效果是,V8 团队多年来大幅缩短了 GC 停顿时间,使得垃圾回收在大型应用中也几乎察觉不到。次要 GC(新生代清除)通常发生得非常快。主要 GC(老生代)现在较少发生且大多是并发的。如果你打开 Chrome 的任务管理器或 DevTools 的 Memory 面板,你可能会看到 V8 的堆被分为“Young space”和“Old space”,这反映了这种分代设计。

对于开发者来说,这意味着不需要手动管理内存,但你仍应留意:例如,避免在紧密循环中创建大量短命对象(尽管 V8 非常擅长处理短命对象),并意识到持有大型数据结构会使其留在内存中。DevTools 等工具可以强制执行垃圾回收或记录内存概况以查看内存占用情况。

V8 与 Web API:值得一提的是,V8 涵盖了核心 JavaScript 语言和运行时(执行、标准 JS 对象等),但许多“浏览器 API”(如 DOM 方法、alert()、网络 XHR/fetch 等)并不是 V8 本身的一部分。这些由浏览器提供,并通过绑定(bindings)暴露给 JS。例如,当你调用 document.querySelector 时,它在底层进入了引擎与 C++ DOM 实现的绑定。V8 负责调用 C++ 并获取结果,并且有大量的机制来使这个边界变得快速(Chrome 使用 IDL 来生成高效的绑定)。

在了解了浏览器如何获取资源、解析 HTML/CSS、计算布局、使用 GPU 绘制以及运行 JS 之后,我们已经掌握了加载和渲染页面的全过程。但还有更多值得探索的地方:ES 模块如何处理(因为模块涉及其自身的加载机制)、浏览器的多进程架构是如何组织的,以及沙箱和站点隔离等安全特性如何运作。

模块加载与导入映射 (Import Maps)

与传统的 <script> 标签相比,JavaScript 模块(ES6 模块)引入了不同的加载和执行模型。模块不是一个可能创建全局变量的大型脚本文件,而是显式导入/导出值的文件。让我们看看浏览器(特别是 Chrome 中的 V8)如何加载模块,以及动态 import() 和导入映射等特性如何发挥作用。

  • 静态模块导入:当浏览器遇到 <script type="module" src="main.js"> 时,它将 main.js 视为模块入口点。加载过程如下:浏览器获取 main.js,然后将其解析为 ES 模块。在解析期间,它会发现任何 import 语句(例如 import { foo } from './utils.js';)。浏览器不会立即执行代码,而是构建一个模块依赖图。它将启动获取任何导入的模块(在本例中为 utils.js),并递归地解析每个模块的导入、获取,依此类推。这是异步发生的。只有当整个模块图都被获取并解析后,浏览器才能评估模块。模块脚本本质上是延迟执行的——浏览器在所有依赖项就绪之前不会执行模块代码。然后它按依赖顺序执行它们(确保如果模块 A 导入 B,则 B 先运行)。

这种静态导入过程就是为什么在某些情况下无法从 file:// 加载 ES 模块(除非允许),以及为什么它们默认对跨源脚本要求 CORS 的原因——浏览器正在主动链接和加载多个文件,而不仅仅是在页面中丢入一个 <script>

  • 动态 import():除了静态 import 语句外,ES2020 还引入了 import(moduleSpecifier) 表达式。这允许代码飞速加载模块(返回一个解析为模块导出的 Promise)。例如,你可以根据用户操作执行 const module = await import('./analytics.js'),从而对应用进行代码拆分。在底层,import() 触发浏览器获取请求的模块(及其依赖项,如果尚未加载),然后实例化并执行它,并使用模块命名空间对象解析 Promise。V8 和浏览器在这里协同工作:浏览器的模块加载器处理获取和解析,V8 在就绪后处理编译和执行。动态 import 非常强大,因为它也可以在非模块脚本中使用(例如,内联脚本可以动态导入模块)。它本质上赋予了开发者按需加载 JS 的控制权。与静态导入的区别在于,静态导入是提前解析的(在任何模块代码运行之前,整个图都已加载),而动态 import 的行为更像是在运行时加载新脚本(除了具有模块语义和 Promise)。
  • 导入映射 (Import Maps):浏览器中 ES 模块面临的一个挑战是模块说明符(module specifiers)。在 Node 或打包工具中,你经常通过包名导入(例如 import { compile } from 'react')。在 Web 上,如果没有打包工具,'react' 不是一个有效的 URL——浏览器会将其视为相对路径(这会失败)。这就是导入映射发挥作用的地方。导入映射是一个 JSON 配置,告诉浏览器如何将模块说明符解析为真实的 URL。它通过 HTML 中的 <script type="importmap"> 标签提供。例如,导入映射可能会说明说明符 "react" 映射到 "https://cdn.example.com/react@19.0.0/index.js"(指向实际脚本的完整 URL)。然后,当任何模块执行 import 'react' 时,浏览器使用该映射找到 URL 并加载它。本质上,导入映射允许“裸”说明符(如包名)通过映射到 CDN URL 或本地路径在 Web 上工作。

导入映射一直是未打包开发的规则改变者。自 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”拓扑排序)。

总结一下,浏览器中的模块加载是网络(获取模块文件)、模块解析器(使用导入映射或标准 URL 解析)和 JS 引擎(按正确顺序编译和评估模块)之间的一场协调舞蹈。它比旧的 <script> 加载更复杂,但带来了更模块化和可维护的代码结构。对于开发者来说,关键要点是:使用模块来组织代码,如果你想要裸导入则使用导入映射,并知道你可以通过 import() 在需要时动态加载模块。浏览器将处理确保一切按正确顺序执行的繁重工作。

现在我们已经了解了单个页面的内部机制,让我们放大视野,检查允许多个页面、标签页和 Web 应用同时运行而互不干扰的浏览器架构。这带我们进入了多进程模型。

浏览器多进程架构

现代浏览器(Chrome、Firefox、Safari、Edge 等)都使用多进程架构来实现稳定性、安全性和性能隔离。不同于将整个浏览器作为一个巨大的进程运行(早期浏览器就是这样做的),浏览器的不同方面运行在不同的进程中。Chrome 在 2008 年率先采用了这种方法,其他浏览器也以各种形式效仿。让我们重点关注 Chromium 的架构,并指出 Firefox 和 Safari 的不同之处。

在 Chromium(Chrome、Edge、Brave 等)中,有一个核心的浏览器进程(Browser Process)。该浏览器进程负责 UI(地址栏、书签、菜单——所有浏览器外壳部分),并负责协调资源加载和导航等高级任务。当你打开 Chrome 并在操作系统任务管理器中看到一个条目时,那就是浏览器进程。它也是产生其他进程的父进程。

然后,对于每个标签页(有时是标签页中的每个站点),Chrome 会创建一个渲染器进程(Renderer Process)。渲染器进程为该标签页的内容运行 Blink 渲染引擎和 V8 JS 引擎。通常,每个标签页至少获得一个渲染器进程。

如果你打开了多个不相关的站点,它们将位于不同的进程中(站点 A 在一个进程,站点 B 在另一个进程,依此类推)。Chrome 甚至将跨源 iframe 隔离到单独的进程中(稍后在站点隔离中详细介绍)。渲染器进程是沙箱化的,不能直接任意访问你的文件系统或网络——它必须通过浏览器进程进行这些特权操作。

Chrome 中的其他关键进程包括:

  • GPU 进程:专门用于与 GPU 通信的进程(如前所述)。来自渲染器合成器的所有渲染和合成请求都发送到 GPU 进程,由其发出实际的图形 API 调用。该进程是沙箱化的且独立的,因此 GPU 崩溃不会导致渲染器崩溃。
  • 网络进程:在旧版 Chrome 中,网络是浏览器进程中的一个线程,但现在通过“服务化”通常是一个独立的进程。该进程处理网络请求、DNS 等,并且可以单独进行沙箱化。
  • 实用程序进程(Utility Processes):这些用于 Chrome 可能卸载的各种服务(如音频播放、图像解码等)。
  • 插件进程:在 Flash 和 NPAPI 插件时代,插件运行在自己的进程中。Flash 现在已弃用,因此这不太相关,但架构仍为插件不在主浏览器进程中运行做好了准备。
  • 扩展进程:Chrome 扩展(本质上是可以作用于网页或浏览器的脚本)也运行在独立的进程中,为了安全与网站隔离。

一个简化的视图是:一个浏览器进程协调多个渲染器进程(每个标签页或每个站点实例一个),外加一个 GPU 进程和几个其他服务进程。Chrome 的任务管理器(Windows 上按 Shift+Esc 或通过“更多工具 > 任务管理器”)会列出每个进程类型及其内存使用情况。

多进程的好处

主要好处包括:

  1. 稳定性:如果一个网页(渲染器进程)崩溃或内存泄漏,它不会导致整个浏览器崩溃——你可以关闭该标签页,其余部分保持活跃。在单进程浏览器中,一个糟糕的脚本就可能摧毁一切。当单个标签页的进程死亡时,Chrome 可以显示“喔唷,崩溃了!”错误,你可以独立重新加载它。
  2. 安全(沙箱化):通过在受限进程中运行 Web 内容,浏览器可以限制该代码在系统上能做的事情。即使攻击者在渲染引擎中发现了漏洞,他们也会被困在沙箱中——渲染器进程通常无法读取你的文件,也无法任意打开网络连接或启动程序。它必须向浏览器进程请求文件访问等操作,而这些请求可以被验证或拒绝。这种沙箱是在操作系统级别强制执行的(根据平台使用作业对象、seccomp 过滤器等)。
  3. 性能隔离:一个标签页中的密集工作(沉重的 Web 应用或死循环)大多被限制在该标签页的渲染器进程中。其他标签页(不同进程)可以保持响应,因为它们的进程没有被阻塞。此外,操作系统可以将进程调度到不同的 CPU 核心上——因此两个沉重的页面在多核系统上并行运行的效果比它们是单个进程的线程要好。
  • 站点隔离(Site Isolation):最初,Chrome 的模型是每个标签页一个进程。随着时间的推移,他们将其演变为每个站点一个进程(特别是在 Spectre 之后——见下一节关于安全的内容)。截至 2024 年,站点隔离已在桌面平台的 99% Chrome 用户中默认启用,Android 支持也在不断完善。这意味着如果你有两个标签页都打开了 example.com,Chrome 可能会决定为两者使用同一个进程(为了节省内存,因为它们是同一个站点,放在一起风险较小)。但一个带有 example.comevil.com iframe 的标签页默认会将 evil.com 的 iframe 放在与父页面不同的进程中(以保护 example.com 的数据)。这种强制执行就是 Chrome 所说的“严格站点隔离”(约在 Chrome 67 左右作为默认设置推出)。站点隔离导致 Chrome 由于进程创建增加而多消耗 10-13% 的系统资源,但提供了至关重要的安全收益。

Firefox 的架构称为 Electrolysis (e10s),历史上曾是所有标签页共用一个内容进程(多年来 Firefox 都是单进程,直到 2017 年左右才启用了几个内容进程)。截至 2021 年,Firefox 使用多个内容进程(默认 8 个用于 Web 内容)。通过 Project Fission(站点隔离),Firefox 正在转向类似的站点隔离——它可以为跨站 iframe 开启新进程,并在 Firefox 108+ 中默认启用了站点隔离,将进程数量增加到可能像 Chrome 一样每个站点一个。Firefox 也有一个 GPU 进程(用于 WebRender 和合成)和一个独立的网络进程,类似于 Chrome 的划分。因此在实践中,Firefox 现在拥有一个非常类似 Chrome 的模型。

Safari (WebKit) 同样转向了多进程模型 (WebKit2),其中每个标签页的内容位于独立的 WebContent 进程中,中央 UI 进程控制它们。Safari 的 WebContent 进程也是沙箱化的,如果不通过 UI 进程中介,无法直接访问设备或文件。Safari 还有一个共享的网络进程(可能还有其他助手)。因此,虽然实现细节不同,但概念是一致的:将每个网页的代码隔离在自己的沙箱环境中。

  • 进程间通信 (IPC):这些进程如何互相交谈?浏览器使用 IPC 机制(在 Windows 上通常是命名管道或其他系统 IPC;在 Linux 上可能是 Unix 域套接字或共享内存;Chrome 有自己的 IPC 库 Mojo)。例如,当网络响应到达网络进程时,它需要被交付给正确的渲染器进程(通过浏览器进程协调)。同样,当你执行 DOM fetch() 时,JS 引擎将调用网络 API,该 API 向网络进程发送请求,依此类推。IPC 增加了复杂性,但浏览器进行了大量优化(例如使用共享内存高效传输图像等大数据,并发布异步消息以避免阻塞)。
  • 进程分配策略:Chrome 并不总是为每个标签页都创建一个全新的进程——存在限制(特别是在内存较低的设备上,它可能会为同站标签页重用进程)。如果你打开另一个同站标签页,Chrome 会重用现有的渲染器以节省内存。它还对总进程数有限制(可根据 RAM 大小缩放)。当达到限制时,它可能会开始将多个不相关的站点放在一个进程中,尽管如果启用了站点隔离,它会努力避免混合站点。在 Android 上,由于内存限制,Chrome 使用的进程较少(内容进程通常最多 5-6 个)。
  • 服务化 (Servicification):Chromium 中的另一个概念是将浏览器组件拆分为可以运行在独立进程中的服务。例如,网络服务被制成一个可以进程外运行的独立模块。其理念是模块化——强大的系统可以在各自的进程中运行每个服务,而受限设备可能会将某些服务合并回一个进程以减少开销。

要点:Chromium 的架构旨在将浏览器 UI 和每个站点运行在不同的沙箱中,使用进程作为隔离边界。Firefox 和 Safari 也趋向于类似的设计。这种架构以增加内存使用为代价,极大地提高了安全性和可靠性。Web 内容进程被视为不可信的,这就是站点隔离发挥作用的地方,甚至在独立进程中将不同的源相互隔离。

站点隔离与沙箱化

站点隔离和沙箱化是建立在多进程基础上的安全特性。它们旨在确保即使恶意代码在浏览器中运行,也无法轻易窃取其他站点的数据或访问你的系统。

  • 站点隔离:我们已经提到过——这意味着不同的网站(更严格地说是不同的站点)运行在不同的渲染器进程中。在 2018 年 Spectre 漏洞曝光后,Chrome 的站点隔离得到了加强。Spectre 表明恶意 JavaScript 可能会读取它不应读取的内存(通过利用 CPU 的推测执行)。如果两个站点在同一个进程中,恶意站点就可以利用 Spectre 窥探敏感站点(如银行站点)的内存。唯一稳健的解决方案就是根本不让它们共享进程。因此 Chrome 将站点隔离设为默认:每个站点获得自己的进程,包括跨源 iframe。Firefox 也紧随其后推出了 Project Fission。这与过去相比是一个重大变化,过去如果你有一个父页面和来自不同域的多个 iframe,它们可能都住在同一个进程中。现在,这些 iframe 会被拆分,例如好页面上的 <iframe src="https://evil.com"> 会被强制进入不同的进程,防止即使是底层的攻击在它们之间泄露信息。

从开发者的角度来看,站点隔离大多是透明的。一个影响是嵌入式 iframe 与其父页面之间的通信现在可能跨越进程边界,因此它们之间的 postMessage 在底层是通过 IPC 实现的。但浏览器使这一切变得无缝;你作为开发者只需照常使用 API。

  • 沙箱化:每个渲染器进程(以及其他辅助进程)都运行在权限受限的沙箱中。例如,在 Windows 上,Chrome 使用作业对象并降低权限,使渲染器无法调用大多数访问系统的 Win32 API。在 Linux 上,它使用命名空间和 seccomp 过滤器来限制系统调用。渲染器基本上可以计算和渲染内容,但如果它尝试打开文件、摄像头或麦克风,它将被拦截(除非通过向浏览器进程请求并获得用户许可的正确渠道)。WebKit 的文档明确指出,WebContent 进程无法直接访问文件系统、剪贴板、设备等——它们必须通过中介 UI 进程进行请求。这就是为什么当站点尝试使用你的麦克风时,权限提示是由浏览器 UI(浏览器进程)显示的,如果允许,实际的录音是在受控进程中完成的。沙箱是至关重要的防线。即使攻击者发现了在渲染器中运行原生代码的漏洞,他们随后也会面临沙箱屏障——他们需要第二个漏洞(“逃逸”)才能突破到系统。这种分层方法(称为站点隔离 + 沙箱)是浏览器安全的最高水平。
  • 进程外 iframe (OOPIF):在 Chrome 的站点隔离实现中,他们发明了 OOPIF 这个术语。从用户的角度来看,没有任何变化,但在 Chrome 的内部架构中,页面的每个框架都可能由不同的渲染器进程支持。顶级框架和同站框架共享一个进程;跨站框架使用不同的进程。所有这些进程“协作”渲染单个标签页的内容,由浏览器进程协调。这相当复杂,但 Chrome 有一个可以跨越进程的框架树。这意味着你的一个标签页可能运行着 N 个进程。它们通过 IPC 进行通信,处理跨边界的 DOM 事件或涉及跨上下文的某些 JavaScript 调用。在 Spectre 之后,Web 平台(通过 COOP/COEP、SharedArrayBuffer 等规范)正在考虑这些约束进行演进。
  • 内存与性能成本:站点隔离确实增加了内存使用,因为使用了更多进程。Chrome 开发者指出,在某些情况下可能会有 10-20% 的内存开销。他们通过对同站进行“尽力而为的进程合并”以及限制可产生的进程数量来减轻部分负担。

跨站预取出于隐私原因受到限制,目前仅在用户未为目标站点设置 Cookie 的情况下有效,以防止站点通过可能永远不会被访问的预取页面跟踪用户活动。

总而言之,站点隔离确保了最小权限原则的应用:源 A 的代码无法访问源 B 的数据,除非通过具有明确许可的 Web API(如 postMessage 或分区的存储)。沙箱确保了即使代码是恶意的,它也无法直接触碰你的系统。这些措施使得浏览器漏洞利用变得困难得多——攻击者现在通常需要多个链式漏洞(一个破坏渲染器,一个逃逸沙箱)才能造成严重破坏,这显著提高了门槛。

作为 Web 开发者,你可能不会直接感受到站点隔离,但你通过更安全的 Web 从中受益。需要注意的一点是,跨源交互可能会有略微更多的开销(因为 IPC),并且某些优化(如进程内脚本共享)在跨源之间是不可能的。但浏览器正在不断优化进程间的消息传递,以尽量减少性能影响。

比较 Chromium、Gecko 和 WebKit

我们主要描述了 Chrome/Chromium 的行为(用于 HTML/CSS 的 Blink 引擎,用于 JS 的 V8,通过 Aura/Chromium 基础设施实现的多进程)。其他主要引擎——Mozilla 的 Gecko(用于 Firefox)和 Apple 的 WebKit(用于 Safari)——拥有相同的基本目标和大致相似的流水线,但存在值得注意的差异和历史分歧。

  • 共同概念:所有引擎都将 HTML 解析为 DOM,将 CSS 解析为样式数据,计算布局,并进行绘制/合成。所有引擎都有带有 JIT 和垃圾回收的 JS 引擎。所有现代引擎都是多进程(或至少是多线程)的,以实现并行和安全。

CSS/样式系统的差异

一个有趣的差异是渲染引擎如何实现 CSS 样式计算:

  • Blink (Chromium):在 C++ 中使用单线程样式引擎(历史上基于 WebKit)。它按顺序为 DOM 树计算样式。它具有增量样式失效优化,但总的来说是一个线程在工作(除了动画中的一些微小并行化)。
  • Gecko (Firefox):在 Quantum 项目(2017 年)中,Firefox 集成了 Stylo,这是一个用 Rust 编写的新 CSS 引擎,它是多线程的。Firefox 可以利用所有 CPU 核心并行计算不同 DOM 子树的样式。这是 Gecko 中 CSS 性能的一次重大提升。因此,Firefox 中的样式重新计算可能会使用 4 个核心来完成 Blink 在 1 个核心上完成的工作。这是 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 赶上——通常它们通过标准组织共享代码)。

  • Safari (WebKit) 使用的方法与旧版 Chrome 更相似:它有一个带有层的合成器(称为 CALayer,因为在 Mac 和 iOS 上它使用 Core Animation 层)。Safari 很早就转向了 GPU 合成(2009 年的 iPhone OS 和 Safari 4 就已经为某些 CSS 如变换提供了硬件加速合成)。Safari 和 Chrome 虽有分歧,但在概念上都进行切片和合成。Safari 还将大量工作卸载到 GPU(并使用切片,特别是在 iOS 上,切片绘制对于平滑滚动至关重要)。
  • 移动端优化:每个引擎都有针对移动端的特殊情况。例如,WebKit 具有用于滚动的切片覆盖概念(历史上用于 iOS 的 UIWebView)。Android 上的 Chrome 使用“切片”并尝试保持光栅化任务最小化以达到帧率。Firefox 的 WebRender 源自移动优先的 Servo 项目。

JavaScript 引擎

  • V8 (Chromium):如前所述,包含 Ignition、Sparkplug、TurboFan,以及 2023 年加入的 Maglev。
  • SpiderMonkey (Firefox):历史上它有一个解释器,然后是一个基准 JIT 和一个优化 JIT (IonMonkey)。最近的工作 (Warp) 更改了 JIT 分层的工作方式,可能简化了 Ion,并使其更像 TurboFan 使用缓存字节码和类型信息的方法。SpiderMonkey 也有不同的 GC(也是分代的,自 2012 年起称为增量 GC,现在大多是增量/并发的)。
  • JavaScriptCore (Safari):如前所述,它有 4 层(LLInt, Baseline, DFG, FTL)。它使用不同的 GC(WebKit 的 GC 是分代标记-清除,历史上称为 Butterfly 或 Boehm 变体,现在是 bmalloc 等)。JSC 的 FTL 使用 LLVM 进行优化,这是独一无二的(V8 和 SM 有自己的编译器,JSC 在一层中利用了 LLVM)。这可以产生非常快的代码,但编译开销较大。JSC 倾向于在某些基准测试中优先考虑峰值性能。

在 ES 特性方面,得益于 test262 和彼此间的竞争,这三个引擎基本上都紧跟最新标准。

多进程模型差异

  • Chrome:每个标签页通常独立,源级别的站点隔离,进程非常多(可能有几十个)。
  • Firefox:默认进程较少(8 个内容进程处理所有标签页,如果 Fission 需要跨站 iframe 则更多)。因此,它不一定是每个标签页一个进程;标签页在池中共享内容进程。这意味着 Firefox 在多标签场景下内存占用可能较低,但也意味着一个内容进程崩溃可能会波及多个标签页(尽管它尝试按站点分组)。
  • Safari:很可能是每个标签页一个进程(或每几个标签页一个)——在 iOS 上,WKWebView 肯定隔离了每个 webview。Safari 桌面版历史上也是每个标签页独立的。

进程间协调:所有引擎都必须解决类似的问题,例如如何在多进程环境中实现 alert()(它会阻塞 JS)——通常浏览器进程显示 alert UI 并暂停该脚本上下文。存在细微差别(例如,Chrome 并不真正为 alert 阻塞线程——它在渲染器中运行一个嵌套的运行循环,而 Firefox 可能会冻结该标签页的进程)。

崩溃处理:Chrome 和 Firefox 都有崩溃报告器,可以重新启动崩溃的内容进程并在标签页中显示错误。Safari 的 Web 内容进程崩溃通常会在内容区域显示一个更简单的错误消息。

性能权衡

历史上,Chrome 因多进程和 V8 而在 JS 速度和整体性能上备受赞誉。Firefox 通过 Quantum 缩小了许多差距,有时在图形方面超过了 Chrome(WebRender 在复杂页面上可以非常快)。Safari 通常在 Apple 硬件上的图形和低功耗方面表现出色(他们针对功耗进行了大量优化)。

  • 内存:Chrome 以高内存占用著称(所有那些进程)。Firefox 尝试更保守一些。Safari 在 iOS 上出于必要(内存有限)非常节省内存,并且在 WebKit 中进行了大量内存优化。

从 Web 开发者的角度来看,这些差异通常表现为:

  1. 需要在所有引擎上进行测试,因为在 CSS 特性或 API 的实现上可能存在细微差异或错误。
  2. 性能可能不同(例如,由于 JIT 启发式算法,特定的 JS 工作负载在一个引擎中可能比另一个快)。
  3. 某些 API 在其中一个中可能不可用(Safari 通常是最后实现某些新 API 的,如 WebRTC 或 IndexedDB 版本等,尽管它们最终会实现)。

但我们讨论的核心概念(网络 -> 解析 -> 布局 -> 绘制 -> 合成 -> JS 执行)适用于所有引擎,只是内部方法或名称有所不同。

结论与延伸阅读

我们已经走过了现代浏览器内部网页的一生——从输入 URL 的那一刻起,经过网络和导航、HTML 解析、样式、布局、绘制和 JavaScript 执行,一直到 GPU 将像素呈现在屏幕上。我们看到浏览器本质上是微型操作系统:管理进程、线程、内存和一系列复杂的子系统,以确保 Web 内容加载迅速且运行安全。对于 Web 开发者来说,了解这些内部机制可以揭示为什么某些最佳实践(如减少重排或使用异步脚本)对性能至关重要,或者为什么存在某些安全策略(如不在 iframe 中混合源)。

给开发者的几个关键要点:

  1. 优化网络使用:更少的往返和更小的文件 = 更快的初始渲染。浏览器可以做很多事情(HTTP/2、缓存、推测性加载),但你仍应利用资源提示和高效缓存等技术。网络栈性能很高,但延迟永远是杀手。
  2. 为效率构建 HTML/CSS 结构:结构良好的 DOM 和精简的 CSS(避免过深的树或过于复杂的选择器)可以帮助解析和样式系统。理解 CSS 和 DOM 构建计算样式,然后布局计算几何形状——沉重的 DOM 操作或样式更改会触发这些重新计算。
  3. 批量更新 DOM:以避免重复的样式/布局抖动。使用 DevTools 的 Performance 面板来捕捉你的脚本何时导致了多次布局或绘制。
  4. 使用合成友好的 CSS 进行动画:对 transformopacity 的动画保持在主线程之外并在合成器上运行,从而产生平滑的动画。尽可能避免对受布局约束的属性进行动画处理。
  5. 留意 JS 执行:虽然 JS 引擎速度极快,但长任务会阻塞主线程。分解长操作(使页面保持响应),在某些情况下考虑使用 Web Workers 处理后台任务。此外,请记住沉重的 JS 可能会导致 GC 停顿。
  6. 拥抱安全特性:例如在适当的时候使用 iframe 沙箱或 rel=noopener,因为你现在知道浏览器无论如何都会隔离这些;与其配合是件好事。
  7. DevTools 是你的好朋友:特别是性能和网络面板,是查看浏览器具体在做什么的金矿。如果某些东西很慢或卡顿,工具通常会指向原因(长布局、慢绘制等)。

对于那些渴望更深入研究的人,Pavel Panchekha 和 Chris Harrelson 编写的 Browser Engineering 是一个极佳的资源。它基本上是一本免费的在线书籍,引导你构建一个简单的 Web 浏览器,以易于理解的方式涵盖网络、HTML/CSS 解析、布局等内容。

总之,现代浏览器是软件工程的奇迹。它们成功地抽象掉了所有这些复杂性,使得作为开发者,我们大多只需编写 HTML/CSS/JS 并信任浏览器来处理它。然而,通过窥探幕后,我们获得了有助于编写更高性能、更健壮应用的见解。

祝开发愉快!请记住,Web 平台的深度会回馈那些探索它的人——总有更多的东西可以学习,也有工具可以帮助你学习。

延伸阅读

  • Web Browser Engineering —— 浏览器工作原理深度解析书籍。
  • Chromium University —— 关于 Chromium 工作原理的免费深度视频系列,包括精彩的 "Life of a Pixel" 演讲。
  • Inside the Browser (Chrome 开发者博客系列) —— 第 1-4 部分涵盖了架构、导航流、渲染流水线以及输入/控制器线程。
  • Google Chrome at 17 —— 我们浏览器的历史。
  • 本文中的插图由 Susie Lu 委约创作。