深度解析现代浏览器工作原理

addyosmani 发布于 2026-06-22 阅读 30

本文深度解析现代浏览器(特别是Chromium)的内部工作原理,包括网络请求与资源加载、HTML/CSS/JS解析、样式计算与布局、绘制与GPU合成、V8引擎的编译与执行、ES模块与Import Maps、多进程架构与站点隔离,并对比Firefox和Safari的差别,帮助开发者写出更高效的Web应用。

图像

Web 开发者常常将浏览器视为一个黑盒,神奇地将 HTML、CSS 和 JavaScript 转化为交互式 Web 应用。实际上,像 Chrome (Chromium)、Firefox (Gecko) 或 Safari (WebKit) 这样的现代浏览器是一个复杂的软件。它协调网络、解析并执行代码、利用 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(根据宽松的 Web 标准)。这里也有安全措施:网络层检查 Content-Type,并可能阻止可疑的 MIME 不匹配或不允许的跨域数据(Chrome 的 CORB - 跨域读取阻塞 - 是其中一种机制)。浏览器还会咨询 Safe Browsing 或类似服务以阻止已知的恶意载荷。

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

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

推测加载和资源优化

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

预加载扫描器操作

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

早期提示(HTTP 103)

早期提示允许服务器在生成主响应时发送资源提示,使用 HTTP 103 状态码。这使预连接和预加载提示能够在服务器思考时间内发送,可能将最大内容绘制时间 (Largest Contentful Paint) 改善几百毫秒。早期提示仅适用于导航请求,支持 preconnect 和 preload 指令,但不支持 prefetch。

推测规则 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 解析才能恢复。这种脚本执行阻塞行为是为什么在头部包含大型 <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 中在 CSS <link> 之前出现一个未标记为 async/defer 的 <script>,它还将额外等待 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 或 size),则可能必须重做布局,然后必须重绘。这个链意味着布局和绘制依赖于最新的样式,依此类推。我们将在 DevTools 部分讨论这方面的性能影响(因为浏览器提供了工具来查看这些步骤何时发生以及耗时多长)。

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

绘制、合成与 GPU 渲染

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

图像

绘制 / 光栅化

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

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

分层与合成

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

在 Chromium 的管道中,在生成绘制记录之后,有一个构建层树的步骤(这对应于哪些元素在哪个层上)。一些层是自动创建的(例如,video 元素、canvas,或者具有某些 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 处理潜在不受信任的内容,如 canvas 绘制、WebGL 等——驱动程序中曾存在安全漏洞——在进程外运行它们可降低风险)。

合成结果最终被发送到显示器(浏览器运行的操作系统窗口或上下文)。对于每个动画帧(目标 60fps 或每帧 16.7 毫秒以获得流畅效果),合成器旨在生成一帧。如果主线程繁忙(例如 JavaScript 花费了很长时间),合成器可能会跳过帧或无法更新,导致可见的卡顿。开发者工具可以在性能时间线中显示丢帧。像 requestAnimationFrame 这样的技术将 JS 更新与帧边界对齐,有助于平滑渲染。

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

JavaScript 引擎内部(V8)

JavaScript 驱动着 Web 页面的交互行为。在 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 是一个基于寄存器的 VM)。目标是尽快开始执行代码,同时最小化前期成本(这对页面加载时间很重要)。

AST 内部化过程

AST 内部化涉及在 V8 堆上分配字面量对象(字符串、数字、对象字面量样板),以供生成的字节码使用。为了实现后台编译,此过程被移到了编译管道的后期,在字节码编译之后,需要对访问 AST 中嵌入的原始字面量值而不是访问内部化的堆上值进行修改。

显式编译提示

V8 引入了一个名为“显式编译提示”的新特性,允许开发者通过急切编译来指示 V8 在加载时立即解析和编译代码。带有此提示的文件在后台线程上编译,而延迟编译在主线程上进行。对流行网页的实验显示,20 个案例中有 17 个性能得到改善,前台解析和编译时间平均减少了 630 毫秒。开发者可以通过使用特殊注释为 JavaScript 文件添加显式编译提示,以便在后台线程上对关键代码路径启用急切编译。

扫描器和解析器优化

