现代浏览器的工作原理

1
分类技术博客
作者Addy Osmani
来源跳转
发表时间

内容

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

本文将深入探讨现代浏览器的工作原理——重点聚焦 Chromium 的架构与内部机制,同时也会指出其他引擎的不同之处。我们将探索从网络栈、解析管道,到通过 Blink 进行渲染、通过 V8 执行 JavaScript、模块加载、多进程架构、安全沙盒以及开发者工具等方方面面。目标是为开发者提供一个友好的解释,揭开浏览器幕后工作的神秘面纱。

图像

让我们开始探索浏览器内部的旅程吧。

网络与资源加载

图像

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

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

具体步骤包括:

URL 解析与安全检查:浏览器解析 URL 以确定协议(http、https 等)和目标域名。它还会判断输入是搜索查询还是 URL(例如在 Chrome 的地址栏中)。这里可能会检查安全黑名单等功能,以避免访问钓鱼网站。

DNS 查询:网络栈将域名解析为 IP 地址(除非已缓存)。这可能涉及联系 DNS 服务器。现代浏览器可能会使用操作系统的 DNS 服务,甚至如果配置了,会使用 DNS over HTTPS (DoH),但最终它们会获得主机的 IP 地址。

建立连接:如果与服务器没有打开的连接,浏览器会打开一个。对于 HTTPS URL,这包括一个 TLS 握手,以安全地交换密钥并验证证书。浏览器的网络线程透明地处理 TCP/TLS 设置等协议。

发送 HTTP 请求:连接建立后,会为资源发送一个 HTTP GET 请求(或其他方法)。如果服务器支持,如今的浏览器默认使用 HTTP/2 或 HTTP/3,这允许在一个连接上多路复用多个资源请求。这通过避免每个主机约 6 个并行连接(HTTP/1.1)的旧限制来提高性能。例如,使用 HTTP/2,HTML、CSS、JS、图片都可以在一个 TCP/TLS 链路上并发获取;而使用 HTTP/3(基于 QUIC UDP),连接建立的延迟进一步降低。

接收响应:服务器响应 HTTP 状态码和头部,后跟响应体(HTML 内容、JSON 数据等)。浏览器读取响应流。如果 Content-Type 头部缺失或不正确,它可能需要嗅探 MIME 类型,以决定如何处理内容。例如,如果一个响应看起来像 HTML 但没有被标记为 HTML,浏览器仍然会尝试将其视为 HTML(根据宽松的 Web 标准)。这里也有安全措施:网络层检查 Content-Type,并可能阻止可疑的 MIME 类型不匹配或不允许的跨域数据(Chrome 的 CORB - 跨域读取阻塞 - 就是这样的机制之一)。浏览器还会咨询安全浏览服务或类似服务,以阻止已知的恶意负载。

重定向与后续步骤:如果响应是 HTTP 重定向(例如,带有 Location 头部的 301 或 302),网络代码将遵循重定向(在通知 UI 线程后)并重复对新 URL 的请求。只有当获得带有实际内容的最终响应时,浏览器才会继续处理该内容。

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

推测性加载与资源优化

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

预加载扫描器操作:Chromium 实现了一个复杂的预加载扫描器,它在主解析器之前对 HTML 标记进行分词。当主 HTML 解析器被 CSS 或同步 JavaScript 阻塞时,预加载扫描器继续检查原始标记,以识别可以并行获取的资源,如图片、脚本和样式表。这种机制是现代浏览器性能的基础,无需开发者干预即可自动运行。预加载扫描器无法发现通过 JavaScript 注入的资源,这使得此类资源很可能被顺序加载而非并发加载。

Early Hints (HTTP 103)Early Hints 允许服务器在生成主响应时发送资源提示,使用 HTTP 103 状态码。这使得在服务器思考时间内可以发送预连接和预加载提示,可能将最大内容绘制时间缩短几百毫秒。Early Hints 仅适用于导航请求,支持预连接和预加载指令,但不支持预取。

推测规则 API推测规则 API 是一个较新的 Web 标准,允许定义规则,根据用户交互模式动态预取和预渲染 URL。与传统的链接预取不同,此 API 可以预渲染整个页面,包括 JavaScript 执行,从而实现近乎即时的加载时间。该 API 在 script 元素或 HTTP 头部中使用 JSON 语法来指定应被推测性加载的 URL。Chrome 有限制以防止过度使用,根据紧急程度设置不同的容量。

HTTP/2 和 HTTP/3:大多数基于 Chromium 的浏览器和 Firefox 完全支持 HTTP/2,并且 HTTP/3(基于 QUIC)也得到广泛支持(Chrome 默认对支持的站点启用)。这些协议通过允许并发传输和减少握手开销来提高页面加载速度。从开发者的角度来看,这意味着你可能不再需要雪碧图或域名分片技巧——浏览器可以在一个连接上高效地并行获取许多小文件。

