w3ctech

Addy Osmani:现代浏览器工作原理深度剖析

原文链接:https://x.com/addyosmani/status/2068394292796871019
作者:Addy Osmani

Web 开发者通常将浏览器视作一个“黑盒”——它仿佛会施展魔法,将 HTML、CSS 和 JavaScript 转化为交互式的 Web 应用。然而事实是,像 Chrome (Chromium)、Firefox (Gecko) 或 Safari (WebKit) 这样的现代 Web 浏览器,是极其复杂的软件工程杰作。它统筹协调网络请求、解析并执行代码、利用 GPU 加速渲染图形,并通过沙盒进程隔离内容以保障安全。

本文将深入探讨现代浏览器的工作原理——重点聚焦于 Chromium 的架构与内核,同时也会指出其他浏览器引擎的差异之处。我们将探索从网络栈、解析流水线,到基于 Blink 的渲染流程、基于 V8 的 JavaScript 引擎、模块加载机制、多进程架构、安全沙盒,以及开发者工具的方方面面。我们的目标是为开发者提供一份易于理解的指南,揭开浏览器幕后运作的神秘面纱。

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

网络与资源加载 (Networking and Resource Loading)

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

**浏览器进程(Browser Process)**是主控进程,负责管理所有其他进程以及浏览器的用户界面。特定网页标签页之外发生的一切,都由浏览器进程控制。

主要步骤包括:

  1. URL 解析与安全检查:浏览器解析 URL 以确定协议(http、https 等)和目标域名。它还会判断输入的是搜索词还是 URL(例如在 Chrome 的多功能地址栏中)。为了防止钓鱼网站,此时可能会查询恶意网站黑名单(Blocklists)等安全功能。
  2. DNS 查询:网络栈将域名解析为 IP 地址(如果未被缓存的话)。这可能需要联系 DNS 服务器。现代浏览器如果配置了相关功能,可能会使用操作系统的 DNS 服务,甚至使用基于 HTTPS 的 DNS (DoH),但最终目的都是获取主机的 IP 地址。
  3. 建立连接:如果与服务器之间没有打开的连接,浏览器就会新建一个。对于 HTTPS 链接,这包括一次 TLS 握手,以安全地交换密钥并验证证书。浏览器的网络线程在底层透明地处理 TCP/TLS 设置等协议。
  4. 发送 HTTP 请求:连接建立后,浏览器会针对该资源发送 HTTP GET 请求(或其他方法)。如果服务器支持,当今的浏览器默认使用 HTTP/2 或 HTTP/3,这允许在单个连接上**多路复用(Multiplexing)**多个资源请求。这打破了过去每个主机最多并发约 6 个连接的限制(HTTP/1.1),大幅提升了性能。例如,在 HTTP/2 中,HTML、CSS、JS 和图片可以在一条 TCP/TLS 链路上并发获取;而在 HTTP/3(基于 QUIC UDP)中,建连延迟被进一步降低。
  5. 接收响应:服务器响应 HTTP 状态码和响应头,随后是响应体(HTML 内容、JSON 数据等)。浏览器读取响应流。如果缺少 Content-Type 响应头或不正确,浏览器可能需要对 MIME 类型进行嗅探(Sniffing),以决定如何处理内容。例如,如果响应看起来像 HTML 但未标记为 HTML,浏览器依然会尝试将其作为 HTML 处理(依据宽容的 Web 标准)。这里同样存在安全措施:网络层会检查 Content-Type,并可能拦截可疑的 MIME 错配或不允许的跨源数据(Chrome 的 CORB 即“跨源读取阻塞”就是这种机制)。浏览器还会向“安全浏览(Safe Browsing)”或类似服务发起查询,以拦截已知的恶意负载。
  6. 重定向与后续步骤:如果响应是 HTTP 重定向(例如状态码为 301 或 302,且带有 Location 头),网络代码将(在通知 UI 线程后)跟随重定向,并向新 URL 重新发起请求。只有获取到包含实际内容的最终响应时,浏览器才会进入内容处理阶段。

所有这些步骤都发生在网络栈中。在 Chromium 中,网络栈运行在专门的**网络服务(Network Service)**中(作为 Chrome “服务化”改造的一部分,它现在通常是一个独立的进程)。浏览器进程的网络线程在底层利用操作系统的网络 API 协调 Socket 通信的底层工作。重要的是,这种设计意味着渲染器进程(负责执行页面代码)无法直接访问网络——它必须请求浏览器进程代为获取资源,这是一个巨大的安全优势。