V8 的扫描器已经过显著优化,带来了全面的改进:单Token扫描改进约 1.4 倍,字符串扫描改进约 1.3 倍,多行注释扫描改进约 2.1 倍,而标识符扫描改进约 1.2-1.5 倍(取决于标识符长度)。

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

JIT 编译层级

V8 并不止于解释。它采用多层即时编译器来加速热点代码。其思想是将更多的编译工作投入到运行频繁的代码上,使其更快,同时不浪费时间去优化只运行一次的代码。

  • Ignition(解释字节码)。
  • Sparkplug:V8 的基线 JIT,称为 Sparkplug(大约 2021 年推出)。Sparkplug 获取字节码并快速将其编译为机器码,没有大量的优化。这产生了比解释更快的原生代码,但 Sparkplug 不做深入分析——它的目标是几乎像解释器一样快速启动,但生成运行稍快的代码。
  • Maglev:2023 年,V8 引入了 Maglev,一个中等层级的优化编译器,现已积极部署。Maglev 生成代码的速度比 Sparkplug 慢近 20 倍,但比 TurboFan 快 10-100 倍,有效地弥补了对于中等热度但未达到 TurboFan 优化条件的函数的差距。Maglev 适用于那些有点热但不足以使用 TurboFan 的函数,或者当 TurboFan 的编译成本太高时。从 Chrome M117 开始,Maglev 可以处理许多情况,通过弥合基线和最高层级 JIT 之间的差距,为在“温热”代码(不冷,也不超热)中花费时间的 Web 应用带来更快的启动速度。
  • TurboFan:当函数或循环被执行多次时,V8 将调用其最强大的优化编译器。TurboFan 获取代码并使用收集到的类型反馈生成高度优化的机器码,应用高级优化(内联函数、消除边界检查等)。注意:截至 2025 年,V8 正在逐步用基于 CFG 的 IR(称为 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 优化以获得最大速度。

Intel 还开发了配置文件引导的分层,增强了 V8 的效率,在 Speedometer 3 基准测试中实现了约 5% 的改进。最近的 V8 更新包括静态根优化,可以在编译时准确预测常用对象的内存地址,显著提高访问速度。

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

字节码刷新与内存管理

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

内存管理(垃圾回收)

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

  • 分代:V8 按年龄隔离对象。新对象被分配在年轻代(或“nursery”)。这些使用非常快速的 scavenge 算法(将存活对象复制到新空间并回收其余部分)频繁回收。存活足够多周期的对象会被提升到老年代。
  • 标记-清除/压缩:对于老年代,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/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 的角度来看,模块由相同的编译管道处理,但它们创建了单独的 ModuleRecord。引擎确保模块的顶层代码只有在所有依赖项准备就绪后才运行。V8 还必须处理循环模块导入(允许的,并可能导致部分初始化的导出)。细节符合规范——但本质上,引擎将创建所有模块实例,然后通过提供占位符来解决循环,然后以尊重依赖关系的顺序执行(规范算法是模块图的“DAG”拓扑排序)。

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

现在我们已经介绍了单个页面内部的工作原理,让我们放大视角,检查允许多个页面、标签和 Web 应用同时运行而不相互干扰的浏览器架构。这就引出了多进程模型。

浏览器多进程架构

现代浏览器(Chrome、Firefox、Safari、Edge 等)都使用多进程架构来实现稳定性、安全性和性能隔离。浏览器不再是作为一个巨大的单一进程运行(早期浏览器的工作方式),而是不同方面运行在不同的进程中。Chrome 在 2008 年率先采用这种方法,其他浏览器随后以各种形式跟进。让我们重点关注 Chromium 的架构,并指出 Firefox 和 Safari 的差异。

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

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

图像

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

Chrome 中其他关键进程包括

  • GPU 进程:专门用于与 GPU 通信的进程(如前所述)。来自渲染器的所有渲染和合成请求都转到 GPU 进程,该进程实际发出图形 API 调用。此进程是沙箱化的且分离的,以便 GPU 崩溃不会导致渲染器崩溃。
  • 网络进程:(在旧版 Chrome 中,网络是浏览器进程中的一个线程,但现在通过“servicification”通常是一个单独的进程)。此进程处理网络请求、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.comevil.com 的 iframe 的标签页,默认情况下会将 evil.com 的 iframe 放在与父页面不同的进程中(以保护 example.com 的数据)。这种强制执行就是 Chrome 所称的“严格站点隔离”(大约在 Chrome 67 中作为默认值推出)。由于增加了进程创建,站点隔离导致 Chrome 多使用 10-13% 的系统资源,但提供了关键的安全优势。

Firefox 的架构,称为 Electrolysis (e10s),历史上所有标签共用一个内容进程(多年来 Firefox 是单进程的,直到 2017 年左右才启用少数内容进程)。截至 2021 年,Firefox 使用多个内容进程(默认情况下为 Web 内容使用 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() 时,JS 引擎将调用一个网络 API,该 API 向网络进程发送请求等等。IPC 增加了复杂性,但浏览器进行了大量优化(例如,使用共享内存高效传输大块数据如图像,并发送异步消息以避免阻塞)。

进程分配策略

Chrome 并不总是为每个标签创建一个全新的进程——有限制(尤其是在内存不足的设备上,它可能会为同站点标签重用进程)。如果你打开另一个同站点标签,Chrome 会重用现有的渲染器以节省内存(这就是为什么有时两个同站点标签共享一个进程)。它还有进程总数限制(可以根据 RAM 调整)。当达到限制时,它可能开始将多个不相关的站点放在一个进程中,但如果启用了站点隔离,它会尽量避免混合站点。在 Android 上,由于内存限制,Chrome 使用更少的进程(通常最多 5-6 个内容进程)。

Chromium 中的另一个概念是 servicification:将浏览器组件拆分为可以在单独进程中运行的服务。例如,网络服务被制作为一个单独的模块,可以在进程外运行。其理念是模块化——强大的系统可以在自己的进程中运行每个服务,而受限的设备可能将一些服务合并回一个进程以减少开销。Chrome 可以在运行时或构建时决定如何部署这些服务。如片段中所述,在高端设备上,它可能会拆分所有内容(UI、网络、GPU 等全部独立),在低端设备(Android)上,它可能将浏览器和网络合并到一个进程中以减少开销。

总结: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 使用作业对象并降低权限,使得渲染器不能调用访问系统的大多数 Win32 API。在 Linux 上,它使用命名空间和 seccomp 过滤器来限制系统调用。渲染器基本上可以计算和渲染内容,但如果它试图打开文件、摄像头或麦克风,它将被阻止(除非通过适当的渠道,经过浏览器进程请求用户许可)。WebKit 的文档明确指出,WebContent 进程没有直接访问文件系统、剪贴板、设备等的权限——它们必须通过 UI 进程请求,UI 进程进行中介。这就是为什么,例如,当一个站点试图使用你的麦克风时,权限提示由浏览器 UI(浏览器进程)显示,如果允许,实际录制在一个受控进程中进行。沙箱是一个关键防线。即使攻击者发现一个漏洞来在渲染器中运行原生代码,他们也会面临沙箱屏障——他们需要单独的漏洞(“逃逸”)才能突破到系统。这种分层方法(称为站点隔离 + 沙箱)是浏览器安全的最新水平。

Firefox 的沙箱现在也非常严格(在早期 e10s 时期较弱,但他们加强了)。Firefox 的内容进程也不能直接访问太多东西;Firefox 还对 GPU 进程进行沙箱化以处理图形驱动程序问题。

进程外 iframe (OOPIF)

在 Chrome 的站点隔离实现中,他们为进程外 iframe 创造了术语 OOPIF。从用户的角度来看,没有任何变化,但在 Chrome 的内部架构中,页面的每个帧都可能由不同的渲染进程支持。顶层帧和同站点帧共享一个进程;跨站点帧使用不同的进程。所有这些进程“协作”以渲染单个标签的内容,由浏览器进程协调。这相当复杂,但 Chrome 有一个可以跨进程的帧树。这意味着你的一个标签可能正在运行 N 个进程(一个用于主文档,其他用于每个跨站点子文档)。它们通过 IPC 通信,用于跨边界的事件或某些涉及跨上下文的 JavaScript 调用。Web 平台(通过像 COOP/COEPSharedArrayBuffer 等规范)在 Spectre 之后正在考虑这些约束进行演变。

内存和性能成本

站点隔离确实增加了内存使用,因为使用了更多的进程。Chrome 开发者指出,在某些情况下可能会有 10-20% 的内存开销。他们通过为同站点进行“尽力而为的进程合并”,以及限制可以生成的进程数量(我们之前提到过)来缓解一些影响。Firefox 最初由于内存考虑没有隔离每个站点,但在 Spectre 之后,他们找到了更高效的方法,使用 8 个特权进程限制和按需创建进程。Safari 历史上有一个强大的进程模型,但我不确定它是否隔离跨站点 iframe;WebKit2 当然隔离了顶层页面。Apple 的焦点通常也在隐私上(智能跟踪预防将分区 cookie 等),但那是不同的层次。

出于隐私原因,跨站点预取受到限制,目前仅当用户未在目标站点设置 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 在一个核心上所做的工作。这是 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 中的默认渲染引擎,并为性能提升做出了显著贡献,特别是对于图形密集型 Web 内容。WebRender(也是 Rust)基本上获取显示列表并直接在 GPU 上渲染它,处理诸如形状镶嵌、文本等,使用 GPU。这就像将更多的绘制工作移到 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 分道扬镳,但概念上都进行瓦片化和合成。Safari 也将大量工作卸载到 GPU(并使用瓦片化,尤其是在 iOS 上,瓦片绘制是流畅滚动的基础)。

移动端优化:每个引擎都有针对移动端的特殊案例。例如,WebKit 有用于滚动的瓦片覆盖概念(历史上用于 iOS 的 UIWebView)。Android 上的 Chrome 使用“瓦片化”并努力保持光栅任务最少以达到帧率。Firefox 的 WebRender 源自移动优先的 Servo 项目。

JavaScript 引擎

  • V8 (Chromium) 我们已描述:Ignition、Sparkplug、TurboFan、Maglev(截至 2023 年)。
  • SpiderMonkey (Firefox):历史上它有一个解释器,然后是一个 Baseline JIT 和一个优化 JIT(IonMonkey)。自 Firefox 83 (2021) 起,IonMonkey 已被 WarpMonkey 完全取代,后者基于 CacheIR 数据而不是独立的类型推断系统。当前的层级是:Baseline Interpreter、Baseline JIT 和 WarpMonkey 作为顶级优化编译器。SpiderMonkey 也有一个不同的 GC(也是分代的,自 2012 年起称为 Incremental GC,现在大多是增量/并发的)。
  • JavaScriptCore (Safari):如前所述,它有 4 个层级(LLInt、Baseline、DFG、FTL)。它使用不同的 GC(WebKit 的 GC 是一种分代标记-清除,历史上称为 Butterfly 或 Boehm 变体,现在是 bmalloc 等)。JSC 的 FTL 使用 LLVM 进行优化,这是独一无二的(V8 和 SM 有自己的编译器,JSC 为一个层级利用 LLVM)。这可以产生非常快的代码,但编译开销很大。JSC 倾向于在某些基准测试中优先考虑峰值性能(它经常在某些测试中表现出色,但 V8 倾向于赶上;它们交替领先)。

在 ES 特性方面,由于 test262 和彼此之间的竞争,所有三个引擎都与最新标准同步良好。

多进程模型差异

  • Chrome:每个标签通常独立,站点隔离在源级别,进程很多(可能有几十个)。
  • Firefox:默认使用较少的进程(8 个内容进程处理所有标签,如果需要处理带 Fission 的跨站点 iframe,可能会更多)。因此,它不一定每个标签一个进程;标签共享一个进程池。这意味着 Firefox 在多个标签场景下可能内存使用较低,但这也意味着一个内容进程崩溃可能同时导致多个标签崩溃(尽管它尝试按站点分组,所以可能所有 Facebook 标签在一个进程中等)。
  • Safari:可能每个标签一个进程(或每几个标签一个)——在 iOS 上,WKWebView 肯定隔离每个 webview。Safari 桌面版历史上也是每个标签独立。不确定它们是否隔离跨源 iframe——苹果没有过多讨论 Spectre 缓解措施,但 Safari 至少为顶层页面提供每域一个进程。

进程间协调:所有引擎都必须解决类似的问题,比如如何在多进程环境中实现 alert()(它会阻塞 JS)——通常浏览器进程显示警报 UI 并暂停该脚本上下文。或者如何处理 prompt/confirm,如何处理模态对话框等。存在细微的差异(例如,Chrome 并不会真正阻塞线程来处理 alert——它在渲染器中旋转一个嵌套运行循环等,而 Firefox 可能会冻结该标签的进程)。

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

特性实现分歧

一些 Web 平台特性是引擎特定的:例如,View Transitions API(以前在 Chrome 中处于实验阶段)在 2025 年 10 月达到了“基线新可用”状态,同文档过渡现在在 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 在 Apple 硬件上通常在图形和低功耗方面表现出色(它们非常注重功耗优化)。

内存:Chrome 以高内存使用而闻名(所有那些进程)。Firefox 试图更保守一些。Safari 在 iOS 上由于内存限制(RAM 有限)而非常高效,并且它们在 WebKit 中做了大量内存优化。

外部贡献者:有趣的是,这些引擎的很多改进来自像 Igalia 这样的外部团队(例如,在 WebKit 和 Blink 中都实现了 CSS Grid)。因此,有时特性大致同时落地。

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

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

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

  • 在 Gecko 中:解析 → 帧树 → 显示列表 → WebRender 场景或层树(如果禁用了 WebRender)→ 合成。
  • 在 WebKit 中:解析 → 渲染树 → 图形层 → 合成(通过 CoreAnimation)。

并且所有都有类似的子系统(DOM、样式、布局、图形、JS 引擎、网络、进程/线程)。

了解这些有助于调试:例如,如果某些东西在 Safari 中卡顿但在 Chrome 中没有,可能是 WebKit 的绘制不同。或者如果 CSS 在 Firefox 中很慢,可能它碰到了一条未被 Stylo 并行化的路径(尽管这很少见)。

总而言之,虽然 Chromium、Gecko 和 WebKit 有不同的实现,甚至有一些不同的创新(Gecko 中的并行 CSS、WebRender GPU 等),但它们越来越多地实现相同的 Web 标准,甚至在许多方面进行合作。引擎的选择对平台供应商和开放 Web 多样性更重要,但作为开发者,你主要关心的是你的站点能在所有地方运行。在底层,每个引擎的独特架构可能导致不同的性能特征或错误,这就是为什么在每个引擎中进行测试和使用性能诊断工具(如 Firefox 的性能工具 vs Chrome 的)可以带来帮助。列出所有差异超出了我们的范围,但希望这能给出一个大致的图景:它们在高层设计上趋同(多进程、相似的管道),但在具体技术解决方案上存在分歧。

结论与进一步阅读

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

给开发者的一些关键要点:

  • 优化网络使用:更少的往返和更小的文件 = 更快的初始渲染。浏览器可以做很多(HTTP/2、缓存、推测加载),但你仍然应该利用资源提示和高效缓存等技术。网络栈是高性能的,但延迟总是杀手。
  • 高效构建 HTML/CSS:结构良好的 DOM 和简洁的 CSS(避免非常深的树或过于复杂的选择器)可以帮助解析和样式系统。理解 CSS 和 DOM 构建计算样式,然后布局计算几何——大量的 DOM 操作或样式更改会触发这些重新计算。
  • 批量 DOM 更新:以避免重复的样式/布局抖动。使用 DevTools 性能面板来捕捉你的脚本何时导致许多布局或绘制。
  • 对动画使用利于合成的 CSS:transform 或 opacity 的动画保持在主线程之外并在合成器上,从而产生流畅的动画。如果可能,避免对布局相关的属性进行动画。
  • 注意 JS 执行:尽管 JS 引擎超快,长时间的任务会阻塞主线程。分解长时间操作(使页面保持响应),在某些情况下考虑使用 Web Workers 进行后台任务。另外,请记住,繁重的 JS 可能导致 GC 暂停(现在很少长时间暂停,但如果内存膨胀可能会发生)。
  • 安全特性:拥抱它们——例如,在适当的时候使用 iframe sandbox 或 rel=noopener,因为你现在知道浏览器无论如何都会隔离它们;与之合作是好的。
  • DevTools 是你的朋友:特别是性能和网络面板是查看浏览器究竟在做什么的宝库。如果某些东西很慢或卡顿,工具通常会指出原因(长布局、慢绘制等)。

对于那些渴望更深入的人,一个极好的资源是 Browser Engineering,作者 Pavel Panchekha 和 Chris Harrelson(可在 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 提供。

进一步阅读

  • 原文链接: x.com/addyosmani/status/...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~

相关文章

0 条评论