资源优先级:浏览器还会对某些资源进行优先级排序。通常,HTML 和 CSS 是高优先级(因为它们阻塞渲染),脚本可能是中等优先级(如果适当标记为 defer/async 则可能更高),而图片可能优先级较低。Chromium 的网络栈分配权重,甚至可以取消或延迟请求,以优先处理初始渲染所需的内容。开发者可以使用 link rel=preloadFetch Priority 来影响资源优先级。

在网络阶段结束时,浏览器拥有了页面的初始 HTML(假设是 HTML 导航)。此时,Chrome 的浏览器进程选择一个渲染器进程来处理内容。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 中包含大型 <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)后才执行首次渲染。这是因为应用不完整的样式表可能会导致无样式内容闪烁。实际上,如果一个未标记为 async/defer 的 <script> 出现在 HTML 中的 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 的结构和每个元素的计算样式。接下来的问题是:所有这些元素在屏幕上应该放在哪里?它们有多大?这是布局的工作(也称为"回流"或"布局计算")。在此阶段,浏览器根据 CSS 规则(流式、盒模型、flexbox 或 grid 等)和 DOM 层次结构,计算每个元素的几何属性——它们的大小和位置。

图像

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

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

关于布局树的一些细节:

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

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

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

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

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

  • DOM 树 - 结构和内容
  • CSSOM - 解析后的 CSS 规则
  • 计算样式 - 将 CSS 规则匹配到每个 DOM 节点的结果
  • 布局树 - 过滤到可视元素的 DOM 树,包含每个节点的几何信息

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

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

绘制、合成与 GPU 渲染

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

图像

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

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

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

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

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

借助 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 进程使用 GPU 着色器处理几乎所有绘制(更多内容见比较部分)。WebKit(Safari)也使用多线程合成器,并通过 macOS 上的 "CALayers" 进行 GPU 渲染。因此,所有现代引擎都利用 GPU 进行渲染,特别是用于合成和光栅化图形密集型部分,以实现高帧率并将工作从 CPU 卸载。

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

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

合成结果最终被发送到显示器(浏览器运行的 OS 窗口或上下文)。对于每个动画帧(目标为 60fps,即每帧 16.7 毫秒以获得流畅效果),合成器旨在生成一帧。如果主线程繁忙(例如 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)表示。预解析器是解析器的一个副本,它只做跳过函数所需的最小工作。它验证函数在语法上是否有效,并生成外部函数正确编译所需的所有信息。当稍后调用一个预解析的函数时,它会按需进行完整解析和编译。

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 会收集关于其使用方式的数据:变量类型、哪些函数被频繁调用等。这些信息将在后续步骤中用于使代码运行得更快。

JIT 编译层级

V8 并不止步于解释执行。它采用多层即时编译器来加速热点代码。其思想是:对运行次数多的代码投入更多编译工作以使其更快,同时避免浪费时间去优化只运行一次的代码。

  1. Ignition(解释字节码)。
  2. Sparkplug:V8 的基线 JIT,称为 Sparkplug(大约 2021 年推出)。Sparkplug 获取字节码并快速将其编译为机器码,不进行大量优化。这生成了比解释执行更快的原生代码,但 Sparkplug 不做深度分析——它的目标是启动速度几乎与解释器一样快,但生成的代码运行速度稍快。
  3. Maglev:2023 年,V8 引入了 Maglev,一个中层优化编译器,现已积极部署。Maglev 生成代码的速度比 Sparkplug 慢近 20 倍,但比 TurboFan 快 10 到 100 倍,有效地填补了那些中等热度但不足以进行 TurboFan 优化的函数的空白。Maglev 适用于那些有点热但不足以进行 TurboFan 优化的函数,或者当 TurboFan 的编译成本过高时。从 Chrome M117 开始,Maglev 可以处理许多情况,通过弥合基线和最高层级 JIT 之间的差距,使得在“温热”代码(既不冷也不极热)上花费时间的 Web 应用启动更快。
  4. TurboFan:当函数或循环被执行多次时,V8 将启用其最强大的优化编译器。TurboFan 获取代码并使用收集到的类型反馈生成高度优化的机器码,应用高级优化(内联函数、消除边界检查等)。注意:截至 2025 年,V8 已逐步用基于 CFG 的中间表示 Turboshaft 替换 TurboFan 内部的“Sea of Nodes”中间表示。TurboFan 的整个 JavaScript 后端现在使用 Turboshaft,并且正在进行另一个项目(Turbolev),旨在使用 Maglev 的 IR 作为前端,完全替换 TurboFan 的前端。如果假设成立,这种优化后的代码可以运行得更快。

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

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

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