推测性加载与资源优化 (Speculative Loading and Resource Optimization)

现代浏览器在网络阶段实现了极其复杂的性能优化:

  • Chrome 会在你将鼠标悬停在链接上或开始输入 URL 时,主动执行 DNS 预解析或建立 TCP 连接(使用 Predictor 或 preconnect 机制),这样当你真正点击时,已经省去了一部分延迟。
  • 还有 HTTP 缓存:如果资源已缓存且未过期,网络栈可以直接从浏览器缓存中返回结果,从而避免网络传输。

预加载扫描器 (Preload Scanner): Chromium 实现了一个精密的预加载扫描器,它会在主解析器之前对 HTML 标记进行分词(Tokenize)。当主 HTML 解析器被 CSS 或同步 JavaScript 阻塞时,预加载扫描器会继续扫描原始标记,寻找可以并行获取的资源(如图片、脚本和样式表)。这种机制对现代浏览器性能至关重要,且无需开发者干预即可自动运行。需要注意的是,预加载扫描器无法发现通过 JavaScript 注入的资源,这会导致这些资源按顺序加载而非并发加载。

Early Hints (HTTP 103): Early Hints 允许服务器在生成主响应的“思考时间”内,使用 HTTP 103 状态码发送资源提示。这使得可以在服务端处理期间提前发送 preconnect(预连接)和 preload(预加载)提示,有望将最大内容绘制(LCP)时间缩短数百毫秒。Early Hints 仅适用于导航请求,支持预连接和预加载指令,但不支持 prefetch(预提取)。

Speculation Rules API(推测规则 API): 这是一项较新的 Web 标准,允许开发者定义规则,根据用户的交互模式动态预提取(prefetch)和预渲染(prerender)URL。与传统的链接预提取不同,此 API 可以预渲染整个页面(包括执行 JavaScript),从而实现近乎瞬间的加载。该 API 在 <script> 标签内或 HTTP 头中使用 JSON 语法来指定应推测加载哪些 URL。为防止滥用,Chrome 设有容量限制,并根据紧急程度设置了不同的阈值。

HTTP/2 与 HTTP/3: 大多数基于 Chromium 的浏览器和 Firefox 全面支持 HTTP/2,并且广泛支持基于 QUIC 的 HTTP/3(Chrome 对支持该协议的站点默认开启)。这些协议通过支持并发传输和降低握手开销来提升页面加载速度。从开发者的角度来看,这意味着你可能不再需要使用“雪碧图(Sprite sheets)”或“域名分片(Domain sharding)”等老旧技巧——浏览器已经能在单个连接上高效地并行获取大量小文件。

资源优先级排序: 浏览器还会对某些资源进行优先级排序。通常,HTML 和 CSS 的优先级很高(因为它们会阻塞渲染),脚本可能是中等(如果正确标记了 defer/async 则可能为高),图片通常较低。Chromium 的网络栈会分配权重,甚至可以取消或推迟某些请求,以优先满足首次渲染所需的资源。开发者可以使用 <link rel="preload">Fetch Priority (提取优先级) 来影响资源的优先级。

在网络阶段结束时,浏览器拿到了页面的初始 HTML(假设这是一次 HTML 导航)。此时,Chrome 的浏览器进程会选择一个**渲染器进程(Renderer Process)**来处理这些内容。Chrome 通常会与网络请求并行(推测性地)启动一个新的渲染器进程,以便数据到达时随时待命。这个渲染器进程是隔离的(后文探讨多进程架构时会详述),它将接管解析和渲染页面的工作。

一旦完全接收到响应(或数据流开始涌入),浏览器进程就会提交导航(Commit Navigation):它向渲染器进程发出信号,让其接收字节流并开始处理页面。就在这一刻,地址栏会更新,安全指示器(HTTPS 锁等)也会针对新站点显示。现在,舞台交给了渲染器进程:解析 HTML、加载子资源、执行脚本,并绘制页面。

解析 HTML、CSS 与 JavaScript (Parsing)

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

