Addy Osmani (@addyosmani)
2026-06-21
D
原文
---
title: "现代浏览器是怎么工作的"
author: "Addy Osmani (@addyosmani)"
source_url: "https://x.com/addyosmani/status/2068394292796871019"
published_at: "2026-06-20T18:03:22.000Z"
fetched_at: "2026-06-22T15:25:03Z"
updated_at: "2026-06-25T02:24:28Z"
language: "zh"
review_status: "draft"
---

# 现代浏览器是怎么工作的
Web 开发者常把浏览器当成一个**黑盒**:它似乎能神奇地把 HTML、CSS 和 JavaScript 变成可交互的 Web 应用。但实际上,像 Chrome([Chromium](https://www.chromium.org/chromium-projects/))、Firefox([Gecko](https://firefox-source-docs.mozilla.org/overview/gecko.html))或 Safari([WebKit](https://webkit.org/))这样的现代 Web 浏览器,是一套非常复杂的软件系统。它要调度网络请求,解析并执行代码,用 GPU 加速图形渲染,还要把内容隔离在沙箱进程里来保证安全。
这篇文章会深入讲清楚**现代浏览器是怎么工作的**:以 **Chromium** 的架构和内部机制为主线,同时说明其他引擎有哪些不同。我们会从网络栈和解析管线一路讲到 [Blink](https://www.chromium.org/blink/) 的渲染流程、[V8](http://v8.dev/) 的 JavaScript 引擎、模块加载、多进程架构、安全沙箱和开发者工具。目标是用开发者容易理解的方式,把浏览器幕后发生的事拆开讲明白。

下面就从浏览器内部开始这趟旅程。
### **网络与资源加载**

每次页面加载,都是从浏览器的网络栈去 Web 上获取资源开始的。当你输入一个 URL 或点击一个链接时,浏览器的 UI 线程(运行在「[browser process](https://www.chromium.org/developers/design-documents/multi-process-architecture/)」里)会发起一次导航请求。
> **browser process** 是主控进程,负责管理所有其他进程和浏览器的用户界面。所有不属于某个具体网页标签页的事情,都由 browser process 控制。
这个过程包括:
**URL 解析与安全检查**:浏览器会解析 URL,确定协议(http、https 等)和目标域名。它还要判断输入内容是搜索词还是 URL(比如 Chrome 的 omnibox 就会这么做)。浏览器也可能在这里检查黑名单等安全机制,避开钓鱼网站。
**DNS 查询**:网络栈会把域名解析成 IP 地址(除非已经命中缓存)。这可能需要联系 DNS 服务器。现代浏览器可能使用操作系统的 DNS 服务,也可能在配置允许时使用 DNS over HTTPS(DoH),但最终都要拿到目标主机的 IP。
**建立连接**:如果还没有打开到服务器的连接,浏览器就会新建一条。对 HTTPS URL 来说,这还包括 TLS 握手,用来安全交换密钥并校验证书。浏览器的网络线程会在底层处理 TCP/TLS 建连细节。
**发送 HTTP 请求**:连接建立后,浏览器会为这个资源发送一个 HTTP GET 请求(或其他方法)。今天的浏览器如果发现服务器支持,默认会使用 HTTP/2 或 HTTP/3;这让多个资源请求可以在同一个连接上多路复用。这样就避开了 HTTP/1.1 时代每个主机大约 6 条并发连接的旧限制。比如在 HTTP/2 下,HTML、CSS、JS、图片都可以在同一条 TCP/TLS 连接上并发获取;HTTP/3(基于 UDP 上的 QUIC)还能进一步降低建连延迟。
**接收响应**:服务器会返回 HTTP 状态码和 header,随后是响应体(HTML 内容、JSON 数据等)。浏览器读取响应流。如果 Content-Type header 缺失或不正确,浏览器可能需要嗅探 MIME 类型,决定该怎样处理内容。比如一个响应看起来像 HTML,但没有标成 HTML,浏览器仍会按照 Web 标准的宽松规则尝试把它当 HTML 处理。这里同样有安全检查:网络层会检查 Content-Type,并可能阻止可疑的 MIME 不匹配或不允许的跨域数据(Chrome 的 CORB,也就是 Cross-Origin Read Blocking,就是这类机制)。浏览器还会查询 Safe Browsing 或类似服务,拦截已知的恶意载荷。
**重定向和后续步骤**:如果响应是 HTTP 重定向(比如 301 或 302,并带有 Location header),网络代码会先通知 UI 线程,然后跟随重定向,对新 URL 重新发起请求。只有拿到最终的、带实际内容的响应后,浏览器才会进入内容处理阶段。
这些步骤都发生在网络栈里。在 Chromium 中,网络栈运行在专门的 Network Service 里;现在它通常是一个独立进程,是 Chrome「[servicification](https://www.chromium.org/servicification/)」工作的一部分。browser process 的网络线程负责协调底层 socket 通信,底下使用操作系统提供的网络 API。这里很关键的一点是:renderer(也就是后面会执行页面代码的进程)不会直接访问网络;它要请 browser process 去取自己需要的资源。这带来了安全收益。
**推测性加载与资源优化**
现代浏览器会在网络阶段做很多复杂的性能优化。比如当你把鼠标悬停在一个链接上,或开始输入一个 URL 时,Chrome 可能会主动做 DNS 预解析,或提前打开 TCP 连接(通过 Predictor 或 preconnect 机制)。这样一来,如果你真的点击了链接,一部分延迟已经提前消掉了。还有 HTTP 缓存:如果某个资源已经缓存且仍然新鲜,网络栈可以直接从浏览器缓存满足请求,避免一次网络往返。
**Preload scanner 的工作方式**:Chromium 实现了一个复杂的 [preload scanner](https://web.dev/articles/preload-scanner),会在主解析器前面先对 HTML 标记做分词。当主 HTML 解析器被 CSS 或同步 JavaScript 阻塞时,preload scanner 会继续扫描原始 HTML 标记,找出图片、脚本、样式表等可以并行获取的资源。这是现代浏览器性能的基础机制,会自动运行,不需要开发者介入。preload scanner 发现不了通过 JavaScript 注入的资源,所以这类资源更可能被串行加载,而不是并发加载。
**Early Hints(HTTP 103)**:[Early Hints](https://developer.chrome.com/docs/web-platform/early-hints) 允许服务器在生成主响应时,用 HTTP 103 状态码先发出资源提示。这样 preconnect 和 preload hint 就能在服务器生成响应的这段时间里提前发出,可能把 Largest Contentful Paint 改善数百毫秒。Early Hints 只适用于导航请求,支持 preconnect 和 preload 指令,但不支持 prefetch。
**Speculation Rules API**:[Speculation Rules API](https://developer.chrome.com/docs/web-platform/implementing-speculation-rules) 是一个较新的 Web 标准,允许开发者定义规则,根据用户交互模式动态 prefetch 和 prerender URL。不同于传统的 link prefetch,这个 API 可以 prerender 整个页面,包括 JavaScript 执行,从而让页面几乎瞬间打开。这个 API 使用 script 元素或 HTTP header 里的 JSON 语法,指定哪些 URL 应该被推测性加载。Chrome 设置了上限来防止滥用,并会根据紧急程度使用不同的容量配置。
**HTTP/2 和 HTTP/3**:大多数 Chromium 系浏览器和 Firefox 都完整支持 HTTP/2,[HTTP/3](https://alexandrehtrb.github.io/posts/2024/03/http2-and-http3-explained/)(基于 QUIC)也已经被广泛支持(Chrome 对支持它的网站默认开启)。这些协议通过允许并发传输、减少握手开销来改善页面加载。站在开发者角度,这意味着你可能不再需要 sprite sheet 或 domain sharding 这类技巧了:浏览器可以在同一个连接上高效并行获取大量小文件。
**资源优先级**:浏览器还会给不同资源排优先级。通常 HTML 和 CSS 是高优先级,因为它们会阻塞渲染;脚本可能是中等优先级,如果正确标记了 defer/async,也可能更高;图片通常更低。Chromium 的网络栈会分配优先级权重,甚至可以取消或推迟某些请求,以优先保证首次渲染所需的资源。开发者可以用 [link rel=preload](https://web.dev/articles/preload-critical-assets) 和 [Fetch Priority](https://web.dev/articles/fetch-priority) 来影响资源优先级。
网络阶段结束时,浏览器已经拿到了页面的初始 HTML(假设这是一次 HTML 导航)。这时,Chrome 的 browser process 会选择一个 renderer process 来处理内容。Chrome 往往会在网络请求进行时并行启动一个新的 renderer process(推测性启动),这样数据一到就能开始工作。这个 renderer process 是隔离的(后面讲多进程架构时会展开),接下来它会接管页面的解析和渲染。
一旦响应完全接收完毕(或者随着流式响应不断到达),browser process 就会提交这次导航:它通知 renderer process 接管这段字节流,并开始处理页面。此刻地址栏更新,新站点的安全指示(HTTPS 锁等)显示出来。接下来的工作转到 renderer process:解析 HTML、加载子资源、执行脚本、绘制页面。
### **解析 HTML、CSS 和 JavaScript**
renderer process 收到 HTML 内容后,它的主线程会按照 HTML 规范开始解析。HTML 解析的产物是 DOM(Document Object Model,文档对象模型):一棵由对象组成的树,用来表示页面结构。解析是增量式的,可以和网络读取交错进行;浏览器会以流式方式解析 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](https://web.dev/articles/efficiently-load-third-party-javascript)(或者使用现代 ES module 脚本),都会改变浏览器的处理方式。async 会并行获取脚本文件,脚本一就绪就执行,不会暂停 HTML 解析;解析器不等待,脚本也不保证和其他 async 脚本按原始顺序执行。defer 也会并行获取脚本,但会把执行推迟到 HTML 解析结束之后,并在那时按原始顺序执行。两种情况下,解析器都不会因为等待脚本而阻塞,通常对性能更好。ES6 模块(`<script type="module">`)默认也会 defer;模块还可以使用 import 语句,后面会单独讲模块加载。通过这些手段,浏览器可以更少停顿地构建 DOM,让页面加载更快。
**CSS 解析与 CSSOM**:和 HTML 一样,CSS 文本也必须被解析成浏览器能处理的结构,通常叫 CSSOM(CSS Object Model)。[CSSOM](https://web.dev/articles/critical-rendering-path/constructing-the-object-model) 本质上是适用于文档的所有样式(规则、选择器、属性)的表示。浏览器的 CSS 解析器会读取 CSS 文件(或 `<style>` 块),把它们转成 CSS 规则列表,并使用大量 bloom filter 等机制加速样式解析。随后,在 DOM 构建过程中(或当 DOM 与 CSSOM 都就绪后),浏览器会为每个 DOM 节点计算样式。这一步通常叫样式解析(style resolution)或样式计算。浏览器会把 DOM 和 CSSOM 结合起来,确定每个元素适用哪些 CSS 规则,以及最终的计算样式是什么(在应用 cascade、继承和默认样式之后)。产物通常可以理解成:每个 DOM 节点都关联着一份计算样式,也就是该元素解析后的最终 CSS 属性,比如 color、font、size 等。
值得注意的是,即使作者没有写任何 CSS,每个元素也都有浏览器默认样式(user-agent stylesheet)。比如 `<h1>` 在几乎所有浏览器里都有默认的 font-size 和 margin。浏览器内置样式规则会以最低优先级应用,保证页面有一个合理的默认呈现。开发者可以在 DevTools 里查看 computed styles,看到某个元素最终得到哪些 CSS 属性。样式计算会综合所有适用样式(user agent、user styles、author styles),确定每个元素的最终样式。
**渲染阻塞行为**:HTML 解析虽然可以在 CSS 没有完全加载时继续,但这里存在[渲染阻塞关系](https://web.dev/learn/performance/understanding-the-critical-path):浏览器通常会等 CSS 加载完之后才做第一次渲染(尤其是 `<head>` 里的 CSS)。原因是,如果用不完整的样式表渲染页面,用户可能看到无样式内容闪烁。实践中,如果一个没有标记 async/defer 的 `<script>` 出现在 HTML 中某个 CSS `<link>` 之前,它还会额外等待 CSS 加载完再执行脚本,因为脚本可能通过 DOM API 查询样式信息。经验法则是:把样式表链接放在 head 里(它们阻塞渲染,但早期就需要),把非关键或大型脚本加 defer/async,或放在底部,避免拖慢 DOM 解析。
现在,浏览器已经有了:(1)从 HTML 构建出的 DOM;(2)解析好的 CSS 规则(CSSOM);(3)每个 DOM 节点的计算样式。它们共同构成下一阶段——布局(layout)的基础。但在继续之前,我们还要更细地看看 JavaScript 这一侧:具体来说,JS 引擎(Chrome 中是 V8)到底怎么执行代码。前面提到了脚本会阻塞解析,但 JS 真正运行起来时发生了什么?后面会用单独一节讲 V8 和 JS 执行的内部机制。现在先假设:脚本运行时可能会修改 DOM 或 CSSOM,比如调用 `document.createElement` 或设置元素样式。浏览器可能需要响应这些变化,按需重新计算样式或布局;如果反复发生,就会带来性能成本。解析期间脚本的初始运行通常包括设置事件处理器,或操作 DOM(比如模板渲染)。之后页面通常完成解析,然后进入布局和渲染。
### **样式与布局**
到了这个阶段,浏览器的 renderer process 已经知道 DOM 的结构,以及每个元素的计算样式。下一个问题是:这些元素应该放在屏幕上的哪里?它们有多大?这就是布局(layout)的工作,也叫 reflow 或 layout calculation。在这个阶段,浏览器会根据 CSS 规则(普通流、盒模型、flexbox、grid 等)和 DOM 层级,计算每个元素的几何信息:大小和位置。

**布局树构建**:浏览器会遍历 DOM 树,生成一棵布局树(有时也叫 render tree 或 frame tree)。布局树在结构上类似 DOM 树,但会省略非可视元素(例如 script 或 meta 标签不会产生盒子),也可能把某些元素拆成多个盒子(例如一个跨多行排版的 HTML 元素,可能对应多个 layout box)。布局树中的每个节点都持有该元素的计算样式,并保存节点内容(文本或图片)以及影响布局的计算属性(宽、高、padding 等)。
布局期间,浏览器会为每个元素盒子计算准确的位置(x、y 坐标)和大小(width、height)。这涉及 CSS 规范定义的算法:比如在普通文档流中,块级元素从上到下堆叠,默认占满可用宽度;inline 元素则在行内流动,并在需要时换行。现代布局模式,比如 [flexbox](https://web.dev/learn/css/flexbox) 或 [grid](https://web.dev/learn/css/grid),都有各自的算法。引擎还要考虑字体度量来断行,所以文本布局需要测量一段段文本;它还要处理 margin、padding、border 等。这里有大量边界情况,例如 margin 折叠、float、脱离普通流的绝对定位元素等,所以布局是一个出人意料地复杂的过程。即使只是一个「简单」的从上到下布局,也要根据可用宽度和字体大小决定文本在哪里换行。浏览器引擎都有专门团队,并经过多年开发,才能把布局做得既准确又高效。
关于布局树,还有几个细节:
- `display:none` 的元素会完全从布局树中省略,不产生任何盒子。相反,只是不可见的元素(比如 `visibility:hidden`)仍会得到一个布局盒子,占据空间,只是后面不会绘制。
- `::before` 或 `::after` 这类会生成内容的伪元素,也会出现在布局树中,因为它们确实有可视盒子。
- 布局树节点知道自己的几何信息。比如一个 `<p>` 元素的布局节点会知道它相对于 viewport 的位置和尺寸,并且为其中每一行或 inline box 持有子节点。
**布局计算**:布局通常是一个递归过程。浏览器从根节点(`<html>` 元素)开始,先计算 viewport(对于 `<html>`/`<body>`)的大小,然后在其中排布子元素,再一路向下。很多元素的尺寸依赖子元素或父元素,比如容器可能为了容纳子元素而扩展,子元素也可能是父元素宽度的 50%。布局算法经常需要为 float 或某些复杂交互做多轮处理,但总体上会沿一个方向推进(自上而下),必要时回溯。
到这个阶段结束时,页面上每个元素的位置和尺寸都已经确定。我们可以在概念上把页面看成一堆盒子,里面装着文本或图片。但这时屏幕上还没有真正画出任何东西;下一步才是绘制(painting)。
不过这里有一个关键概念:布局可能很昂贵,尤其是被反复触发的时候。如果 JavaScript 后续改变了某个元素的大小,或者插入了内容,就可能强制浏览器重新布局页面的一部分,甚至全部页面。开发者常听到「避免 layout thrashing」的建议,例如不要在 JS 修改 DOM 后立刻读取布局信息,因为这可能强迫浏览器同步重新计算。浏览器会尝试优化:标记布局树中哪些部分是「脏的」,只重新计算那些部分。但在最坏情况下,DOM 高层的变化可能要求大型页面重新计算整棵布局树。这就是为什么要尽量减少昂贵的样式和布局操作。
**样式与布局小结**:总结一下,浏览器会从 HTML 和 CSS 构建出:
- DOM 树:结构和内容
- CSSOM:解析后的 CSS 规则
- 计算样式:CSS 规则匹配到每个 DOM 节点后的结果
- 布局树:从 DOM 树中过滤出可视元素,并为每个节点附上几何信息
每个阶段都建立在上一个阶段之上。如果其中任何阶段发生变化,比如脚本修改了 DOM 或改了某个 CSS 属性,后续阶段可能都需要更新。例如你修改一个元素的 CSS class,浏览器可能要为该元素重新计算样式;如果继承发生变化,子元素也可能要重算。若样式变化影响几何信息,例如 display 或 size 改了,浏览器可能还要重新布局,然后重新绘制。这条链意味着 layout 和 paint 都依赖最新的样式状态。我们会在 DevTools 部分讨论这些性能影响:浏览器提供了工具,可以看到这些步骤何时发生、耗时多久。
布局完成后,就进入下一个大阶段:绘制(painting)。
### **绘制、合成与 GPU 渲染**
绘制是把结构化的布局信息真正变成屏幕像素的过程。传统说法里,浏览器会遍历布局树,为每个节点发出绘制命令:画背景、画文本、在这些坐标画图片。现代浏览器在概念上仍然这么做,但通常会把工作拆成多个阶段,并利用 GPU 提升效率。

**绘制 / 光栅化**:在 renderer 的主线程上,完成布局后,Chrome 会遍历布局树生成 paint records(或 display list)。这基本是一组带坐标的绘制操作列表,就像画家先规划这幅画应该怎么画:比如「在 (x,y) 画一个宽 W、高 H、填充蓝色的矩形;然后在 (x2,y2) 用 XYZ 字体画文本 ‘Hello’;再在某处画一张图片」,诸如此类。这个列表会按照正确的 z-index 顺序排列,保证重叠元素能正确绘制。例如,一个元素的 z-index 更高,它的绘制命令就会晚于较低 z-index 的内容执行,显示在上层。浏览器必须考虑 stacking context、透明度等因素,才能得到正确的顺序。
过去,浏览器可能会按顺序直接把每个元素画到屏幕上。但如果页面局部变化,这种做法效率很低,因为你可能不得不重画所有东西。现代浏览器通常会记录这些绘制命令,然后通过合成步骤组装最终图像,尤其是在使用 GPU 加速时。
**分层与合成**:合成是一种优化:把页面拆成若干可以独立处理的 layer。例如,一个有 CSS transform 或动画的定位元素,可能会得到自己的 layer。Layer 就像独立的「草稿画布」:浏览器可以分别光栅化(绘制)每个 layer,然后 compositor 再把它们合成到屏幕上,通常会使用 GPU。
在 Chromium 的管线中,生成 paint records 之后,还会构建 layer tree,用来表示哪些元素在哪个 layer 上。有些 layer 会自动创建,例如 video 元素、canvas,或者带有某些 CSS 属性的元素会被提升为 layer。开发者也可以通过 will-change 或 transform 之类的 CSS 属性,提示浏览器为元素创建 layer。Layer 有用,是因为 layer 上的位置或透明度变化可以只走合成:浏览器只需要移动或重新合成这个 layer,不必重绘整个页面。不过 layer 太多会消耗内存、增加开销,所以浏览器会谨慎选择。
确定 layer 之后,Chrome 的主线程会把工作交给 compositor 线程。compositor 线程运行在 renderer process 中,但和主线程分离;所以即使主 JS 线程很忙,它也可以继续工作。这对流畅滚动和动画非常重要。compositor 线程的工作是拿到 layer,把它们光栅化(把绘制操作转成真实像素位图),再合成为 frame。
**GPU 辅助光栅化**:光栅化工作也可以分发。在 Chrome 中,compositor 线程会把 layer 拆成更小的 tile(图块),可以想成 256x256 或 512x512 像素的块;启用 GPU 光栅化时通常更大。然后它把这些 tile 分发给多个 raster worker 线程,这些线程甚至可以跨多个 CPU 核运行。每个 raster worker 拿到一个 tile,也就是某个 layer 区域的一组绘制命令,然后产出一个位图(像素数据)。重要的是,Skia(Chrome 的图形库)既可以用 CPU 也可以用 GPU 做光栅化;在 Chrome 中,这些 raster 线程通常用 CPU 渲染像素,再把结果上传到 GPU 内存。Firefox 较新的 WebRender 走的是另一条路线,后面会提到。光栅化后的 tile 会以纹理形式存入 GPU 内存。所有需要的 tile 绘制完成后,compositor 线程就拥有了一组就绪的纹理 layer。
接着,compositor 会组装一个 compositor frame。它本质上是一条发送给 browser process 的消息,包含组成屏幕的所有 quad(各个 layer 的 tile)、它们的位置等信息。这个 compositor frame 会通过 IPC 提交回 browser process。最终,浏览器的 GPU process(Chrome 中访问 GPU 的独立进程)会拿到这些内容并显示出来。browser process 自己的 UI,比如标签栏,也会通过 compositor frame 绘制,并在最后一步一起混合。GPU process 接收 frame 后,使用 GPU(通过 OpenGL、DirectX、Metal 等)执行合成:基本就是把每张纹理画到屏幕上的正确位置,应用 transform 等操作,而且速度非常快。你最终看到的图像就是这样产生的。
这条管线的优势在滚动和动画时特别明显。比如滚动一个页面,本质上大多只是改变一个更大页面纹理上的 viewport。compositor 可以只移动 layer 的位置,并让 GPU 重绘进入视野的新部分,不需要主线程重画所有东西。如果某个动画只是 transform,例如移动一个已经拥有自己 layer 的元素,compositor 线程可以每帧更新该元素的位置并产出新 frame,不需要牵涉主线程,也不需要重新跑样式和布局。这就是为什么推荐使用「只触发合成」的动画,也就是修改 transform 或 opacity、不会触发布局的动画:即使主线程很忙,它们也能以 60 FPS 平滑运行。相比之下,为 height 或 background-color 做动画,可能每一帧都强制重新布局或重新绘制;如果主线程跟不上,就会卡顿。
简要来说,Chrome 的渲染管线是:DOM → 样式 → 布局 → 绘制(记录 display item)→ 分 layer → 光栅化(tile)→ 合成(GPU)。Firefox 的管线在 display list 之前概念上类似,但启用 WebRender 后,它会跳过显式 layer 构建,改为把 display list 发送给 GPU process,由后者使用 GPU shader 处理几乎所有绘制(比较部分会展开)。WebKit(Safari)也使用多线程 compositor,并在 macOS 上通过「CALayers」做 GPU 渲染。因此所有现代引擎都会利用 GPU,尤其是在合成和光栅化图形密集内容时,用来获得高帧率,并把部分工作从 CPU 卸下来。
继续之前,我们再细看 GPU 的角色。在 Chromium 中,GPU process 是一个独立进程,负责和图形硬件对接。它会从所有 renderer 的 compositor 以及 browser UI 接收绘制命令(多数是较高层的命令,比如「在这些坐标画这些纹理」),再把它们翻译成实际的 GPU API 调用。把 GPU 访问隔离到一个独立进程里,有一个好处:如果 GPU 驱动有 bug 并导致崩溃,也不会拖垮整个浏览器,崩的只是 GPU process,浏览器可以重启它。此外,这也提供了一道沙箱边界。GPU 会处理 canvas 绘制、WebGL 等可能来自不可信内容的输入,驱动里也曾出现过安全漏洞;放到独立进程里运行可以降低风险。
合成结果最终会发送到显示设备,也就是浏览器运行所在的 OS 窗口或上下文。对每个动画帧来说(目标是 60fps,也就是每帧 16.7ms,以获得平滑效果),compositor 都会尽量产出一帧。如果主线程很忙,例如 JavaScript 执行了很久,compositor 可能会跳帧,或者无法及时更新,于是用户就会看到卡顿。开发者工具可以在性能时间线中显示掉帧。`requestAnimationFrame` 这类技术可以把 JS 更新对齐到帧边界,帮助渲染保持平滑。
总结一下,浏览器的渲染引擎会把页面内容和样式仔细拆解成几何信息(layout)和绘制指令,再通过 layer 和 GPU 合成,高效地变成你看到的像素。这条复杂管线让 Web 上丰富的图形和动画能以可交互帧率运行。接下来我们进入 JavaScript 引擎内部,理解浏览器是怎么执行脚本的;到目前为止,我们一直把这部分当成黑盒。
### **JavaScript 引擎内部(V8)**
JavaScript 驱动着网页的交互行为。在 Chromium 浏览器中,V8 引擎负责执行 JavaScript(以及 WebAssembly)。理解 V8 的工作方式,可以帮助开发者写出性能更好的 JS。完整深挖会写成一本书,所以这里只聚焦 JS 执行管线的关键阶段:解析/编译代码、执行代码,以及管理内存(垃圾回收)。我们还会说明 V8 如何处理即时编译(JIT)分层、ES modules 等现代特性。

**现代 V8 解析与编译管线**

**后台编译**:从 Chrome 66 开始,V8 会在后台线程编译 JavaScript 源码,使典型网站上主线程花在编译上的时间减少 5% 到 20%。自版本 41 起,Chrome 已经通过 V8 的 StreamedSource API 支持在后台线程解析 JavaScript 源文件。V8 可以在网络下载到第一个 chunk 后立即开始解析 JavaScript 源码,并在文件继续流式下载时并行解析。几乎所有脚本编译都会发生在后台线程;只有很短的 AST internalization 和 bytecode 最终化步骤,会在脚本执行前发生在主线程上。目前,顶层脚本代码和立即调用函数表达式会在后台线程编译,而内部函数仍会在首次执行时由主线程惰性编译。
**解析与 bytecode**:遇到 `<script>` 时(无论是在 HTML 解析期间遇到,还是后续加载),V8 首先解析 JavaScript 源码,生成代码的抽象语法树(AST)。preparser 是 parser 的一个副本,只做跳过函数所需的最少解析工作。它会验证函数在语法上合法,并产出让外层函数正确编译所需的信息。当一个预解析过的函数后来被调用时,它才会按需完整解析和编译。
V8 不会直接从 AST 解释执行,而是使用一个叫 Ignition 的 bytecode 解释器(2016 年引入)。Ignition 会把 JavaScript 编译成紧凑的 bytecode 格式,本质上是一组虚拟机指令。这个初始编译非常快,bytecode 也相当低层(Ignition 是基于寄存器的 VM)。目标是在前期开销尽量小的情况下尽快开始执行代码,这对页面加载时间很重要。
**AST internalization 过程**:AST internalization 涉及在 V8 heap 上分配字面量对象,比如字符串、数字、对象字面量样板,供生成 bytecode 时使用。为了支持后台编译,这个过程被移动到编译管线更晚的位置,也就是 bytecode 编译之后。这要求 V8 做一些修改,以便访问嵌在 AST 里的原始字面量值,而不是已经 internalized 到堆上的值。
**Explicit Compile Hints**:V8 引入了一个叫「[Explicit Compile Hints](https://v8.dev/blog/explicit-compile-hints)」的新特性,允许开发者指示 V8 在加载时立刻解析和编译代码,也就是进行 eager compilation(急切编译)。带有这个 hint 的文件会在后台线程编译,而延迟编译会发生在主线程上。针对热门网页的实验显示,20 个案例中有 17 个性能改善,前台解析和编译时间平均减少 630ms。开发者可以通过特殊注释给 JavaScript 文件添加 explicit compile hint,让关键代码路径在后台线程上急切编译。
**Scanner 与 parser 优化**:V8 的 scanner 做过大量优化,并带来全面改善:单 token 扫描大约提升 1.4 倍,字符串扫描提升 1.3 倍,多行注释扫描提升 2.1 倍,标识符扫描则根据标识符长度不同提升 1.2 到 1.5 倍。
脚本运行时,Ignition 会解释 bytecode 并执行程序。解释执行通常比优化后的机器码慢,但它让引擎可以先跑起来,同时收集代码行为的 profiling 信息。随着代码运行,V8 会收集变量类型、哪些函数被频繁调用等数据。这些信息会用于后续步骤,让代码跑得更快。
**JIT 编译分层**
V8 不止于解释执行。它使用多层即时编译器来加速热代码。思路是:对运行次数多的代码投入更多编译成本,让它变快;同时不把时间浪费在只运行一次的代码上。
1. **Ignition**:解释 bytecode。
2. **Sparkplug**:V8 的 baseline JIT(基线 JIT),大约在 2021 年推出。Sparkplug 会拿 bytecode 快速编译成机器码,不做重量级优化。它产出的原生代码比解释执行更快,但 Sparkplug 不做深度分析;它的目标是启动速度几乎和解释器一样快,同时让代码跑得稍快一些。
3. **Maglev**:2023 年,V8 引入 Maglev,这是一个中层优化编译器,现在已经在积极部署。Maglev 生成代码的速度比 Sparkplug 慢近 20 倍,但比 TurboFan 快 10 到 100 倍,有效填补那些中等热度、但还不值得交给 TurboFan 优化的函数之间的空档。Maglev 会在函数有些热、但还没有热到适合 TurboFan,或者 TurboFan 编译成本太高时介入。截至 Chrome M117,Maglev 已能处理许多场景,能让那些花时间在「温」代码(不冷也不超热)里的 Web 应用启动更快,弥合 baseline 与最高层级 JIT 之间的空档。
4. **TurboFan**:当函数或循环执行很多次后,V8 会启用最强大的优化编译器。TurboFan 会利用收集到的类型反馈生成高度优化的机器码,并应用高级优化,比如内联函数、消除边界检查等。注意:截至 2025 年,V8 正在逐步把 TurboFan 内部的「Sea of Nodes」中间表示替换为一个基于 CFG 的 IR,叫 Turboshaft。TurboFan 的整个 JavaScript 后端现在已经使用 Turboshaft;另一个后续项目 Turbolev 也在推进,它会把 Maglev 的 IR 用作前端,最终完全替换 TurboFan 的前端。如果这些假设成立,优化后的代码可以跑得快得多。
所以,V8 现在实际上有四个执行层级:Ignition 解释器、Sparkplug 基线 JIT、Maglev 优化 JIT、TurboFan 优化 JIT(后端正在逐步被 Turboshaft 替换)。这类似 Java 的 HotSpot VM 也有多个 JIT 层级(C1 和 C2)。引擎会根据执行画像动态决定优化哪些函数、什么时候优化。如果某个函数突然被调用一百万次,它很可能最终会被 TurboFan 优化,以获得最高速度。
Intel 也开发了 [Profile-Guided Tiering](https://community.intel.com/t5/Blogs/Tech-Innovation/Client/Profile-Guided-Tiering-in-the-V8-JavaScript-Engine/post/1679340),用于提升 V8 效率,在 Speedometer 3 基准上带来约 5% 改善。近期 V8 更新还包括 static roots 优化,它允许在编译时准确预测常用对象的内存地址,从而显著提升访问速度。
JIT 优化的一个挑战是 JavaScript 是动态类型语言。V8 可能会在某些假设下优化代码,例如某个变量总是整数。如果后续调用打破了这个假设,比如变量变成字符串,优化后的代码就失效了。V8 随后会执行 deoptimization:回退到较低优化级别的版本,或者基于新假设重新生成代码。这个机制依赖 inline caches 和类型反馈来快速适应。deopt 的存在意味着,如果代码里的类型不可预测,峰值性能有时无法持续;但总体上,V8 会尽力处理典型模式,比如某个函数总是传入同一类对象。
**Bytecode 回收与内存管理**
V8 实现了 bytecode flushing:如果一个函数在多次垃圾回收后仍没有被使用,它的 bytecode 就会被回收。下次再次执行这个函数时,parser 会使用之前存储的结果,更快地重新生成 bytecode。这个机制对内存管理很关键,但在某些边界情况下也可能带来解析不一致。
**内存管理(垃圾回收)**:V8 使用垃圾回收器自动管理 JS 对象的内存。多年来,V8 的 GC 演进成所谓的 Orinoco GC:一个分代、增量、并发的垃圾回收器。要点包括:
- **分代**:V8 按对象年龄分区。新对象会分配在 young generation(也叫 nursery)里。这些对象会用很快的 scavenging 算法频繁回收:把存活对象复制到新空间,回收其余对象。经过足够多轮仍然存活的对象,会晋升到 old generation。
- **Mark-and-sweep/compact**:对 old generation,V8 使用带压缩的 mark-and-sweep 回收器。这意味着它偶尔会 stop the world,也就是短暂停止 JS 执行,标记所有可达对象(从 global object 等根开始追踪),然后清扫不可达对象并回收内存。它还可能压缩内存,移动对象以减少碎片。不过 Orinoco 已经把大量标记工作改成并发执行,可以在后台线程中标记,同时 JS 仍在运行,从而尽量减少暂停时间。
- **增量 GC**:V8 会尽可能把垃圾回收拆成小片执行,而不是一次大暂停。这种增量方式把工作摊开,避免卡顿。例如,它可以在脚本执行之间穿插一点标记工作,利用空闲时间。
- **并行 GC**:在多核机器上,V8 也可以用并行线程执行 GC 的部分工作,比如标记或清扫。
最终效果是,V8 团队多年来大幅减少了 GC 暂停时间,让垃圾回收即使在大型应用中也大多不易察觉。Minor GC(新对象清扫)通常非常快。Major GC(老生代)更少发生,并且现在大多是并发的。如果你打开 Chrome 的任务管理器或 DevTools Memory 面板,可能会看到 V8 的 heap 被分成「Young space」和「Old space」,这正反映了分代设计。
对开发者来说,这意味着你不需要手动管理内存,但仍要留意一些问题:例如避免在紧密循环里创建大量短命对象(虽然 V8 很擅长处理短命对象),也要意识到持有大型数据结构会让它们一直留在内存里。DevTools 可以强制触发一次垃圾回收,也可以记录内存 profile,帮助你看清哪些东西在占用内存。
**V8 与 Web API**:还值得一提的是,V8 覆盖的是核心 JavaScript 语言和运行时,也就是执行、标准 JS 对象等。但许多「浏览器 API」,比如 DOM 方法、`alert()`、网络 XHR/fetch 等,并不是 V8 本身的一部分。它们由浏览器提供,并通过 bindings 暴露给 JS。例如你调用 `document.querySelector` 时,底层会进入引擎和 C++ DOM 实现之间的 binding。V8 负责调用 C++ 并取回结果,Chrome 也有大量机制让这个边界足够快,例如用 IDL 生成高效 bindings。
讲完浏览器如何获取资源、解析 HTML/CSS、计算 layout、用 GPU 绘制、运行 JS,我们已经有了页面加载和渲染全过程的图景。但还有更多东西值得看:ES modules 是怎么处理的(模块有自己的一套加载机制),浏览器的多进程架构是怎么组织的,以及 sandboxing 和 site isolation 这类安全特性是怎么工作的。
### **模块加载与 Import Maps**
[JavaScript modules](https://v8.dev/features/modules)(ES6 modules)相比经典 `<script>` 标签,引入了不同的加载和执行模型。它不再是一个可能创建全局变量的大脚本文件;modules 是明确 import/export 值的文件。下面看浏览器(具体到 Chrome 里的 V8)怎么加载 modules,以及 dynamic import() 和 import maps 这些特性如何参与其中。
**静态模块 import**:当浏览器遇到 `<script type="module" src="main.js">` 时,会把 main.js 当成一个模块入口。加载过程如下:浏览器先获取 main.js,然后按 ES module 解析。解析时,它会找到所有 import 语句,例如 `import { foo } from './utils.js';`。浏览器不会立刻执行代码,而是先构建 module 依赖图。它会开始获取所有被导入的模块(这里是 utils.js),并递归地解析每个模块的 import、继续获取依赖。这个过程是异步发生的。只有整个模块图都获取并解析完毕,浏览器才会执行这些模块。Module scripts 天然就是 defer 的:在所有依赖就绪之前,浏览器不会执行模块代码。随后它会按依赖顺序执行,确保如果 module A import 了 B,那么 B 会先运行。
这套静态 import 过程解释了为什么 ES modules 在某些情况下不能从 file:// 加载,除非明确允许;也解释了为什么它们默认要求跨域脚本满足 CORS。浏览器正在主动链接并加载多个文件,而不是简单地往页面里塞一个 `<script>`。
**Dynamic import()**:除了静态 import 语句,ES2020 还引入了 `import(moduleSpecifier)` 这个表达式。它允许代码即时加载一个 module,并返回一个 promise,最终 resolve 为该 module 的 exports。例如,用户执行某个操作时,你可以写 `const module = await import('./analytics.js')`,从而对应用做 code-splitting。底层,import() 会触发浏览器获取指定模块(以及它尚未加载的依赖),然后实例化并执行它,最后用 module namespace object resolve promise。V8 和浏览器会在这里协作:浏览器的 module loader 负责获取和解析,V8 在一切就绪后负责编译和执行。Dynamic import 很强大,因为它也可以在非 module 脚本里使用,例如 inline script 可以动态 import 一个 module。本质上,它让开发者可以按需加载 JS。它和静态 import 的区别在于:静态 import 会提前解析,也就是在任何模块代码运行之前加载完整依赖图;dynamic import 更像在运行时加载一个新脚本,只是带有 module 语义和 promise。
**Import maps**:浏览器里的 ES modules 曾面临一个挑战:module specifier。在 Node 或 bundler 中,你常常按包名 import,例如 `import { compile } from 'react'`。但在 Web 上,如果不用 bundler,`react` 不是合法 URL;浏览器会把它当成相对路径,结果失败。Import maps 就是为了解决这个问题。Import map 是一份 JSON 配置,告诉浏览器如何把 module specifier 解析成真实 URL。它通过 HTML 中的 `<script type="importmap">` 标签提供。比如一个 import map 可以把 specifier 「react」映射到 `https://cdn.example.com/react@19.0.0/index.js`,也就是指向实际脚本的完整 URL。随后任何 module 执行 `import 'react'` 时,浏览器都会用这张 map 找到 URL 并加载。简而言之,import maps 让「裸」specifier(如包名)可以在 Web 上工作,方式是把它们映射到 CDN URL 或本地路径。
Import maps 对无 bundler 开发来说是一次重要改变。自 2023 年起,import maps 已被所有主要浏览器支持(Chrome 89+、Firefox 108+、Safari 16.4+,三大引擎都支持)。它们尤其适合本地开发或简单应用:你想使用 modules,但不想引入构建步骤。生产环境中的大型应用通常仍会 bundle,以减少请求数量、改善性能;但随着浏览器和 HTTP/2/3 改进,直接服务许多小 modules 也变得越来越可行。
因此,浏览器中的 module loader 由几个部分组成:module map(追踪哪些模块已经加载)、可能存在的 import map(用于自定义解析),以及获取/解析逻辑。模块获取并编译后,module 代码会在 strict mode 和自己的顶层 scope 中执行,不会泄漏到 window,除非显式挂上去。exports 会被缓存,所以另一个 module 后续再 import 同一个 module 时,不会重新运行它,而是复用已执行过的 module record。
还要提一点:ES modules 不同于普通脚本,它们会延迟执行,并且在给定依赖图内按顺序执行。如果 main.js import util.js,而 util.js import dep.js,那么执行顺序会是:dep.js 先运行,然后 util.js,最后 main.js;这是深度优先、后序的顺序。这种确定性有时可以让你不再需要 DOMContentLoaded 之类的东西,因为主 module 运行时,它所有 import 都已经加载并执行过。
从 V8 的角度看,modules 仍由同一套编译管线处理,但它们会创建独立的 ModuleRecords。引擎会确保一个 module 的顶层代码只在所有依赖就绪后运行一次。V8 还要处理循环 module import;这是规范允许的,可能导致某些 exports 处于部分初始化状态。细节由规范定义,但本质上,引擎会先创建所有 module 实例,再用占位符解决循环,然后按照尊重依赖关系的顺序执行。规范算法可以看作对 module 图做「DAG」拓扑排序。
总结一下,浏览器里的模块加载,是网络(获取 module 文件)、module resolver(使用 import maps 或标准 URL 解析)和 JS 引擎(按正确顺序编译并执行 modules)之间的一场协调。它比老式 `<script>` 加载更复杂,但带来了更模块化、更可维护的代码结构。对开发者来说,要点是:用 modules 组织代码;如果需要裸 import,就使用 import maps;需要按需加载时,用 import() 动态加载模块。浏览器会负责处理重活,确保一切按正确顺序执行。
讲完单个页面内部怎么工作后,让我们把视角拉大,看看浏览器架构:它如何让多个页面、标签页和 Web 应用同时运行,而且互不干扰。这就进入了多进程模型。
### **浏览器多进程架构**
现代浏览器(Chrome、Firefox、Safari、Edge 等)都使用多进程架构,以获得稳定性、安全性和性能隔离。早期浏览器会把整个浏览器跑在一个巨大进程里;现代浏览器则把不同部分放在不同进程中运行。Chrome 在 2008 年率先采用这种做法,其他浏览器随后以不同形式跟进。下面主要看 Chromium 的架构,同时说明 Firefox 和 Safari 的差异。
在 Chromium(Chrome、Edge、Brave 等)中,有一个核心的 **Browser Process**。这个 browser process 负责 UI(地址栏、书签、菜单,也就是所有浏览器 chrome),并协调资源加载、导航等高层任务。当你打开 Chrome,并在操作系统任务管理器里看到一个条目时,那通常就是 browser process。它也是派生其他进程的父进程。
然后,Chrome 会为每个标签页(有时是标签页里的每个站点)创建一个 **Renderer Process**。Renderer process 会为这个标签页的内容运行 Blink 渲染引擎和 V8 JS 引擎。一般来说,每个标签页至少会有一个 renderer process。

如果你打开了多个互不相关的网站,它们会位于不同进程中:站点 A 一个进程,站点 B 另一个进程,等等。Chrome 甚至会把跨域 iframe 隔离到独立进程中,后面讲 site isolation 时会展开。Renderer process 被沙箱化,不能随意访问你的文件系统或网络;它必须通过 browser process 才能执行这些特权操作。
Chrome 中还有其他关键进程:
- **GPU Process**:专门负责与 GPU 通信的进程,如前所述。来自 renderers 的所有渲染和合成请求都会发往 GPU process,由它真正发出图形 API 调用。这个进程被沙箱化并与其他进程分离,这样 GPU 崩溃不会拖垮 renderers。
- **Network Process**:旧版 Chrome 中网络功能是 browser process 里的一个线程,但现在通过 servicification,网络通常是独立进程。这个进程负责网络请求、DNS 等,也可以独立沙箱化。
- **Utility Processes**:用于各种服务,比如音频播放、图片解码等,Chrome 可能会把这些工作卸载给 utility processes。
- **Plugin Process**:在 Flash 和 NPAPI 插件时代,插件会运行在自己的进程里。Flash 现在已经废弃,所以这不再那么重要,但架构仍保留了让插件不运行在主 browser process 中的能力。
- **Extension Processes**:Chrome 扩展本质上是可以作用于网页或浏览器的脚本,它们也运行在独立进程中,与网站隔离以保证安全。
简化来看,就是一个 Browser process 协调多个 Renderer process(每个标签页或每个站点实例一个),再加上一个 GPU process,以及若干用于服务的其他进程。Chrome 自带的任务管理器(Windows 上 Shift+Esc,或通过 More Tools > Task Manager 打开)会列出每种进程类型及其内存使用量。
**多进程的好处**主要有:
- **稳定性**:如果某个网页(renderer process)崩溃或泄漏内存,它不会拖垮整个浏览器。你可以关闭那个标签页,其他标签页仍然活着。在单进程浏览器中,一个坏脚本就可能把一切都带崩。Chrome 会在某个标签进程死亡时只在该标签里显示「Aw, Snap」错误,你可以单独重新加载它。
- **安全性(沙箱化)**:把 Web 内容放在受限进程里运行,浏览器就能限制这些代码能对系统做什么。即使攻击者在渲染引擎里找到漏洞,也会被困在沙箱中。Renderer process 通常不能读取你的文件、随意打开网络连接或启动程序;它必须向 browser process 请求文件访问等操作,而 browser process 可以验证或拒绝。这个沙箱由操作系统层面强制执行,不同平台会使用 job objects、seccomp filters 等机制。
- **性能隔离**:某个标签页里的密集工作,比如大型 Web 应用或无限循环,大多被限制在那个标签页的 renderer process 里。其他标签页如果在不同进程中,就仍能保持响应,因为它们的进程没有被阻塞。此外,操作系统可以把不同进程调度到不同 CPU 核上;所以两个重型页面在多核系统上可以比它们只是同一进程的两个线程时更好地并行运行。
- **内存分隔**:每个进程都有自己的地址空间,内存不共享。这能防止一个站点窥探另一个站点的数据,也意味着关闭某个标签页时,操作系统可以高效回收该进程的全部内存。代价是会有一些重复资源和额外进程开销,例如每个 renderer 都要加载自己的 JS 引擎副本。
**Site Isolation**:最初,Chrome 的模型是一个标签页一个进程。后来它逐渐演进成一个站点一个进程,尤其是在 Spectre 之后;安全部分会展开。截至 2024 年,site isolation 已经默认启用于 99% 的 Chrome 桌面用户,Android 支持也在持续完善。这意味着如果你有两个标签页都打开 example.com,Chrome 可能决定让它们共用一个进程,以节省内存,因为它们属于同一站点,放在一起风险较低。但如果一个 example.com 标签页里嵌入了 evil.com iframe,Chrome 默认会把 evil.com 的 iframe 放到与父页面不同的进程里,以保护 example.com 的数据。这种强制策略叫「Strict Site Isolation」,大约从 Chrome 67 开始默认启用。Site isolation 会因为创建更多进程而让 Chrome 多使用 10–13% 系统资源,但换来关键的安全收益。
Firefox 的架构叫 [Electrolysis](https://blog.mozilla.org/addons/2016/04/11/the-why-of-electrolysis/)(e10s)。历史上,Firefox 曾长期用一个内容进程处理所有标签页;多年里它都是单进程,直到 2017 年左右才启用少量内容进程。截至 2021 年,Firefox 使用多个内容进程,默认 8 个用于 Web 内容。随着 [Project Fission](https://blog.mozilla.org/security/2021/05/18/introducing-site-isolation-in-firefox/)(site isolation)推进,Firefox 也在走向类似隔离:它可以为跨站 iframe 启动新进程,并且在 Firefox 108+ 中默认启用 site isolation,使进程数量有可能接近 Chrome 的每站点一个。Firefox 也有 GPU process(用于 WebRender 和合成)以及独立网络进程,类似 Chrome 的拆分。因此在实践中,Firefox 现在也很像 Chrome:一个父进程、一个 GPU 进程、一个网络进程、若干内容(renderer)进程,以及一些 utility 进程,用于扩展、媒体解码等,例如媒体插件可以隔离运行。
Safari(WebKit)同样迁移到了多进程模型(WebKit2):每个标签页内容运行在独立的 WebContent process 中,由一个中心 UI process 控制。Safari 的 WebContent process 同样被沙箱化,不能直接访问设备或文件,必须通过 UI process。Safari 也有一个共享的网络进程,可能还有其他 helper。因此虽然实现不同,概念是一致的:把每个网页的代码隔离在自己的沙箱环境里。
还有一个重要点是进程间通信(IPC):这些进程如何彼此对话?浏览器使用 IPC 机制。Windows 上常见的是 named pipes 或其他 OS IPC;Linux 上可能是 Unix domain socket 或共享内存;Chrome 有自己的 IPC 库 Mojo。例如,当 Network process 收到网络响应后,需要交付给正确的 Renderer process,通常由 Browser process 协调。类似地,当你调用 DOM `fetch()` 时,JS 引擎会进入网络 API,向 Network process 发送请求。IPC 增加了复杂度,但浏览器会做大量优化,例如用共享内存高效传输图片等大数据,用异步消息避免阻塞。
**进程分配策略**:Chrome 并不总是为每个标签页创建全新进程。它有一些限制,尤其是在低内存设备上,可能会为同站点标签页复用进程。如果你又打开一个同站点标签页,Chrome 会复用已有 renderer 以节省内存;这就是为什么有时两个同站点标签页会共享进程。Chrome 还有总进程数限制,这个限制会随 RAM 情况扩缩。达到上限时,它可能开始把多个不相关站点放进一个进程,不过如果 site isolation 已启用,它会尽量避免混合站点。在 Android 上,因为内存约束,Chrome 使用的进程更少,内容进程通常最多 5 到 6 个。
Chromium 中还有一个概念叫 **servicification**:把浏览器组件拆成可以独立运行的服务。例如 Network Service 被做成一个独立模块,可以跑在进程外。这样做的思路是模块化:性能强的系统可以让每个服务都跑在自己的进程里,而资源受限的设备则可以把某些服务合并回一个进程,以节省开销。Chrome 可以在运行时或构建时决定如何部署这些服务。就像前面提到的,高端设备上可能把 UI、net、GPU 等全部拆开;低端设备(如 Android)上可能把 browser 和 network 合在一个进程里以减少开销。
要点是:Chromium 的架构设计,是让浏览器 UI 和每个站点运行在不同沙箱中,并把进程作为隔离边界。Firefox 和 Safari 也已经汇聚到类似设计。这种架构用更多内存换来了更好的安全性和可靠性。Web 内容进程被视为不可信,而 site isolation(下一节)会进一步把不同 origin 隔离到不同进程中。
### **站点隔离与沙箱化**
Site isolation 和 sandboxing 都是建立在多进程基础上的安全特性。它们的目标是:即使恶意代码在浏览器中运行,也不能轻易偷走其他站点的数据,或访问你的系统。
**Site Isolation**:前面已经提过,它意味着不同网站(更严格地说,不同 site)会运行在不同 renderer process 中。Chrome 的 site isolation 在 2018 年 [Spectre 漏洞](https://developer.chrome.com/blog/meltdown-spectre) 曝光后得到加强。Spectre 表明,恶意 JavaScript 可能利用 CPU speculative execution 读取不该读取的内存。如果两个站点共享同一个进程,恶意站点就可能用 Spectre 偷看敏感站点的内存,比如你的银行网站。唯一可靠的解决方案,就是根本不要让它们共享进程。因此 Chrome 把 site isolation 设为默认:每个站点都有自己的进程,包括跨域 iframe。Firefox 也通过 Project Fission 跟进(近期版本默认启用),目标同样是为了安全而把每个站点隔离在自己的进程里。这和过去有很大不同。过去,如果一个父页面嵌入多个来自不同域名的 iframe,它们可能全都生活在同一个进程中,尤其是在同一个标签页里。现在,这些 iframe 会被拆到不同进程中;例如一个好站点页面里的跨站 iframe 会被强制放到不同进程,从而防止即使是低层攻击也在它们之间泄漏信息。
从开发者角度看,site isolation 大多是透明的。一个影响是,嵌入的 iframe 和父页面之间的通信现在可能跨越进程边界,所以 postMessage 之类的东西底层会通过 IPC 实现。但浏览器会让这一切保持无缝;你作为开发者照常使用 API 即可。
**Sandboxing**:每个 renderer process(以及其他辅助进程)都会在权限受限的沙箱中运行。例如在 Windows 上,Chrome 使用 job object 并降低权限,使 renderer 不能调用大多数访问系统的 Win32 API。在 Linux 上,它使用 namespaces 和 seccomp filters 限制 syscalls。Renderer 基本只能计算和渲染内容;如果它试图打开文件、摄像头或麦克风,就会被阻止,除非通过正确通道向 browser process 请求,并由浏览器提示用户授权。WebKit 文档也明确指出,WebContent processes 不能直接访问文件系统、剪贴板、设备等;它们必须通过 UI process 请求,由 UI process 进行中介。这也是为什么网站想使用麦克风时,权限提示由浏览器 UI(browser process)显示;如果允许,实际录制也会在受控进程中完成。沙箱是关键防线。即使攻击者找到 renderer 漏洞,可以在其中运行原生代码,他接下来还要面对沙箱屏障:必须再有一个单独的漏洞利用(escape)才能逃到系统层面。这种分层方式,也就是 site isolation + sandbox,是当前主流浏览器安全的先进做法。
Firefox 的沙箱现在也很严格。早期 e10s 时代它较弱,但后来加强了很多。Firefox 的内容进程同样不能直接访问很多东西;Firefox 也会沙箱化 GPU process,以处理图形驱动问题。
**进程外 iframe(OOPIF)**:在 Chrome 的 site isolation 实现中,他们发明了 [OOPIF](https://www.chromium.org/developers/design-documents/oop-iframes/) 这个术语,意思是 out-of-process iframe。用户视角下什么都没变;但在 Chrome 内部架构中,一个页面的每个 frame 都可能由不同 renderer process 支撑。顶层 frame 和同站点 frame 共享一个进程;跨站 frame 使用不同进程。所有这些进程会在 browser process 协调下「协作」渲染同一个标签页内容。这非常复杂,但 Chrome 拥有一棵可以跨进程的 frame tree。这意味着一个标签页可能跑着 N 个进程:一个用于主文档,其他用于各个跨站子文档。它们通过 IPC 处理跨边界的 DOM 事件,或某些涉及跨 context 的 JavaScript 调用。Spectre 之后,Web 平台也在围绕这些约束演进,例如 [COOP/COEP](https://web.dev/articles/coop-coep)、[SharedArrayBuffer](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer) 等规范。
**内存与性能成本**:Site isolation 确实会增加内存使用,因为需要更多进程。Chrome 开发者曾指出,在某些情况下它可能带来 10–20% 的内存[开销](https://www.thurrott.com/mobile/chrome-os/162980/spectre-mitigation-increases-chrome-memory-usage-google-says)。他们用一些方式缓解,例如对同站点做「best-effort process consolidation」,并限制可创建的进程数。Firefox 起初因为内存顾虑没有隔离每个站点,但 Spectre 之后,他们也找到了更高效的做法,例如 8 个 privileged process 限制和按需创建进程。Safari 历史上有很强的进程模型,但我不确定它是否隔离跨站 iframe;WebKit2 至少会隔离顶层页面。Apple 的关注点通常还包括隐私,例如 Intelligent Tracking Prevention 会对 cookies 分区等,不过那是另一层。
出于隐私原因,跨站 prefetch 也会受限。目前,只有在用户对目标站点没有设置 cookies 时,跨站 prefetch 才会工作,以防站点通过那些用户可能永远不会访问的预取页面追踪用户活动。
总的来说,site isolation 保证了最小权限原则:来自 origin A 的代码不能访问 origin B 的数据,除非通过明确同意的 Web API,例如 postMessage,或经过分区的 storage。而 sandbox 则保证即使代码本身是恶意的,也不能直接碰你的系统。这些措施让浏览器漏洞利用变得难得多。攻击者现在通常需要一条多漏洞利用链:先攻破 renderer,再逃出 sandbox,才能造成严重破坏。这大大提高了攻击门槛。
作为 Web 开发者,你可能不会直接感受到 site isolation,但你会从更安全的 Web 中受益。需要注意的一点是,跨域交互可能会有一点额外开销,因为底层经过 IPC;某些优化,例如进程内脚本共享,也无法跨 origin 使用。但浏览器一直在优化进程间消息传递,尽量把性能影响降到最低。
讲完安全之后,我们转向工具和性能检测:也就是开发者如何窥探这条管线,并测量或调试它。
### **Chromium、Gecko 与 WebKit 比较**
前面主要讲的是 Chrome/Chromium 的行为:HTML/CSS 由 Blink 引擎处理,JavaScript 由 V8 执行,多进程架构基于 Aura/Chromium 基础设施。其他主要引擎——Mozilla 的 Gecko(Firefox 使用)和 Apple 的 WebKit(Safari 使用)——有相同的基本目标和大体相似的管线,但也有值得注意的差异和历史分歧。
**共享概念**:所有引擎都会把 HTML 解析成 DOM,把 CSS 解析成样式数据,计算 layout,然后 paint/composite。它们都有带 JIT 和垃圾回收的 JS 引擎。所有现代引擎也都使用多进程(或至少多线程)来获得并行性和安全性。
**CSS/样式系统的差异**
一个有趣差异是:渲染引擎如何实现 CSS 样式计算。
- **Blink(Chromium)**:使用 C++ 写的单线程样式引擎,历史上基于 WebKit。它会按顺序为 DOM 树计算样式。它有增量 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 selector matching 做 bytecode JIT。它可能把 CSS selector 转成 bytecode,并 JIT 编译一个 matcher 来提速。Blink 没有采用这个做法,而是使用迭代匹配。
所以在 CSS 方面,Gecko 因 Rust 实现的并行样式计算而突出。Blink 和 WebKit 则主要依赖优化过的 C++,以及 WebKit 的一些 JIT 技巧。
**Layout 与图形**
三个引擎都实现 CSS box model 和 layout 算法。某些具体特性可能某个引擎先落地,例如一段时间里 WebKit 在 CSS Grid 支持上领先,后来 Blink 追上;它们也经常通过标准组织共享思路和测试。
Firefox(Gecko)引入 **WebRender** 作为 compositor/rasterizer,是一次巨大变化。WebRender 现在是 Firefox 的默认渲染引擎,尤其改善了图形密集型 Web 内容的性能。WebRender 同样用 Rust 写,基本思路是拿 display list 直接在 GPU 上渲染,让 GPU 处理形状 tessellation、文本等工作。这相当于把更多绘制工作移到 GPU 上。在 Chrome 的管线中,对大多数内容来说,光栅化仍然主要发生在 CPU 上,然后以位图形式发送到 GPU。WebRender 则尽量避免为整个 layer 生成位图,而是在 GPU 上直接绘制矢量;文本字形除外,它会把字形缓存成 atlas textures。这意味着 Firefox 在某些情况下可以高性能地动画更多内容,因为如果只有小部分改变,它不必把所有东西重新光栅化,而是可以通过 GPU 快速重画。这很像游戏引擎每帧用 GPU call 重画场景。缺点是实现和调优都很复杂,也可能给 GPU 更大压力。但随着 GPU 越来越强,这是一条面向未来的路线。Chrome 团队也考虑过类似方向(「SKIA GPU」路径),但没有做一次完整的 WebRender 式改造。
Safari(WebKit)使用的方法更接近早期 Chrome:它有一个基于 layer 的 compositor,在 Mac 和 iOS 上这些 layer 叫 CALayer,因为使用 Core Animation layer。Safari 很早就转向 GPU 合成,2009 年 iPhone OS 和 Safari 4 就已经对 transform 等特定 CSS 做硬件加速合成。Safari 和 Chrome 后来分道扬镳,但概念上都做 tiling 和 compositing。Safari 也会把很多工作卸载给 GPU,并使用 tiling;尤其在 iOS 上,tile 绘制是流畅滚动的基础。
**移动端优化**:每个引擎在移动端都有特殊处理。例如 WebKit 有 tile coverage 概念,用于滚动;历史上 iOS 的 UIWebView 就用过。Chrome Android 使用 tiling,并尽量减少 raster task 以达到帧率目标。Firefox 的 WebRender 则来自移动优先的 Servo 项目。
**JavaScript 引擎**
- **V8(Chromium)**:前面已经讲过,截至 2023 年,它包括 Ignition、Sparkplug、TurboFan、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 是分代 mark-sweep,历史上有 Butterfly 或 Boehm 等变体,现在还有 bmalloc 等。JSC 的 FTL 使用 LLVM 做优化,这一点很独特:V8 和 SpiderMonkey 都有自己的编译器,而 JSC 在其中一层借助 LLVM。这能生成非常快的代码,但编译成本较重。JSC 往往在某些 benchmark 上追求峰值性能,并且常常在一些测试里表现突出;V8 也会追上,它们会轮流领先。
ES 特性方面,三个引擎基本都跟上最新标准,这要归功于 test262 和彼此之间的竞争。
**多进程模型差异**
- **Chrome**:每个标签页通常独立,origin 级别 site isolation,进程很多,可能达到几十个。
- **Firefox**:默认进程更少,通常 8 个内容进程处理所有标签页;如果跨站 iframe 需要,Fission 会再增加进程。因此它不一定是一标签一进程,标签页会共享一个内容进程池。这意味着 Firefox 在大量标签页场景下内存使用可能更低,但也意味着一个内容进程崩溃可能带走多个标签页,尽管它会尽量按站点分组,比如所有 Facebook 标签页可能在同一个进程里。
- **Safari**:可能是一标签一进程,或几个标签页共用一个进程。在 iOS 上,WKWebView 确实会隔离每个 webview。Safari 桌面版历史上也倾向于每个标签页独立。不清楚它是否已经隔离跨域 iframe;Apple 没有太多谈 Spectre 缓解,但 Safari 至少对顶层页面有按域进程模型。
**进程间协调**:所有引擎都必须解决类似问题,例如在多进程环境中如何实现 `alert()`(它会阻塞 JS)。通常是 browser process 显示 alert UI,并暂停那个脚本 context。prompt/confirm、modal dialog 等也类似。不同引擎有一些细微差异;比如 Chrome 并不真正阻塞线程来处理 alert,而是在 renderer 中跑一个嵌套 runloop;Firefox 可能仍会冻结那个标签页的进程。
**崩溃处理**:Chrome 和 Firefox 都有 crash reporter,可以重启崩溃的内容进程,并在标签页里显示错误。Safari 的 Web Content process 崩溃时,通常会在内容区域显示更简单的错误消息。
**特性实现差异**
某些 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 highlighter 和 shape editor。Safari 的 Web Inspector 也可用,但在某些方面不如前两者完整。对需要在不同浏览器里调试的开发者来说,这些差异会产生影响。
**性能权衡**
历史上,Chrome 因多进程和 V8 被认为 JS 更快、整体性能更好。Firefox 通过 Quantum 缩小了很多差距,有时在图形方面超过 Chrome;WebRender 对复杂页面可以非常快。Safari 则常在 Apple 硬件上的图形性能和低功耗方面表现出色,因为它们非常重视功耗优化。
**内存**:Chrome 有高内存占用的名声,原因之一就是那些进程。Firefox 会更保守一些。Safari 在 iOS 上由于 RAM 有限,必须非常节省内存,因此 WebKit 做了大量内存优化。
**外部贡献者**:有趣的是,这些引擎中的很多改进来自外部团队,例如 Igalia;它们曾在 WebKit 和 Blink 中都实现过 CSS Grid。因此某些特性有时会大致同时落地。
从 Web 开发者视角看,这些差异常表现为:
- 需要在所有引擎上测试,因为某个 CSS 特性或 API 的实现可能存在细微差异或 bug。
- 性能可能不同,例如某个特定 JS workload 可能因为 JIT 启发式不同,在一个引擎中比另一个更快。
- 某些 API 可能在某个引擎中不可用;Safari 常常最后实现某些新 API,比如 WebRTC 或 IndexedDB 的某些版本,不过它们最终通常也会实现。
但我们讨论的核心概念——网络 → 解析 → layout → paint → 合成 → JS 执行——适用于所有引擎,只是内部方法或名称不同:
- 在 Gecko 中:parse → frame tree → display list → WebRender scene,或在禁用 WebRender 时进入 layer tree → composite。
- 在 WebKit 中:parse → render tree → graphics layers → composite(通过 CoreAnimation)。
它们也都有类似的子系统:DOM、styling、layout、graphics、JS engine、networking、processes/threads。
知道这些有助于调试。比如,一个页面如果在 Safari 里 jank、但在 Chrome 里不卡,可能是 WebKit 的绘制路径不同。或者如果 CSS 在 Firefox 里慢,可能是撞到了 Stylo 没有并行化的路径,虽然这很少见。
总结一下,Chromium、Gecko 和 WebKit 虽然有不同实现,甚至有各自的创新点,例如 Gecko 的并行 CSS、WebRender 的 GPU 路线等,但它们越来越多地实现相同 Web 标准,也在许多方面合作。引擎选择对平台厂商和开放 Web 多样性更重要;作为开发者,你主要关心的是自己的站点能在各处正常运行。在底层,每个引擎独特的架构可能带来不同的性能 profile 或 bug,这也是为什么要在各个引擎中测试,并使用各自的性能诊断工具,例如 Firefox performance tool 和 Chrome performance tool。完整列出所有差异超出了本文范围,但希望这里能让你对这片技术版图有个大致认识:它们在高层设计上越来越趋同(多进程、类似管线),但在具体技术方案上仍各有不同。
### **结语与延伸阅读**
我们一路走过了一个 Web 页面在现代浏览器内部的一生:从输入 URL 的那一刻开始,经过网络和导航、HTML 解析、样式、布局、绘制、JavaScript 执行,直到 GPU 把像素放到屏幕上。我们看到,浏览器本质上像一个小型操作系统:它管理进程、线程、内存,以及一整套复杂子系统,确保 Web 内容加载得快、运行得安全。对 Web 开发者来说,理解这些内部机制,可以解释为什么某些最佳实践(比如减少 reflow、使用 async scripts)对性能重要,也可以解释为什么某些安全策略(比如不要在 iframe 中混用 origins)存在。
给开发者的几个关键 takeaway:
**优化网络使用**:更少的往返、更小的文件,意味着更快的首次渲染。浏览器能做很多事,比如 HTTP/2、缓存、推测性加载,但你仍应善用 resource hints 和高效缓存等技术。网络栈已经很高性能,但延迟始终是杀手。
**高效组织 HTML/CSS**:结构良好的 DOM 和精简 CSS(避免过深的树或过于复杂的选择器)可以帮助解析和样式系统。要理解 CSS 和 DOM 会共同构建计算样式,然后 layout 计算几何信息;大量 DOM 操作或样式变化会触发这些重新计算。
**批量 DOM 更新**:避免反复触发 style/layout thrash。使用 DevTools Performance 面板,找出脚本什么时候造成了大量 layout 或 paint。
**用适合合成的 CSS 做动画**:transform 或 opacity 动画可以留在主线程之外,由 compositor 处理,因此更容易得到平滑动画。尽量避免给会触发布局的属性做动画。
**留意 JS 执行**:JS 引擎虽然非常快,但长任务仍会阻塞主线程。把长操作拆开,让页面保持响应;在某些情况下,可以考虑使用 Web Workers 做后台任务。也要记住,重 JS 可能导致 GC 暂停;现在很少出现很长暂停,但如果内存膨胀,仍可能发生。
**安全特性**:拥抱它们。例如在合适时使用 iframe sandbox 或 `rel=noopener`,因为你现在知道浏览器本来就会隔离这些东西;顺着浏览器的安全模型做事是有益的。
**DevTools 是你的朋友**:性能面板和网络面板尤其有价值,它们能让你看到浏览器到底在做什么。如果某个东西很慢或很卡,工具通常能指向原因,例如一次很长的 layout、一次很慢的 paint 等。
**如果你想继续深入,Pavel Panchekha 和 Chris Harrelson 的 Browser Engineering 是非常好的资源([browser.engineering](http://browser.engineering/))。**
它本质上是一本免费的在线书,会带你构建一个简单 Web 浏览器,用容易理解的方式覆盖网络、HTML/CSS 解析、layout 等主题。它可以作为本文的深入配套读物,用例子巩固知识。此外,Chrome 团队的系列文章「[Inside look at modern web browser](https://developer.chrome.com/blog/inside-browser-part1)」提供了带图的易读概览。V8 博客([v8.dev](http://v8.dev/))和 [Mozilla Hacks 博客](https://hacks.mozilla.org/)也是学习引擎进展的好地方,例如新的 JIT 编译层级或 WebRender 内部机制。
总之,现代浏览器是软件工程的奇迹。它们成功抽象掉所有这些复杂度,让我们作为开发者大多只需要写 HTML/CSS/JS,并信任浏览器处理其余事情。然而,通过理解底层,我们可以获得帮助自己写出更高性能、更健壮应用的洞察。我们也会理解为什么某些技术能改善用户体验,例如避免阻塞主线程、减少不必要的 DOM 复杂度,因为我们看到了浏览器在底层究竟要做哪些工作。下次你调试网页,或好奇 Chrome、Firefox 为什么会以某种方式表现时,你就有一套浏览器内部的心智模型可以依靠。
祝你构建愉快。也请记住:Web 平台的深度会回报愿意探索的人。总有更多东西可学,也总有工具能帮你学。
*本文插图由 Susie Lu 绘制。*
**延伸阅读**
- **[Browser Engineering](https://browser.engineering)** 是 Pavel Panchekha 和 Chris Harrelson 写的一本优秀免费书
- **[Chromium University](https://www.youtube.com/playlist?list=PL9ioqAuyl6ULp1f36EEjIN1vSBEfsb-0a)**:一套免费的深度视频系列,讲解 Chromium 如何工作,其中包括出色的 [Life of a Pixel talk](https://www.youtube.com/watch?v=K2QHdgAKP-s&list=PL9ioqAuyl6ULp1f36EEjIN1vSBEfsb-0a&index=3&pp=iAQB)
- [**Google Chrome at 17——一段浏览器历史**](https://addyosmani.com/blog/chrome-17th/)