字节码刷新与内存管理

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

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

  • 分代:V8 按对象年龄进行隔离。新对象分配在年轻代(或“nursery”)。这些对象通过非常快速的 scavenging 算法(将存活对象复制到新空间并回收其余部分)频繁回收。存活足够多次循环的对象会被提升到老年代。
  • 标记-清除/压缩:对于老年代,V8 使用带有压缩功能的标记-清除回收器。这意味着它会偶尔停止世界(短暂停止 JS 执行),标记所有可达对象(从根对象如全局对象开始追踪),然后清除以回收未引用对象的内存。它还可能压缩内存(移动对象以减少碎片)。然而,Orinoco 已经使大部分标记工作并发化——它可以在后台线程上完成大量标记工作,同时 JS 仍在运行,从而最小化暂停时间。
  • 增量 GC:V8 尽可能以小片段而非一次大暂停的方式执行垃圾回收。这种增量方法将工作分散开来以避免卡顿。例如,它可以在脚本执行之间穿插少量标记工作,利用空闲时间。
  • 并行 GC:在多核机器上,V8 还可以在并行线程上执行部分 GC(如标记或清除)。

总体效果是,V8 团队多年来成功大幅减少了 GC 暂停时间,使得垃圾回收即使在大型应用中也几乎不可察觉。小型 GC(新对象 scavenge)通常非常快。大型 GC(老年代)较少发生,且现在大多是并发的。如果你打开 Chrome 的任务管理器或 DevTools 内存面板,可能会看到 V8 的堆被分为“年轻空间”和“老空间”,反映了这种分代设计。

对于开发者来说,这意味着不需要手动管理内存,但仍需注意:例如,避免在紧密循环中创建大量短生命周期对象(尽管 V8 非常擅长处理短生命周期对象),并注意持有大型数据结构会使它们保留在内存中。像 DevTools 这样的工具可以强制进行垃圾回收或记录内存配置文件,以查看哪些内容正在使用内存。

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

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

模块加载与导入映射

JavaScript 模块(ES6 模块)引入了与经典 <script> 标签不同的加载和执行模型。模块不是可能创建全局变量的庞大脚本文件,而是显式导入/导出值的文件。让我们看看浏览器(特别是 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 先运行)。

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

动态 import():除了静态导入语句,ES2020 引入了 import(moduleSpecifier) 作为表达式。这允许代码动态加载模块(返回一个解析为模块导出的 promise)。例如,你可以根据用户操作执行 const module = await import('./analytics.js'),从而实现应用的代码拆分。在底层,import() 触发浏览器获取请求的模块(及其依赖项,如果尚未加载),然后实例化并执行它,并用模块命名空间对象解析 promise。V8 和浏览器在此协同工作:浏览器的模块加载器处理获取和解析,V8 在准备就绪后处理编译和执行。动态导入功能强大,因为它也可以在非模块脚本中使用(例如,内联脚本可以动态导入一个模块)。它本质上让开发者能够按需加载 JS。与静态导入的区别在于,静态导入是提前解析的(在任何模块代码运行之前,整个图就已加载),而动态导入更像是在运行时加载新脚本(但具有模块语义和 promise)。

导入映射:浏览器中 ES 模块的一个挑战是模块说明符。在 Node 或打包工具中,你通常按包名导入(例如 import { compile } from 'react')。在 Web 上,如果没有打包工具,'react' 不是一个有效的 URL——浏览器会将其视为相对路径(这会导致失败)。这就是导入映射发挥作用的地方。导入映射是一个 JSON 配置,告诉浏览器如何将模块说明符解析为真实 URL。它通过 HTML 中的 <script type="importmap"> 标签提供。例如,导入映射可能说明符 "react" 映射到 "https://cdn.example.com/[email protected]/index.js"(实际脚本的完整 URL)。然后,当任何模块执行 import 'react' 时,浏览器使用映射找到 URL 并加载它。本质上,导入映射允许“裸”说明符(如包名)在 Web 上工作,通过将它们映射到 CDN URL 或本地路径。

导入映射对于无打包开发来说是一个游戏规则改变者。自 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 等)中,有一个核心的浏览器进程。该浏览器进程负责 UI(地址栏、书签、菜单——所有浏览器 chrome)以及协调资源加载和导航等高层任务。当你打开 Chrome 并在操作系统任务管理器中看到一个条目时,那就是浏览器进程。它也是生成其他进程的父进程。

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

图像

如果你打开了多个不相关的站点,它们将在不同的进程中(站点 A 在一个进程中,站点 B 在另一个进程中,等等)。Chrome 甚至将跨域 iframe 隔离到单独的进程中(更多内容见站点隔离)。渲染器进程被沙箱化,不能直接任意访问你的文件系统或网络——它必须通过浏览器进程来执行这些特权操作。

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

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

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