HTML 解析与 DOM 构建: HTML 规范将 HTML 解析定义为一个具有容错性的过程。无论标记多么不规范,解析器都能生成一个 DOM。这意味着,即使你忘记了闭合 </p> 标签或者标签嵌套错误,解析器也会隐式地修复或调整 DOM 树使其合法。例如,<p>Hello <div>World</div> 会在 DOM 结构中自动将 <p> 闭合在 <div> 之前。解析器为 HTML 中的每个标签或文本创建 DOM 元素和文本节点。每个元素都被放置在一棵反映源码嵌套关系的树中。

一个重要的细节是,HTML 解析器在运行过程中可能会遇到需要获取的资源:例如,遇到 <link rel="stylesheet" href="..."> 会促使浏览器(在网络线程上)请求 CSS 文件;遇到 <img src="..."> 会触发图片请求。这些请求与解析并行发生。解析器在加载这些资源时可以继续解析,但有一个重大例外:脚本

处理 <script> 标签: 如果 HTML 解析器遇到一个 <script> 标签,它会暂停解析,并(在默认情况下)必须先执行完脚本才能继续。这是因为脚本可能使用 document.write() 或其他 DOM 操作 API,从而改变页面结构或后续即将加载的内容。通过在这个时间点立即执行脚本,浏览器保证了操作顺序与 HTML 的相对位置一致。因此,解析器将脚本移交给 JavaScript 引擎执行,只有当脚本执行完毕(且它所做的任何 DOM 修改生效)后,HTML 解析才能恢复。这种脚本阻塞行为解释了为什么在 <head> 中引入大型 <script> 文件会拖慢页面渲染——在脚本下载并运行完毕之前,HTML 解析根本无法继续。

不过,开发者可以通过属性来改变这种行为:在 <script> 标签上添加 deferasync(或使用现代的 ES Module 脚本)可以改变浏览器的处理方式:

  • 使用 async:脚本文件会被并行下载,一旦准备就绪便立即执行,不暂停 HTML 解析(解析器不等待,且脚本不保证按原有的相对顺序执行)。
  • 使用 defer:脚本同样并行下载,但执行会被推迟到 HTML 解析完成之后(并将在稍后按原有的先后顺序执行)。
    在这两种情况下,解析器都不会被阻塞等待脚本,这对性能大有裨益。ES6 模块(<script type="module">)默认也是 defer 的(它们还可以使用 import 语句——后文会单独讲解模块加载)。使用这些技巧,浏览器可以在没有长停顿的情况下继续构建 DOM,从而显著提升页面加载速度。

CSS 解析与 CSSOM: 与 HTML 类似,CSS 文本也必须被解析成浏览器能够操作的结构——通常称为 CSSOM(CSS 对象模型)。CSSOM 本质上是应用于文档的所有样式(规则、选择器、属性)的内部表示。浏览器的 CSS 解析器读取 CSS 文件(或 <style> 块),将它们转化为 CSS 规则列表(内部使用了大量的布隆过滤器等机制来加速样式解析)。接着,在构建 DOM 的同时(或当 DOM 和 CSSOM 都就绪后),浏览器会为每个 DOM 节点计算样式。这一步通常被称为样式解析(Style Resolution)样式计算(Style Calculation)。浏览器将 DOM 和 CSSOM 结合起来,根据层叠规则、继承和默认样式,决定每个元素适用哪些 CSS 规则以及最终的“计算样式(Computed styles)”。其输出可以被理解为每个 DOM 节点与其计算样式(元素最终解析出的 CSS 属性,如颜色、字体、大小等)的映射。

值得注意的是,即使没有任何开发者编写的 CSS,每个元素都有浏览器的默认样式(User-Agent 样式表)。例如,<h1> 在几乎所有浏览器中都有默认的 font-sizemargin。浏览器内置样式规则以最低优先级应用,确保页面有合理的默认呈现。开发者可以在 DevTools 的“Computed (计算样式)”面板查看元素最终应用了哪些确切的 CSS 属性。

渲染阻塞行为: 虽然没有完全加载完 CSS 时 HTML 依然可以继续解析,但两者之间存在“渲染阻塞”关系:浏览器通常会等待 CSS 加载完毕后才进行首次渲染(针对位于 <head> 中的 CSS)。这是因为应用不完整的样式表会导致“未渲染内容闪烁”(FOUC)。在实践中,如果一个未标记 async/defer<script> 出现在 CSS <link> 之后,解析器在执行该脚本前,还会额外等待 CSS 加载完成(因为脚本可能会通过 DOM API 查询样式信息)。经验法则:将样式表链接放在 <head>(它们会阻塞渲染,但早期就需要),将非关键或大型脚本加上 defer/async 或放在底部,以免延迟 DOM 解析。

