当我们写 fetch ('...') 时,浏览器到底做了什么?—— 一次完整的全链路梳理

6 阅读7分钟

本文是我系统学习浏览器网络原理时,对 fetch 请求全生命周期的一次完整梳理。
从一行 fetch() 代码出发,拆解了从 JS 执行、渲染进程处理、IPC 通信、网络进程调度、HTTP/1.1/2/3 协议细节,到最终响应返回 JS 的完整链路,也纠正了自己学习过程中踩过的不少认知误区(比如 HTTP/1.1 与流的关系、fetch 流式的本质等)。
内容仅为个人学习总结,难免有疏漏或理解不到位的地方,若有勘误、补充或不同理解,欢迎在评论区指正。

第一步:解析参数与构造请求(渲染进程,JS主线程/Worker线程) JS的URL => 浏览器Request对象
// js
fetch('/api/user', {
  method: 'POST',
  body: JSON.stringify({ id: 1 }),
  headers: { 'Content-Type': 'application/json' },
  credentials: 'include',
  cache: 'no-cache'
})
// 浏览器生成的Request实例
Request {
  // 传入的、可直接读写的核心配置
  url: "https://当前页面origin/api/user", // 已经完成相对路径拼接
  method: "POST",
  headers: Headers {}, // 包含传入的Content-Type
  body: ReadableStream {}, // 请求体的流式对象
  // 浏览器填充的默认配置,这些全都是服务端看不到的,是给网络进程消费的
  credentials: "include",
  cache: "no-cache",
  mode: "cors",
  redirect: "follow",
  referrer: "https://当前页面origin/",
  keepalive: false,
  // ... 其他标准化属性
}

a. URL解析与规范化:补全协议、域名、端口号,处理../、./等,得到真正的URL

b. 创建请求对象

  • fetch专属:根据真正的URL和options,调用Request构造函数,创建一个request实例对象

c. 缓存策略 —— 元数据添加缓存策略

根据cache配置(如no-cache)在请求元数据中标记缓存处理策略,由网络进程执行实际的缓存查询逻辑

  • 命中强缓存(Cache-Control:max-age=xxx 未过期): 网络进程直接返回缓存结果,无需发起网络请求
  • 命中协商缓存(ETag/Last-Modified) :为Request添加 If-None-Match / If-Modified-Since配置
  • 未命中缓存:下一步

d. 安全策略检查(CORS预检判断)—— 是否预检

  • 简单请求:GET/HEAD/POST,且Content-Type是application/x-www-form-urlencoded、multipart/form-data、text/plain,且无自定义头,下一步
  • 预检请求
    • 在渲染进程生成OPTIONS预检请求配置
    • 预检请求携带 Access-Control-Request-Method/Access-Control-Request-Headers

e. 进程间通信(IPC)

缓存策略、OPTIONS 预检请求的完整配置参数,随主请求元数据一起通过 IPC 交给网络进程,由网络进程按规范先执行预检请求。

第二步:建立网络连接(网络进程)

a. DNS解析: DNS缓存(浏览器缓存=>系统缓存) => hosts => 服务器DNS

b. 连接池管理(连接复用核心)

网络进程不会每次都建立TCP连接,而是先检查连接池

  • 查询是否有到「协议 + 域名 + 端口」的空闲 TCP 连接(现代浏览器通常并行6个)
  • HTTP/1.1:复用keep-alive的空闲连接
  • HTTP/2.0:多路复用
  • HTTP/3: 基于 QUIC 协议,传输层TCP => UDP, 浏览器对同域名仍会建立多个 QUIC 连接(通常 6 个,并行限制一致), 但单个 QUIC 连接即可承载大量独立的请求流,无需像 HTTP/1.1 那样为每个请求串行排队。同时支持连接迁移:客户端切换网络(WiFi→4G)时,不需要重新建立连接、重新握手,基于连接 ID 就能恢复连接,对移动端用户的体验提升极大。

c. TCP/TLS连接(无空闲连接时)

第三步:发送HTTP请求(网络进程)