多进程的优势:主要优势包括:

  • 稳定性:如果一个网页(渲染器进程)崩溃或内存泄漏,它不会导致整个浏览器崩溃——你可以关闭该标签页,其余部分保持正常运行。在单进程浏览器中,一个糟糕的脚本可能摧毁一切。Chrome 可以在单个标签页的进程死亡时显示“哦,崩溃了”错误,并且你可以独立重新加载它。
  • 安全性(沙箱化):通过在受限进程中运行 Web 内容,浏览器可以限制该代码在系统上能做什么。即使攻击者在渲染引擎中发现漏洞,他们也被困在沙箱中——渲染器进程通常无法读取你的文件、任意打开网络连接或启动程序。它必须向浏览器进程请求文件访问等操作,这些请求可以被验证或拒绝。这种沙箱在操作系统级别强制执行(根据平台使用作业对象、seccomp 过滤器等)。
  • 性能隔离:一个标签页中的密集工作(一个繁重的 Web 应用或无限循环)主要局限在该标签页的渲染器进程中。其他标签页(不同进程)可以保持响应,因为它们的进程不会被阻塞。此外,操作系统可以将进程调度到不同的 CPU 核心上——因此两个繁重的页面可以在多核系统上更好地并行运行,而不是作为单个进程的线程。
  • 内存分段:每个进程拥有自己的地址空间,因此内存不共享。这防止了一个站点窥探另一个站点的数据,也意味着当标签页关闭时,操作系统可以高效地回收该进程的所有内存。缺点是因重复资源和进程而产生一些开销(每个渲染器加载自己的 JS 引擎副本等)。

站点隔离:最初,Chrome 的模型是每个标签页一个进程。随着时间的推移,他们演变为每个站点一个进程(特别是在 Spectre 之后——见下一节关于安全的内容)。截至 2024 年,站点隔离在桌面平台上默认对 99% 的 Chrome 用户启用,Android 支持持续改进。这意味着如果你有两个标签页都打开 example.com,Chrome 可能会决定使用一个进程来处理两者(以节省内存,因为它们是同一个站点,放在一起风险较小)。但是,一个包含 example.com 和 evil.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 的模型:一个父进程、一个 GPU 进程、一个网络进程、几个内容(渲染器)进程,以及一些实用工具进程(用于扩展、媒体解码等——例如,媒体插件可以隔离运行)。

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

一个重要点是进程间通信(IPC):这些进程如何相互通信?浏览器使用 IPC 机制(在 Windows 上,通常是命名管道或其他 OS IPC;在 Linux 上,可能是 Unix 域套接字或共享内存;Chrome 有自己的 IPC 库 Mojo)。例如,当网络响应到达网络进程时,它需要被传递到正确的渲染器进程(通过浏览器进程协调)。类似地,当你执行 DOM fetch() 时,JS 引擎会调用网络 API,该 API 向网络进程发送请求,等等。IPC 增加了复杂性,但浏览器进行了大量优化(例如,使用共享内存高效传输大型数据如图像,并发布异步消息以避免阻塞)。

一个重要点是进程间通信(IPC):这些进程如何彼此交流?浏览器使用 IPC 机制(在 Windows 上,通常是命名管道或其他操作系统 IPC;在 Linux 上,可能是 Unix 域套接字或共享内存;Chrome 还有自己的 IPC 库 Mojo)。例如,当网络响应到达 Network 进程时,需要把它传递给正确的 Renderer 进程(由 Browser 进程负责协调)。同样地,当你执行一个 DOM fetch() 时,JS 引擎会调用网络 API,由它向 Network 进程发送请求,等等。IPC 增加了复杂度,但浏览器会做大量优化(例如用共享内存高效传输图片等大数据,并通过异步消息来避免阻塞)。

进程分配策略:Chrome 并不会总是为每个标签页都新建一个独立进程——这里有一些限制(尤其是在低内存设备上,它可能会为同站点标签页复用进程)。如果你打开另一个同站点标签页,Chrome 会复用已有的渲染进程,以节省内存(这也是为什么有时两个同站点标签页会共享进程)。它还对总进程数有限制(这个上限可根据 RAM 规模动态调整)。当达到上限时,它可能会开始把多个无关站点塞进同一个进程,不过如果启用了站点隔离,它会尽力避免混站点。在 Android 上,由于内存约束,Chrome 使用的进程更少(内容相关进程通常最多只有 5-6 个)。