现在浏览器已经具备了:(1) 由 HTML 构建的 DOM,(2) 解析后的 CSS 规则 CSSOM,以及 (3) 每个 DOM 节点的计算样式。这三者构成了下一个阶段——“布局(Layout)”的基础。但在进入布局之前,我们先稍作探讨 JavaScript 方面:当 JS 引擎(在 Chrome 中是 V8)执行代码时会发生什么?我们将在后续小节专门深入探讨 V8 内部机制。目前只需知道,脚本在运行时可能会修改 DOM 或 CSSOM。浏览器必须响应这些变化,按需重新计算样式或布局(如果频繁进行,会产生性能成本)。

样式与布局 (Styling and Layout)

在这个阶段,浏览器的渲染器进程已经知道了 DOM 的结构以及每个元素的计算样式。接下来的问题是:这些元素在屏幕上的确切位置在哪里?它们有多大?这就是布局(Layout)——有时也称为“回流(Reflow)”——的任务。在这个阶段,浏览器根据 CSS 规则(普通流、盒模型、Flexbox 或是 Grid 等)和 DOM 层级,计算出每个元素的几何信息(大小和位置)

布局树的构建: 浏览器遍历 DOM 树并生成一棵布局树 (Layout Tree)(有时被称为渲染树 Render Tree 或 帧树 Frame Tree)。布局树在结构上与 DOM 树相似,但它会忽略非视觉元素(例如 <script><meta> 标签不会产生盒子),并且在需要时可能会将某些元素拆分为多个盒子(例如,分布在多行中的内联元素可能会对应多个布局盒)。布局树中的每个节点都持有该元素的计算样式,包含其内容(文本或图片)以及影响布局的计算属性(如 widthheightpadding 等)。

在布局过程中,浏览器计算每个元素盒子的精确坐标(x, y)和尺寸(宽,高)。这涉及 CSS 规范中定义的复杂算法:例如,在普通文档流中,块级元素默认自上而下堆叠并占据整个宽度,而内联元素在行内流动并按需换行。Flexbox 或 Grid 等现代布局模式则有其专有的算法。引擎必须考虑字体度量以进行换行(因此文本布局涉及测量文本排版段落),还必须处理外边距(Margin)、内边距(Padding)、边框(Border)等。这里存在海量的边缘情况(如外边距折叠、浮动、脱离文档流的绝对定位元素等),使得布局成为一个惊人复杂的计算过程。即使是“简单”的自上而下布局,也必须计算依赖于可用宽度和字体大小的文本换行。浏览器引擎有专门的团队进行多年开发,以保证布局的准确与高效。

关于布局树的几个细节:

  • 带有 display: none 的元素会被完全从布局树中剔除(不生成任何盒子)。相反,仅仅是不可见(如 visibility: hidden)的元素仍会生成布局盒(占据空间),只是稍后不被绘制。
  • 伪元素(如 ::before::after)如果生成了内容,则会包含在布局树中(因为它们具有视觉盒子)。
  • 布局树节点知道自己的几何信息。例如,一个 <p> 元素的布局节点知道它相对于视口的位置和尺寸,并拥有其内部每行或内联盒子的子节点。

布局计算: 布局通常是一个递归过程。从根节点(<html> 元素)开始,浏览器计算视口(Viewport)的大小,然后在其内部布局子元素,以此类推。许多元素的尺寸依赖于它的子元素或父元素。布局算法经常需要对浮动等复杂交互进行多次遍历,但通常它是单向(自上而下)进行的,必要时会进行回溯。

这一阶段结束时,页面上每个元素的坐标和大小都已确定。我们现在可以将页面概念化为一堆内部包含文本或图片的“盒子”。但是,我们还没有真正在屏幕上画出任何东西——这就是下一步:绘制。

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

样式与布局总结:
从 HTML 和 CSS 出发,浏览器构建了:

  1. DOM 树 - 结构与内容
  2. CSSOM - 解析后的 CSS 规则
  3. 计算样式 (Computed Styles) - 将 CSS 规则匹配到每个 DOM 节点的结果
  4. 布局树 (Layout tree) - 过滤出视觉元素的 DOM 树,附带每个节点的几何信息。