组装报文,发起请求。

  • HTTP/1.1 —— 报文级传输(底层TCP流与分块传输编码chunked可实现流式传输效果,但非协议层原生流抽象),将整个报文交给TCP层,TCP层分割报文段、标上序号。http在每个TCP通道上排队,应用层级队头阻塞(前面的http没接收后面的传不了)
  • HTTP/2 —— 二进制传输。请求头压缩为HPACK分成HEADER帧,请求体分为DATA帧,每个帧有Stream ID(表识属于哪个请求)。多路复用,解决了应用层队头阻塞,但存在TCP队头阻塞。假定请求a、b、c都在一个TCP通道里,帧按照1、2、3、4...说明,a1、a2、c1、b2、c2到了,检测到b1丢包未到,c2实际上不受b1影响,但tcp不知道,开始等待b1重传,则b2及后续全部卡住,表现为TCP队头阻塞,也就是传输层级队头阻塞 —— 弱网环境甚至可能效果比http1.1更差,多路复用交错传输,但是丢包一个卡住全部。
    • 如果在弱网环境下发现HTTP/2没有预期中快,甚至更慢,可以从“TCP队头阻塞”角度思考。通过Network瀑布流,观察是否有多个请求被同一个TCP连接上的丢包所阻塞。这直接决定是否应考虑升级到HTTP/3。
  • HTTP/3 —— 彻底解决队头阻塞。传输层从 TCP 改为 UDP,基于 QUIC 协议,每个请求对应一个完全独立的 QUIC 流,流之间没有任何依赖。还是用之前的例子:请求 a、b、c 的帧交错发送,a1、a2、c1、b2、c2 都到了,唯独 b1 丢包。
    • 对 HTTP/3 来说,c2 和 b1 属于完全独立的流,c2 已经完整到达,直接就能交给上层应用,不需要等 b1 重传;
    • 只有请求 b 的流,会等待 b1 的重传,不会影响 a 和 c 的请求,彻底解决了 TCP 层的队头阻塞。同时,头部压缩从 HTTP/2 的 HPACK 升级为QPACK,支持乱序解压头部,解决了 HPACK 的队头阻塞问题,弱网环境下的性能提升非常明显。另外支持0-RTT握手,极大减少延迟,对移动端的首包时间优化极大。
第四步:接收与处理响应(网络进程与渲染进程通信)

先说本质,Stream是所有软件工程信息传输的基本概念。 IPC通信也不例外。网络传输是流、进程通信是流(只是有的API会将流缓存到内存中,全部结束后一次性返回)。无论任何情况,返回给浏览器的都是Stream,基于浏览器的Stream API。但浏览器交给JS则不一定。

a. 接收响应头(fetch 立即 resolve、XHR 触发readyState=2的onreadystatechange)

  • 只要接收到完整的响应头,从应用层的角度,这次行为是成功的。
  • 状态码只是一种业内约定规范,只要心大可以404表示成功。但浏览器fetch对3xx做了特殊处理(原因是3xx表示中间态,2xx/4xx/5xx都是结束态(业务上的成功或失败),心大也得有个限度)
  • 如果3xx —— 则自动发起重定向请求(fetch里redirect为默认值的情况, manual直接 resolve Promise,将 3xx 响应返回给 JS,error则reject)
  • 注,axios会处理4xx/5xx,是因为它是针对业务的库,对业务码做了处理。而原生fetch需要手动检查response.ok

b. 响应体

  • fetch:在接收到完整响应头瞬间,渲染进程会创建一个原生ReadableStream对象,挂载到response.body(一开始什么都没有)并立即交给JS。 网络进程将解析后的 HTTP 响应体字节,通过 IPC 进程间通信实时推送到该流中
  • XHR:设计之初针对http/1.1报文级模型(所以默认等待完整响应体接收完毕)。XHR2也可以设置拿到流式对象,但易用性不如fetch。

c. 返回给JS(渲染进程)

fetch

  • 流式处理:直接操作 response.body.getReader() 逐块读取数据,无需等待全部完成。大文件、AI流式对话等场景
  • 非流式处理:调用response.json() 等方法,浏览器会异步流式读取整个响应体,在内存中拼接完整数据后进行解析(比如JSON.parse),最终返回处理后的结果。

XHR

  • readyState 变化
    • readyState=2:接收到响应头,触发 onreadystatechange
    • readyState=3:正在接收响应体,触发 onprogress
    • readyState=4:响应体全部接收完毕,触发 onload
  • 流式:XHR2支持。
第五步:连接回收(网络进程)

请求完成后:

  • 如果是 keep-alive 连接:放回连接池,等待后续请求复用
  • 如果连接超时、出错、或者服务器关闭连接:关闭 TCP/QUIC 连接