Chromium 里还有一个概念叫做 servicification:把浏览器组件拆分成可以运行在独立进程中的服务。例如,Network Service 被做成了一个可以在进程外运行的独立模块。其思想是模块化——性能强大的系统可以让每个服务独立运行,而资源受限的设备则可以把部分服务重新合并到一个进程里,以节省开销。Chrome 可以在运行时或构建时决定如何部署这些服务。如片段所述,在高端设备上它可能把一切都拆开(UI、网络、GPU 等全都独立),而在低端设备上(Android)则可能把 browser 和 network 合并到一个进程中,以降低开销。

核心要点是:Chromium 的架构旨在让浏览器 UI 和每个站点运行在不同的沙箱中,并以进程作为隔离边界。Firefox 和 Safari 也已经趋向类似的设计。这种架构以更高的内存占用为代价,大幅提升了安全性和可靠性。Web 内容进程被视为不可信,而这正是下一节中站点隔离发挥作用的地方——它甚至把不同来源彼此隔离到不同进程里。

站点隔离与沙箱

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

站点隔离:前面已经提到过——它意味着不同网站(更严格地说,不同站点)运行在不同的渲染进程中。Chrome 在 2018 年 Spectre 漏洞 公开后加强了站点隔离。Spectre 表明,恶意 JavaScript 可能通过利用 CPU 的推测执行读取本不该读到的内存。如果两个站点在同一个进程里,恶意站点就可能借助 Spectre 窥探敏感站点(例如你的网银站点)的内存。唯一稳妥的解决方案就是根本不让它们共享同一进程。因此 Chrome 将站点隔离设为默认:每个站点都拥有自己的进程,包括跨域 iframe。Firefox 也通过 Project Fission 跟进了这一方向(在较新的版本中默认启用),目标同样是让每个站点在自己的进程中隔离,以提升安全性。这与过去大不相同——过去如果你有一个父页面和多个来自不同域的 iframe,它们可能都在同一个进程中运行(尤其是在同一个标签页里)。现在,这些 iframe 会被拆分,例如 <iframe src="https://evil.com"> 即使嵌在一个良好站点页面中,也会被强制放入不同进程,从而防止即便是底层攻击也无法在它们之间泄露信息。

从开发者角度看,站点隔离大多是透明的。一个影响是:嵌入式 iframe 与其父页面之间的通信现在可能会跨进程边界,因此像 postMessage 之类的通信会在底层通过 IPC 实现。但浏览器把这些都做得很顺滑;开发者只需像平常一样使用这些 API。

沙箱:每个渲染进程(以及其他辅助进程)都运行在权限受限的沙箱中。例如在 Windows 上,Chrome 使用 job object 并降低权限,让渲染进程无法调用大多数会访问系统的 Win32 API。在 Linux 上,它使用命名空间和 seccomp 过滤器来限制系统调用。渲染进程本质上可以计算和渲染内容,但如果它试图打开文件、调用摄像头或麦克风,就会被阻止(除非通过正确渠道,经由浏览器进程请求用户授权)。WebKit 的文档明确指出,WebContent 进程不能直接访问文件系统、剪贴板、设备等——它们必须通过负责中介的 UI 进程发起请求。这也是为什么例如某个站点想使用麦克风时,权限提示会由浏览器 UI(browser process)显示,而一旦允许,真正的录音会在受控进程中完成。沙箱是至关重要的防线。即便攻击者找到了在渲染进程中执行原生代码的漏洞,也仍然要面对沙箱屏障——他们还需要另一个独立漏洞(“逃逸”)才能突破到系统层。这种分层防御(站点隔离 + 沙箱)是当今浏览器安全的最先进做法。

Firefox 的沙箱现在也相当严格(早期 e10s 时代较弱,但后来逐步加强了)。Firefox 的内容进程同样无法直接访问很多系统资源;Firefox 也会对 GPU 进程进行沙箱处理,以应对图形驱动问题。

进程外 iframe(OOPIF):在 Chrome 的站点隔离实现中,他们创造了 OOPIF 这个术语,表示 out-of-process iframe。站在用户角度看,什么都没变,但在 Chrome 的内部架构中,页面的每个 frame 都可能由不同的渲染进程承载。顶层 frame 和同站点 frame 共享一个进程;跨站点 frame 使用不同进程。所有这些进程“协作”渲染单个标签页的内容,由浏览器进程协调。这非常复杂,但 Chrome 有一棵可以跨进程延伸的 frame tree。这意味着你的一个标签页可能在运行 N 个进程(主文档一个,若干跨站点子文档各自一个)。它们通过 IPC 交换诸如跨边界的 DOM 事件或涉及跨上下文的某些 JavaScript 调用等信息。Web 平台(通过 COOP/COEPSharedArrayBuffer 等规范)也在 Spectre 之后围绕这些约束不断演进。