每个阶段都建立在前一个阶段之上。如果任意阶段发生变化(例如脚本改变了 DOM 或修改了 CSS 属性),后续阶段都可能需要更新。这条影响链意味着布局和绘制依赖于最新的样式,以此类推。

绘制、合成与 GPU 渲染 (Painting, Compositing, and GPU Rendering)

绘制 (Painting) 是获取结构化的布局信息,并在屏幕上实际生成像素的过程。在传统的认知中,浏览器会遍历布局树并为每个节点发出绘制指令(“在这个坐标画背景,画文字,画图片”)。现代浏览器在概念上依然这么做,但通常会将工作拆分为多个阶段,并利用 GPU 来提升效率。

绘制 / 光栅化 (Rasterization): 在布局完成后的渲染器主线程上,Chrome 会通过遍历布局树生成绘制记录 (Paint records)(或称为显示列表 Display List)。这基本等同于一系列带有坐标的绘制操作指令,就像画家规划如何作画一样:“在 (x,y) 画一个宽高为 W 和 H、填充蓝色的矩形,然后在 (x2, y2) 用 XYZ 字体画‘Hello’文字……”。这个列表是按照正确的 z-index 顺序生成的,以确保重叠的元素能正确覆盖。浏览器必须处理层叠上下文 (Stacking contexts) 和透明度等,以获得正确的顺序。

过去,浏览器可能只是简单地按顺序直接在屏幕上绘制每个元素。但如果页面只有部分内容发生变化,这种做法(重绘整个页面)就会非常低效。因此,现代浏览器会将这些绘制指令记录下来,然后利用合成 (Compositing) 步骤将最终图像组装起来,并在此过程中大量应用 GPU 加速。

分层与合成 (Layering and Compositing): 合成是一种优化策略,即将页面拆分为可以独立处理的多个图层 (Layers)。例如,一个具有 CSS transform 或动画的定位元素可能会获得自己的图层。图层就像独立的“草稿画布”——浏览器可以独立对每个图层进行光栅化(将绘制指令转化为像素),然后合成器将它们在屏幕上混合在一起,这通常由 GPU 完成。

在 Chromium 的管线中,生成绘制记录后,会有一个构建图层树 (Layer tree) 的步骤。有些图层是自动创建的(如 <video><canvas> 元素,或者带有特定 CSS 属性的元素会被提升为图层),开发者也可以使用 will-changetransform 这样的属性来显式提示浏览器创建图层。分层的好处在于:一个图层上的移动或透明度变化可以直接被“合成”(即只需重新渲染或移动该图层),而无需重绘整个页面。然而,过多的图层会消耗大量内存并增加开销,因此浏览器对此非常谨慎。

确定图层后,Chrome 主线程将任务移交给合成器线程 (Compositor Thread)。合成器线程运行在渲染器进程中,但独立于主线程(因此即使 JS 主线程正忙,它也能继续工作,这对于实现流畅的滚动和动画至关重要)。合成器线程的工作是提取这些图层,对它们进行光栅化(将矢量绘图转换为实际的像素位图),并将它们组装成帧。

借助 GPU 的光栅化: 光栅化任务也可以并行分配。在 Chrome 中,合成器线程会将图层分割成更小的瓦片 (Tiles)(想象 256x256 或 512x512 像素的块)。然后,它将这些瓦片分派给多个光栅化工作线程(甚至可以跨多个 CPU 核心运行)并发执行。每个工作线程负责一个瓦片——本质上就是针对该区域的绘制指令——并生成位图(像素数据)。重要的是,Chrome 的图形库 Skia 可以利用 CPU 或 GPU 进行光栅化;通常这些线程在 CPU 上渲染像素,然后将其上传到 GPU 内存中作为纹理 (Textures)。当所有需要的瓦片都绘制完成后,合成器线程就准备好了一套带有纹理的图层。

合成器接着组装出一个合成器帧 (Compositor frame) —— 这本质上是一条发给浏览器进程的消息,其中包含了构成屏幕画面的所有四边形(quads/图层瓦片)、它们的位置等信息。该帧通过 IPC(进程间通信)发送回浏览器进程,并最终由浏览器的 GPU 进程(Chrome 中专用于访问 GPU 的进程)接手并在屏幕上显示。浏览器的界面(如标签栏)同样也通过合成器帧绘制,在最后一步中混合。GPU 进程接收帧,通过底层 API(OpenGL / DirectX / Metal 等)极快地处理,在屏幕正确位置绘制每个纹理并应用变换。这就是你看到的最终画面。