内存与性能成本:站点隔离确实会增加内存使用,因为使用了更多进程。Chrome 开发者指出,在某些情况下这可能带来 10-20% 的内存 开销。他们通过一种叫做“best-effort process consolidation”的机制对同站点进行了部分合并,并通过限制可创建的进程数量来缓解(前面提到过)。Firefox 最初没有对每个站点都做隔离,也是出于内存考虑,但在 Spectre 之后他们找到了更高效的实现方式,比如 8 个特权进程上限和按需创建进程。Safari 历史上也有很强的进程模型,但我不确定它是否已经对跨站点 iframe 做了隔离;WebKit2 肯定会隔离顶层页面。苹果往往也很重视隐私(如 Intelligent Tracking Prevention 会对 cookie 等进行分区),不过那是另一层面的事情。

出于隐私原因,跨站点预取(prefetch)会受到限制,并且目前只有在用户对目标站点没有设置 cookie 时才会生效,这样可以防止网站通过永远不会真正访问的预取页面追踪用户活动。

总之,站点隔离确保了最小权限原则得以执行:来自 origin A 的代码不能访问 origin 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 树计算样式。虽然也有增量样式失效(incremental style invalidation)优化,但总体上主要还是一个线程在干活(动画方面除外,存在少量并行化)。
  • 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 追上——这通常也是因为它们会通过标准组织共享和对齐代码与规范)。

Firefox(Gecko)做了一项巨大的变革:引入 WebRender 作为合成器/栅格化器。WebRender 现在是 Firefox 的默认渲染引擎,并显著提升了性能,尤其是对图形密集型网页内容。WebRender(同样用 Rust 编写)基本上会接收显示列表,并直接在 GPU 上渲染它,处理形状三角化、文本等任务。它相当于把更多绘制工作交给 GPU。在 Chrome 的流水线中,栅格化通常仍主要在 CPU 上完成,然后以位图形式发送给 GPU。WebRender 则试图避免为整个图层生成位图,而是直接在 GPU 上绘制矢量(除了会缓存为 atlas 纹理的文字字形)。这意味着,如果只有一小部分内容变化,Firefox 可能不需要重新栅格化全部内容,就能通过 GPU 很快重绘,从而更高性能地处理更多动画内容。这有点像游戏引擎每一帧都通过 GPU 调用重绘场景。缺点是实现和调优都很复杂,而且可能对 GPU 造成更大压力。但随着 GPU 能力不断增强,这种方案具有前瞻性。Chrome 团队也考虑过类似方式(“SKIA GPU” 路径),但并没有进行完整的 WebRender 式改造。

Safari(WebKit)采用的方案更接近早期 Chrome:它有一套基于图层的合成器(叫做 CALayer,因为在 Mac 和 iOS 上它使用 Core Animation 图层)。Safari 很早就开始转向 GPU 合成(iPhone OS 和 Safari 4 在 2009 年就已对某些 CSS 如 transform 提供硬件加速合成)。Safari 和 Chrome 的实现路径有所分化,但概念上都做分块(tiling)和合成。Safari 也将大量工作卸载给 GPU(并使用分块,尤其是在 iOS 上,分块绘制对流畅滚动至关重要)。

移动端优化:每个引擎都针对移动设备做了特殊处理。例如,WebKit 有滚动时的 tile 覆盖概念(历史上用于 iOS 的 UIWebView)。Chrome 在 Android 上使用“分块(tiling)”并尽量让栅格化任务最小化,以达到帧率目标。Firefox 的 WebRender 源自移动优先的 Servo 项目。

JavaScript 引擎

  • V8(Chromium):前面已经介绍过:Ignition、Sparkplug、TurboFan,以及截至 2023 年的 Maglev。
  • SpiderMonkey(Firefox):历史上它先是解释器,再是 Baseline JIT 和优化 JIT(IonMonkey)。自 Firefox 83(2021)起,IonMonkey 已被 WarpMonkey 完全取代;WarpMonkey 基于 CacheIR 数据构建,而不是单独的类型推断系统。当前层级为:Baseline Interpreter、Baseline JIT,以及作为顶层优化编译器的 WarpMonkey。SpiderMonkey 还有不同的 GC(同样是分代式,2012 年起称为 Incremental GC,如今主要是增量/并发式)。
  • JavaScriptCore(Safari):如前所述,它有 4 个层级(LLInt、Baseline、DFG、FTL)。它使用不同的 GC(WebKit 的 GC 历史上是分代标记-清除,或者说 Boehm 变体,现在则有 bmalloc 等)。JSC 的 FTL 使用 LLVM 进行优化,这一点独具特色(V8 和 SM 使用各自的编译器,而 JSC 在一个层级上借助 LLVM)。这可能带来非常快的代码,但编译开销也很大。JSC 往往更偏向某些基准测试中的峰值性能(它在某些测试中经常表现出色,但 V8 往往会追上来;双方此消彼长)。