当你滚动页面或观看动画时,这条管线的优势展露无遗。例如,滚动页面主要只是在较大的页面纹理上改变视口 (Viewport)。合成器只需移动图层位置并请求 GPU 绘制进入视口的新部分,主线程完全无需重新绘制任何东西。如果动画只是一个变换(如移动一个独占图层的元素),合成器线程每帧都能更新该元素的位置并生成新帧,完全不需要主线程参与,也不需要重新运行样式和布局计算。这就是为什么建议使用**“仅触发合成 (compositing-only)”**的动画(如改变 transformopacity,不触发布局)——即使主线程正忙,它们也能流畅地以 60 FPS 运行。相反,像动画 height 这样的属性会强制每一帧重新布局和绘制,如果主线程跟不上,就会导致严重的卡顿。

简言之,Chrome 的渲染管线是:DOM → 样式 → 布局 → 绘制(记录指令) → 分层 → 光栅化(瓦片) → 合成 (GPU)。 Firefox 使用 WebRender 的管线在前半部分类似,但它跳过了显式构建图层,而是直接向 GPU 发送显示列表,由 GPU Shader 处理几乎所有绘制操作。Safari (WebKit) 同样使用了多线程合成和基于硬件(Mac 上的 CALayer)的 GPU 渲染。

为了保证平滑渲染,对于每一个动画帧(目标是 60fps,即每帧 16.7ms),合成器都会努力产出一帧。如果主线程执行 JavaScript 花了太长时间,合成器可能会跳帧,导致肉眼可见的卡顿。开发者可以使用 requestAnimationFrame 将 JS 更新与帧边界对齐,从而辅助平滑渲染。

深入 JavaScript 引擎 (Inside V8)

JavaScript 驱动了网页的交互行为。在 Chromium 中,V8 引擎负责执行 JavaScript 和 WebAssembly。

V8 解析与编译管线

  • 后台编译 (Background compilation): V8 在后台线程上编译大部分 JavaScript 源码。当从网络下载到第一个数据块时,V8 就可以并行开始解析。主线程仅在代码真正执行前处理很短的 AST 内部化和字节码定稿。这让主线程上的编译耗时大幅减少。
  • 解析与字节码: 遇到 <script> 时,V8 首先解析源码生成抽象语法树 (AST)。它包含一个轻量级的“预解析器 (Preparser)”,对函数仅做最基础的合法性验证(跳过内部实现),直到函数真正被调用时才进行完整解析。V8 随后使用名为 Ignition 的解释器将 AST 转化为紧凑的字节码 (Bytecode)。字节码启动快,非常适合缩减页面初始加载时间。
  • Explicit Compile Hints(显式编译提示): V8 允许开发者使用特殊的注释向引擎发出信号,对关键路径代码进行强制后台“急切编译(eager compilation)”,这在测试中能显著缩短前台的解析与编译时间。

JIT 编译层级 (JIT Compilation Tiers)

JavaScript 是动态类型的语言,直接解释执行效率较低。V8 拥有多层即时编译器 (JIT Compilers),它的理念是:对执行频繁的“热点”代码投入更多编译时间进行极度优化,以提升运行速度。

  1. Ignition: 字节码解释器。在解释执行的同时,它会收集代码的性能剖析数据(Profiling information),比如变量经常是什么类型。
  2. Sparkplug(基线 JIT): 一款非优化编译器,能极快地将字节码转换为机器码。它不进行深层优化,目的是提供比解释器更快的执行速度。
  3. Maglev(中层 JIT): 针对处于“温热(warm)”状态(有一定调用频率但还未达到巅峰)的代码。它的编译速度比最高层级快很多,同时能生成性能优异的代码。
  4. TurboFan(优化 JIT): 如果一个函数被调用成千上万次,V8 会启用最强编译器 TurboFan。它利用 Ignition 收集到的类型反馈(Type feedback),运用内联(Inlining)、消除边界检查等高级技术,生成极其优化的机器码。(注:目前 V8 正在用名为 Turboshaft 的新架构逐步替换 TurboFan 的底层引擎)。

如果在后续执行中,变量的类型打破了原有的优化假设(例如一个一直传入数字的函数突然传入了字符串),V8 会执行**“去优化(Deoptimization,或逆优化)”**,退回到未优化的状态。因此,保持 JavaScript 变量类型的稳定性对于维持极致性能至关重要

内存管理与垃圾回收 (Garbage Collection)

V8 使用被称为 Orinoco 的垃圾回收器,它是分代、增量且并发的

  • 分代 (Generational): 内存分为新生代和老生代。大多数新对象都在“新生代 (Nursery)”中分配。新生代的 GC 非常频繁且极快(使用 Scavenge 算法,将存活对象复制到新空间并丢弃其余的)。存活时间足够长的对象会被晋升到“老生代”。
  • 标记-清除-整理 (Mark-sweep-compact): 对于老生代,V8 偶尔会触发“Stop the world”(短暂暂停 JS 执行),进行全局标记并清除未引用对象。但 Orinoco 已经实现了在后台并发执行大部分标记工作,从而将停顿时间降至最低。
  • 增量式 (Incremental): V8 不再进行一次漫长的停顿,而是将 GC 切分为许多极短的切片,穿插在 JS 执行的间隙中完成,避免掉帧。

对于开发者来说,现代 GC 的性能已经好到“毫无存在感”,但依然要注意避免在紧凑的循环中创建海量长生命周期的对象。此外,像 document.querySelector 或网络请求等 Web API 实际上并不是 V8 的一部分,它们是浏览器(用 C++ 编写)通过接口绑定暴露给 V8 引擎调用的。

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

与传统的 <script> 标签不同,ES Modules 拥有独立的加载和执行模型。

  • 静态导入: 当遇到 <script type="module">,浏览器会把它作为入口点。它会解析代码并找出所有的 import 语句,并递归地获取和解析所有依赖的模块,构建出一张模块依赖图 (Dependency graph)。这一过程是异步的,只有当依赖图彻底就绪后,模块才会依据依赖顺序(最底层的依赖最先执行)依次运行。
  • 动态导入 import() 可以在代码执行期间动态按需加载模块,它返回一个 Promise。这对“代码分割(Code-splitting)”极为有用。
  • 导入映射 (Import Maps): 在无构建工具的 Web 环境中,裸写 import { react } from 'react' 会报错,因为 'react' 不是有效 URL。Import Maps(<script type="importmap">)允许开发者在浏览器端配置映射表,将包名映射到实际的 CDN 网址。它已经被三大主流浏览器全面支持,是目前去打包化(unbundled)开发的基石。

浏览器多进程架构 (Multi-Process Architecture)

现代浏览器不再是“单进程”巨兽。Chromium 首创了多进程架构以换取稳定性、安全性和性能隔离。

  • 浏览器进程 (Browser Process): 只有一个。负责 UI(地址栏、书签、菜单栏等)和统筹协调网络、进程调度。
  • 渲染器进程 (Renderer Process): 通常每个标签页(或每个站点)对应一个独立的渲染器进程。它运行 Blink 引擎和 V8,是受到沙盒(Sandbox)严格限制的。
  • GPU 进程: 专用于与底层硬件图形 API 交互。如果显卡驱动崩溃,只会导致 GPU 进程挂掉而不会拉垮整个浏览器,并且它也能在沙盒中运行以防范恶意图形代码。
  • 网络进程 / 插件进程 / 扩展进程: 各司其职,模块化运作。

多进程的优势:

  1. 稳定性: 一个复杂的网页崩溃(例如内存泄漏或死循环)只会导致该标签页显示“哎呀,崩溃了 (Aw, Snap)”,其他标签页安然无恙。
  2. 性能隔离: 不同标签页的代码可以在不同的 CPU 核心上并行运行,互不抢占。
  3. 安全性 (沙盒): 这是重点。

站点隔离与沙盒 (Site Isolation and Sandboxing)

这构成了浏览器最坚固的防线:

  • 沙盒 (Sandboxing): 渲染器进程在受限环境中运行。它没有权限直接读取操作系统文件、开启摄像头或任意发起网络请求。即使黑客利用 V8 漏洞控制了渲染进程,他们也会被困在“沙盒”里,必须找到另一个操作系统级别的漏洞进行“沙盒逃逸 (Sandbox Escape)”才能造成真正的破坏。
  • 站点隔离 (Site Isolation): 在 2018 年“幽灵(Spectre)” CPU 漏洞爆发后,浏览器架构发生了巨变。Spectre 漏洞证明,同一进程内的恶意代码可以“窃听”到同一进程内其他站点在内存里的敏感数据。为此,Chrome 强制实施了“严格站点隔离”:每一个物理站点(Site)都会被分配到完全不同的物理进程中
  • 进程外 iframe (OOPIF): 如果你在你的安全网站上通过 iframe 嵌入了一个包含恶意代码的 evil.com。在过去,它们可能在同一个渲染器进程内运行;而现在,Chrome 会将这个跨源的 iframe 拆分到一个独立的进程中。这两个进程通过极其复杂的 IPC(进程间通信)机制,在后台拼装成用户眼前的单个网页。