在 ES 特性方面,得益于 test262 和彼此之间的竞争,这三个引擎几乎都已经跟上了最新标准。

多进程模型的差异

  • Chrome:每个标签页通常独立,站点隔离到 origin 级别,进程很多(可达数十个)。
  • Firefox:默认进程数更少(8 个内容进程处理所有标签页,必要时再为跨站点 iframe 增加更多进程,配合 Fission 使用)。因此它不一定是“每个标签页一个进程”;标签页会共享一个内容进程池。这意味着在大量标签页场景下,Firefox 的内存占用可能更低,但也意味着某个内容进程崩溃可能会带走多个标签页(不过它会尽量按站点分组,所以也许所有 Facebook 标签页会在同一个进程里,等等)。
  • Safari:大概率是每个标签页一个进程(或少数几个标签页共用一个进程)——在 iOS 上,WKWebView 确实会将每个 webview 隔离开来。桌面 Safari 历史上也常常是每个标签页独立进程。不确定它是否已经隔离跨域 iframe——苹果对 Spectre 缓解方案很少公开细说,但 Safari 至少对顶层页面会按域进行进程隔离。

进程间协调:所有引擎都必须解决类似问题,比如在多进程环境中如何实现 alert()(它会阻塞 JS)——通常是浏览器进程显示 alert UI,并暂停对应脚本上下文。或者如何处理 prompt/confirm,如何处理模态对话框等。这里面有一些细微差别(例如 Chrome 并不会真正阻塞线程来实现 alert——它会在渲染进程里启动一个嵌套事件循环,等等;而 Firefox 可能仍会冻结那个标签页的进程)。

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

特性实现分化

一些 Web 平台特性具有引擎差异:例如,View Transitions API(早期在 Chrome 中还是实验性特性)在 2025 年 10 月达到了 Baseline Newly Available 状态,其中同文档过渡现已在 Chrome 111+、Edge 111+、Firefox 133+ 和 Safari 18+ 中得到支持。跨文档过渡(用于多页面应用)则在 Chrome 126+、Edge 126+ 和 Safari 18.2+ 中得到支持,而 Firefox 仍在等待支持。

开发者工具:Chrome 的 DevTools 非常强大。Firefox 的 DevTools 也很优秀(并且有一些独特功能,比如早期就支持的 CSS Grid 高亮器、形状编辑器)。Safari 的 Web Inspector 也不错,但在某些方面功能没有那么全面。这些差异对在各浏览器中调试的开发者很重要。

性能权衡

从历史上看,Chrome 因 JS 更快和整体性能更优而广受赞誉,这得益于多进程和 V8。Firefox 在 Quantum 之后弥补了大量差距,在某些情况下甚至在图形方面超越 Chrome(WebRender 在复杂页面上可能非常快)。Safari 在苹果硬件上通常在图形和低功耗方面表现出色(它们对功耗优化非常重视)。

内存:Chrome 由于进程较多,一直有内存占用偏高的名声。Firefox 则相对更保守一些。Safari 在 iOS 上出于必要原因对内存非常节省(RAM 有限),而 WebKit 也做了大量内存优化。

外部贡献者:有趣的一点是,这些引擎的很多改进都来自外部团队,比如 Igalia(例如在 WebKit 和 Blink 中实现 CSS Grid)。所以有时新特性会几乎同时落地。

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

  • 需要在所有引擎上测试,因为某个 CSS 特性或 API 的实现可能会有细微差别或 bug。
  • 性能可能不同(例如某个 JS 负载在一个引擎中会比另一个更快,这取决于 JIT 机制)。
  • 某些 API 可能在某个引擎里尚未提供(Safari 往往是一些新 API,如 WebRTC 或 IndexedDB 某些版本,最后才实现,不过最终都会补上)。

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

  • 在 Gecko 中:解析 -> frame tree -> display list -> WebRender 场景或图层树(如果未启用 WebRender)-> 合成。
  • 在 WebKit 中:解析 -> render tree -> graphics layers -> 合成(通过 CoreAnimation)。

它们也都有相应的子系统(DOM、样式、布局、图形、JS 引擎、网络、进程/线程)。

了解这些内容有助于调试:例如,如果 Safari 里卡顿而 Chrome 没问题,可能是 WebKit 的绘制路径不同;或者如果 Firefox 里 CSS 很慢,可能碰到了没有被 Stylo 并行化的路径(虽然这种情况很少)。