对于开发者,这意味着跨源通信(如 postMessage)实际上跨越了进程边界,但浏览器底层优雅地屏蔽了这些复杂性。代价是,更多的进程带来了相对更高的内存占用(约 10-20%)。

Chromium、Gecko 与 WebKit 的异同

Chromium (Chrome/Edge/Blink)、Gecko (Firefox) 和 WebKit (Safari) 这三大引擎在总体宏观架构上高度趋同,但技术实现上各有千秋:

  • CSS 与样式计算: Blink 和 WebKit 的样式引擎主要由 C++ 编写,通常在单线程上顺序执行。而 Firefox 的 Gecko 引入了由 Rust 编写的 Stylo 引擎,它能够多线程并行计算 DOM 树不同分支的 CSS 样式,这在复杂 DOM 结构下性能极佳。
  • 图形渲染: Firefox 引入了同样由 Rust 编写的 WebRender。与 Chrome 的 CPU 光栅化后转交 GPU 的模式不同,WebRender 更像是游戏引擎的逻辑,直接向 GPU 发送显示列表,让 GPU 利用 Shader 直接绘制几乎所有矢量图形,极大提升了重绘极度复杂页面的帧率。
  • JavaScript 引擎: Firefox 使用 SpiderMonkey(内置 WarpMonkey JIT),Safari 使用 JavaScriptCore (JSC)。JSC 的特色是引入了 LLVM 作为其最顶级的优化编译层,能生成性能极高但也编译耗时极长的机器码,常在特定的跑分测试中夺魁。
  • 进程模型: 虽然大家都迈向了多进程,但策略略有不同。Firefox 的 Project Fission 也在全面推行类似 Chrome 的站点隔离。Safari (WebKit2) 在 Mac 和 iOS 上也为每个 WebView 分配了独立的受限进程。

结语:给开发者的核心启示

了解浏览器的“黑盒”之后,作为开发者我们可以获得以下具备指导意义的心智模型:

  1. 善用网络机制: HTTP/2/3 虽然解决了并发限制,但物理延迟无法消除。合理使用 <link rel="preload">preconnect 帮助预加载扫描器尽早开工。
  2. 避免布局抖动: 深层理解“DOM -> 样式计算 -> 布局 -> 绘制”这条单向管线。批量读取和修改 DOM 操作,防止因频繁触发“布局(Layout)”导致的性能损耗。
  3. 让 GPU 为动画代劳: 尽量只使用 transformopacity 做动画,它们在“合成器线程”上运行,完全不会被主线程上阻塞的 JavaScript 干扰。
  4. 警惕主线程阻塞: 尽管 V8 引擎拥有四层 JIT,极其强悍,但 JavaScript 依然运行在单线程上。长时间的任务必须拆分,或者交给 Web Worker,否则合成器将无法收到新的帧导致页面卡死。
  5. 用好开发者工具: Chrome/Firefox 的 Performance 面板就是一座宝库。当页面卡顿时,这套心智模型能让你一眼看出是 Scripting (V8)、Rendering (样式与布局) 还是 Painting (绘制/合成) 拖了后腿。

Web 浏览器是当代软件工程中最令人惊叹的杰作之一。它们精妙地抽象了底层操作系统的极度复杂性,让我们能用纯粹的 HTML/CSS/JS 构建丰富多彩的世界。但当你愿意掀开引擎盖,一窥底层的奥秘时,你定会写出性能更极致、更经得起考验的 Web 应用。

(注:如果想进一步深入学习,极力推荐阅读 Pavel Panchekha 和 Chris Harrelson 编写的开源书籍《Browser Engineering》(browser.engineering),以及 Google Chrome 团队的“Inside look at modern web browser”系列文章。)

w3ctech微信

扫码关注w3ctech微信公众号

共收到0条回复