总而言之,虽然 Chromium、Gecko 和 WebKit 的实现不同,甚至在一些方面各有创新(Gecko 的并行 CSS、WebRender GPU 等),但它们越来越多地实现了相同的 Web 标准,并且在许多方面还会相互协作。引擎的选择对平台厂商和开放 Web 的多样性更为重要,但作为开发者,你更关心的是你的站点能在各处正常运行。底层来看,每个引擎独特的架构可能导致不同的性能表现或 bug,因此在各浏览器中进行测试并使用性能诊断工具(比如 Firefox 的性能工具或 Chrome 的工具)会很有帮助。要把所有差异都列出来超出了本文范围,但希望这能让你对整体格局有个概念:它们在高层设计上趋同(多进程、类似流水线),但在具体技术方案上又各自分化。

总结与延伸阅读

我们已经走完了一个现代浏览器中网页的生命周期——从输入 URL 的那一刻起,经过网络与导航、HTML 解析、样式、布局、绘制和 JavaScript 执行,一直到 GPU 将像素送上屏幕。我们看到,浏览器本质上就是一套微型操作系统:管理进程、线程、内存,以及大量复杂子系统,以确保网页内容既能快速加载,又能安全运行。对于 Web 开发者来说,理解这些内部机制可以帮助你明白为什么某些最佳实践(例如尽量减少重排,或使用异步脚本)对性能如此重要,或者为什么某些安全策略(例如不要在 iframe 中混合不同 origin)会存在。

给开发者的几个关键建议:

优化网络使用:更少的往返和更小的文件 = 更快的首屏渲染。浏览器可以做很多事(HTTP/2、缓存、预测性加载),但你仍然应该利用资源提示和高效缓存等技术。网络栈性能很高,但延迟永远是大敌。

以高效方式组织 HTML/CSS:结构良好的 DOM 和精简的 CSS(避免过深的树或过于复杂的选择器)有助于解析和样式系统。要理解 CSS 和 DOM 会构建计算样式,然后布局会计算几何信息——大量 DOM 操作或样式修改会触发这些重计算。

批量更新 DOM:避免重复的样式/布局抖动。使用 DevTools 的 Performance 面板来捕捉脚本是否导致了大量布局或绘制。

动画优先使用有利于合成的 CSS:对 transformopacity 的动画可以留在主线程之外,由合成器处理,从而获得流畅动画。尽量避免动画布局相关属性。

注意 JS 执行:虽然 JS 引擎非常快,长任务仍会阻塞主线程。将长操作拆分开(让页面保持响应),在某些情况下可以考虑使用 Web Worker 处理后台任务。此外,也要记住,繁重的 JS 可能引发 GC 暂停(如今通常不长,但在内存暴涨时仍可能发生)。

安全特性:拥抱它们——例如在合适的场景下使用 iframe sandbox 或 rel=noopener,因为你现在已经知道浏览器本来就会隔离它们;配合浏览器的机制总是更好。

DevTools 是你的好帮手:尤其是性能面板和网络面板,几乎就是观察浏览器行为的宝库。如果某件事很慢或卡顿,这些工具通常能指向原因(一次长布局、一次缓慢绘制等)。

如果你想更深入地了解,一个极好的资源是 Pavel Panchekha 和 Chris Harrelson 的《Browser Engineering》(见 browser.engineering)。

这本书几乎就是一本免费的在线教材,带你构建一个简单的 Web 浏览器,内容涵盖网络、HTML/CSS 解析、布局等,讲解方式也很容易上手。它可以作为我们讨论内容的更深入配套读物,通过实例帮助你巩固知识。此外,Chrome 团队的多篇系列文章“Inside look at modern web browser”也提供了带图示的易读概览。V8 博客(v8.dev)和 Mozilla 的 Hacks 博客 也非常适合了解引擎层面的进展(例如新的 JIT 编译器层级或 WebRender 内部机制)。

总之,现代浏览器是软件工程的奇迹。它们成功地抽象掉了所有这些复杂性,让开发者大多只需编写 HTML/CSS/JS,并相信浏览器会处理好一切。然而,通过窥视其内部工作原理,我们能获得更深的洞察,帮助我们编写更高性能、更健壮的应用。我们也因此更能理解某些技术为什么能改善用户体验(例如避免阻塞主线程,或减少不必要的 DOM 复杂度),因为我们看到了浏览器在底层必须完成的工作。下次你调试网页,或者想知道为什么 Chrome 或 Firefox 会有某种行为时,你脑海里就会有一个浏览器内部机制的模型来帮助你。

祝你构建愉快,也请记住:Web 平台的深度会回报那些愿意探索它的人——总有更多东西可学,也总有工具能帮助你去学习它。

本文插图由 Susie Lu 绘制。

延伸阅读

评论

(0)
未配置登录方式
暂无评论