☰
Fetch API 深度解析:从 Promise 设计到流式处理与工程实践
2026/10/15 2:39:53 网站建设 项目流程

1. 为什么 Fetch API 值得你花时间彻底搞懂

前端圈子里有个挺有意思的现象:很多人天天用fetch,但真被问到“它和 XMLHttpRequest 到底差在哪”“为什么我fetch拿不到 404 的错误”“await fetch之后还要await res.json()是为啥”,能答利索的没几个。我自己带过几个新人,几乎每个人都在fetch上踩过坑——请求发出去了但没带 cookie、POST 提交后端收不到数据、跨域报错看不懂、取消请求不知道怎么下手。这些问题不是fetch难,而是大多数人只学了“怎么发一个 GET”,没系统地把它的设计逻辑捋一遍。

这篇东西就是把我这些年用fetch的经验完整摊开讲。Fetch API 是 ES6 时代浏览器提供的一套基于 Promise 的网络请求接口,它替代了老旧的 XMLHttpRequest,让异步请求的写法从回调地狱变成了链式或async/await。它能干的事很明确:发 HTTP 请求、处理响应、上传下载数据、配合流式处理做大文件操作。适合谁看?刚接触前端异步请求的新手、从 jQuery$.ajax迁移过来的老手、以及想搞清楚“为什么我的请求行为跟预期不一样”的开发者。我会从设计思路讲到实操细节,再到排查技巧,尽量让你读完能直接上手抄作业。

2. Fetch 的整体设计与核心思路拆解

2.1 它到底解决了 XMLHttpRequest 的哪些痛点

要理解fetch为什么长这样,得先看它替代的东西有多难用。XMLHttpRequest 是事件驱动的,你得先new一个实例,然后open、setRequestHeader、send,再监听onreadystatechange或onload,还要手动判断readyState === 4和status。一个简单的 GET 请求写下来十几行,嵌套多个请求就是回调地狱。而且 XHR 的 API 设计是“状态机式”的,心智负担重。

fetch的设计哲学完全不同,它把一次请求抽象成一个 Promise:你调用fetch(url),它立刻返回一个 Promise,这个 Promise 在收到响应头的时候就 resolve,而不是等整个响应体下载完。这个细节非常关键,后面讲流式处理会用到。链式调用让代码线性化,配合async/await几乎和同步代码一样好读。我实测下来,同样一个带错误处理的请求,fetch版本比 XHR 版本少写一半代码,可读性还高不少。

2.2 基于 Promise 的链式调用为什么更符合直觉

Promise 的核心价值是把“未来才会有的值”当成一个普通对象来传递。fetch返回的 Promise resolve 出来的是一个Response对象,这个对象不是数据本身,而是“响应的描述”。所以你会看到await fetch()拿到的是 Response,还得再await res.json()才拿到真正的数据。很多人第一次见觉得多此一举,其实这是设计上的分离:响应头和响应体是分阶段到达的,Response 对象让你可以在响应头到达后、响应体下载前就做判断,比如看res.status决定要不要继续读 body。

这种分离带来的好处在大文件场景下特别明显。你可以拿到 Response 后先检查Content-Length,再决定用流式读取还是一次性json()。XHR 时代这些都得靠onprogress事件去拼,麻烦得多。所以链式调用不只是语法糖,它对应的是真实的网络分层模型。

2.3 请求与响应对象的不可变设计

fetch里有个容易被忽略但很重要的设计:Request和Response对象一旦创建,body 就只能被读取一次。你await res.json()之后,再await res.text()会直接报错 “body stream already read”。这个设计一开始让我很抓狂,后来才明白它是为了支持流式处理——body 本质是一个 ReadableStream,流只能消费一次。如果确实需要多次读取,得用res.clone()复制一份。这个坑我在实际项目里踩过,一个响应要同时给两个处理函数用,结果第二个直接报错,排查了半天才想起来 clone。

理解这个不可变性,你就理解了fetch为什么和 XHR 的“拿到就是完整数据”不一样。它是面向流的、面向分阶段的,这个思路贯穿整个 API。

3. 核心细节解析与实操要点

3.1 请求配置对象里那些必须记住的字段

fetch的第二个参数是一个配置对象,字段不少,但常用的就那么几个。我把它们整理成一张表,方便你对照记忆。

字段作用常见取值注意事项
method请求方法GET/POST/PUT/DELETE默认 GET,大小写不敏感但建议大写
headers请求头对象或 Headers 实例中文值要注意编码
body请求体字符串/FormData/BlobGET/HEAD 不能带 body
mode跨域模式cors/no-cors/same-origin默认 cors
credentials是否带凭证omit/same-origin/include跨域带 cookie 必须 include
cache缓存策略default/no-store/reload调试时常用 no-store
signal取消信号AbortSignal 实例配合 AbortController 用

这里重点说credentials。默认值是same-origin,意思是同源请求才带 cookie。如果你前后端分离部署,前端域名和后端域名不同,那默认情况下 cookie 是不会带上的,后端 session 自然拿不到。这时候必须显式写credentials: 'include',而且后端响应头也得配Access-Control-Allow-Credentials: true,两边缺一不可。我见过太多人只改前端不改后端,然后对着“登录状态丢失”抓耳挠腮。

3.2 body 的几种形态与后端接收的对应关系

body能传的东西比想象中多,但不同形态对应不同的Content-Type,后端解析方式也不一样。这是新手最容易翻车的地方。

  • JSON 字符串:JSON.stringify(data),同时手动设Content-Type: application/json。fetch不会自动帮你设这个头,忘了设后端可能按表单解析,直接拿不到数据。
  • FormData:直接传FormData实例,fetch会自动设multipart/form-data并带上 boundary。这个场景适合文件上传,千万别手动设 Content-Type,否则 boundary 丢了后端解析失败。
  • URLSearchParams:传这个实例,fetch自动设application/x-www-form-urlencoded,适合传统表单提交。
  • Blob/ArrayBuffer:二进制数据,适合文件流场景。

我个人的经验是:能用 FormData 就用 FormData,尤其是涉及文件的时候,省心。纯 JSON 接口就老老实实JSON.stringify加手动头,别偷懒。

3.3 响应对象的常用方法与读取时机

Response 对象上有一堆方法,json()、text()、blob()、arrayBuffer()、formData(),还有clone()和流相关的body。它们都是返回 Promise 的,因为读取 body 是异步的。

这里有个实操要点:先判断res.ok再读 body。res.ok是status在 200-299 之间的布尔值。如果请求返回 500,fetch的 Promise 依然是 resolved 的,不会 reject,你必须自己判断res.ok或res.status。这是fetch和很多人直觉相反的地方——它只在网络层面失败(比如断网、DNS 解析失败)时才 reject,HTTP 错误码不算失败。所以正确的写法是:

const res = await fetch(url); if (!res.ok) { throw new Error(`HTTP ${res.status}`); } const data = await res.json();

少了这个判断,你的错误处理就是假的。

4. 实操过程与核心环节实现

4.1 从零封装一个顺手的请求函数

裸用fetch在项目里会很啰嗦,每个请求都要写 headers、判断 ok、处理错误。我一般会封装一层。下面这个版本是我用了好几年的,去掉了一些项目特有的东西,保留核心逻辑。

async function request(url, options = {}) { const config = { method: 'GET', headers: { 'Content-Type': 'application/json', ...options.headers, }, credentials: 'include', ...options, }; // GET 请求不带 body if (config.method.toUpperCase() === 'GET' && config.body) { delete config.body; } const res = await fetch(url, config); if (!res.ok) { const errorText = await res.text(); throw new Error(`请求失败 ${res.status}: ${errorText}`); } const contentType = res.headers.get('content-type') || ''; if (contentType.includes('application/json')) { return res.json(); } return res.text(); }

这个封装有几个设计考量。第一,默认credentials: 'include',因为现在前后端分离是主流,省得每次忘。第二,根据content-type自动决定解析方式,避免对非 JSON 响应调json()报错。第三,GET 请求强制去掉 body,因为有些浏览器对 GET 带 body 的行为不一致。第四,错误信息里带上响应文本,方便排查后端返回的具体错误。

4.2 POST 提交 JSON 与表单的完整示例

光看封装不够,得看实际怎么调。提交 JSON 的场景:

const userData = { name: '张三', age: 28 }; const result = await request('/api/user', { method: 'POST', body: JSON.stringify(userData), });

注意这里headers里的Content-Type已经在封装里默认设成application/json了,所以不用重复写。如果后端要求别的类型,在调用时覆盖即可。

文件上传的场景就得换 FormData:

const formData = new FormData(); formData.append('file', fileInput.files[0]); formData.append('description', '用户头像'); const result = await request('/api/upload', { method: 'POST', body: formData, headers: {}, // 清空默认的 json 头,让浏览器自动设 multipart });

这里有个细节:封装里默认设了Content-Type: application/json,传 FormData 时必须把它清掉,否则浏览器不会自动设multipart/form-data和 boundary,后端就解析不了。我一般会在封装里加个判断,如果 body 是 FormData 就删掉 Content-Type,这样调用方更省心。

4.3 用 AbortController 实现请求取消与超时

fetch本身没有超时机制,这是它被吐槽最多的地方之一。XHR 有timeout属性,fetch没有,得自己用AbortController实现。

function fetchWithTimeout(url, options = {}, timeout = 10000) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); return fetch(url, { ...options, signal: controller.signal }) .finally(() => clearTimeout(timer)); }

AbortController的用法是:创建实例,把controller.signal传给fetch的signal字段,需要取消时调controller.abort()。这时fetch返回的 Promise 会 reject 一个AbortError。注意区分这个错误和普通网络错误,error.name === 'AbortError'就是主动取消的。

取消请求在搜索框联想、页面切换、组件卸载这些场景特别有用。我见过不少项目因为没取消请求,用户快速切换页面时旧请求返回后覆盖了新数据,导致界面显示错乱。加上取消逻辑后这类 bug 基本绝迹。

4.4 流式读取大响应的实操

前面提到fetch在响应头到达时就 resolve,这让我们可以做流式处理。比如下载一个大文件并显示进度:

const res = await fetch('/api/large-file'); const reader = res.body.getReader(); const contentLength = +res.headers.get('Content-Length'); let received = 0; const chunks = []; while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); received += value.length; console.log(`进度: ${((received / contentLength) * 100).toFixed(1)}%`); } const blob = new Blob(chunks);

这段代码的核心是res.body.getReader()拿到一个读取器,然后循环read(),每次拿到一个Uint8Array分片。这种方式内存占用低,适合大文件。不过要注意,Content-Length不一定存在(比如分块传输),进度计算得做兜底。另外流式读取和res.json()是互斥的,读了流就不能再调 json 了。

5. 常见问题与排查技巧实录

5.1 跨域报错的三种典型表现与定位思路

跨域是fetch最高频的问题,报错信息往往很模糊,就一句 “Failed to fetch” 或者 “CORS policy”。我把它拆成三种典型情况。

第一种,简单请求但响应头缺Access-Control-Allow-Origin。浏览器会拦截响应,控制台报 CORS 错误。解决靠后端加头,前端改不了。

第二种,预检请求(OPTIONS)失败。当你带了自定义头、用了 PUT/DELETE、或者Content-Type是application/json时,浏览器会先发一个 OPTIONS 请求探路。如果后端没处理 OPTIONS 或者没返回正确的Access-Control-Allow-Methods、Access-Control-Allow-Headers,预检就挂了。这种情况在 Network 面板能看到一个红色的 OPTIONS 请求,定位很直接。

第三种,带 cookie 的跨域。前端设了credentials: 'include',后端必须同时返回Access-Control-Allow-Credentials: true,而且Access-Control-Allow-Origin不能是*,必须是具体域名。这三个条件缺一个,cookie 就带不上或者响应被拦。

排查跨域我一般先看 Network 面板,确认请求到底发出去没有、响应头长什么样,比盯着控制台报错猜要快得多。

5.2 请求发出去了但后端收不到数据的排查清单

这个问题我遇到过好几次,原因五花八门。整理成一张速查表:

现象可能原因排查方法
后端收到空 bodyContent-Type 没设对看请求头是否匹配后端解析方式
POST 变 GETmethod 拼写错误或没传检查配置对象 method 字段
参数在 URL 里丢了特殊字符没编码用 encodeURIComponent 处理
FormData 解析失败手动设了 Content-Type删掉手动头让浏览器自动设
中文乱码编码不一致确认前后端都用 UTF-8

其中“手动设 Content-Type 导致 FormData 失败”是最隐蔽的,因为请求看起来完全正常,就是后端解析不出来。记住一个原则:传 FormData 时永远不要手动设 Content-Type。

5.3 body 只能读一次的报错与 clone 的正确用法

“Failed to execute 'json' on 'Response': body stream already read” 这个报错,本质是 Response 的 body 是流,只能消费一次。如果你需要把同一个响应交给两个地方处理,比如既存缓存又更新 UI,就得提前 clone。

const res = await fetch(url); const resClone = res.clone(); // 一份用来解析 const data = await res.json(); // 另一份留着备用 const backup = await resClone.text();

注意clone()必须在读取 body 之前调用,读完再 clone 就晚了。而且 clone 会复制整个流,大响应下内存翻倍,所以别滥用,只在确实需要多次读取时用。

5.4 几个我踩过的坑和对应技巧

第一个坑:fetch默认不带 cookie,我早期做前后端分离项目时,登录接口调通了但后续接口一直 401,查了半天才发现是 credentials 的问题。现在的习惯是封装里默认带上,需要不带时再单独关。

第二个坑:res.json()对空响应体会报错。有些接口成功时返回 204 No Content,body 是空的,这时候调json()直接抛异常。我的处理是先判断res.status === 204或者content-length为 0,直接返回 null。

第三个坑:并发请求用Promise.all时,只要有一个 reject 整个就挂了。如果希望部分失败不影响其他,得用Promise.allSettled,它返回每个请求的结果状态,适合批量请求场景。

第四个坑:fetch在页面卸载时发出的请求可能被浏览器中断。如果有关键的埋点或日志上报,得用navigator.sendBeacon,它是专门为这种场景设计的,不阻塞页面卸载。

6. 进阶场景与性能优化建议

6.1 请求去重与缓存策略

同一个接口在短时间内被多次调用是常见现象,比如多个组件同时请求用户信息。我一般会做一个简单的请求缓存:用一个 Map 存 URL 到 Promise 的映射,请求前先查,有就直接返回同一个 Promise,请求完成后删掉。这样并发请求只会真正发一次。

const pending = new Map(); function dedupRequest(url, options) { const key = url + JSON.stringify(options); if (pending.has(key)) { return pending.get(key); } const promise = request(url, options).finally(() => { pending.delete(key); }); pending.set(key, promise); return promise; }

这个方案对 GET 请求特别有效,POST 一般不去重因为可能有副作用。配合cache字段还能利用浏览器缓存,调试时设no-store避免缓存干扰。

6.2 拦截器思路的轻量实现

axios 有拦截器,fetch没有,但可以自己包一层。核心思路是在请求前和响应后各插一个钩子函数数组,依次执行。

const requestInterceptors = []; const responseInterceptors = []; async function interceptedFetch(url, options) { let config = { url, ...options }; for (const fn of requestInterceptors) { config = await fn(config); } let res = await fetch(config.url, config); for (const fn of responseInterceptors) { res = await fn(res); } return res; }

请求拦截器常用来统一加 token、加时间戳防缓存;响应拦截器常用来统一处理 401 跳登录、统一解包数据。这套东西不复杂,但能让业务代码干净很多。我建议拦截器保持轻量,别塞太多逻辑,否则出问题很难定位是哪一层改的。

6.3 什么时候该换用别的方案

fetch不是万能的。上传大文件需要进度条时,fetch没有原生的上传进度事件,得用 XHR 或者较新的ReadableStream上传方案。需要同步请求的场景(虽然很少见)也只能用 XHR。另外一些老项目里 jQuery 的$.ajax已经深度集成,迁移成本高的话没必要硬换。

我的判断标准是:新项目一律fetch,配合封装足够用;需要上传进度、需要兼容很老的浏览器、或者团队已有成熟 XHR 封装时,保持现状。技术选型看场景,别为了新而新。

7. 我个人在实际项目中的几点体会

用fetch这些年,最大的感受是它把网络请求的复杂度从“API 怎么调”转移到了“HTTP 协议本身怎么理解”。XHR 时代很多细节被 API 封装掉了,fetch更贴近底层,所以你得懂 Content-Type、懂 CORS 预检、懂流的概念。这其实是好事,理解这些之后,不管用什么请求库都能快速上手。

另外一点,封装很重要但别过度封装。我见过把fetch包了七八层的项目,出问题时得一层层扒源码,反而降低了效率。我的原则是封装解决三个问题就够了:统一错误处理、统一认证头、统一数据解析。其他场景化的逻辑放在业务层,保持封装的透明和可预测。

最后分享一个小技巧:调试fetch时,在 Network 面板右键请求选“Copy as fetch”,能直接拿到浏览器实际发出的请求代码,对比自己写的配置,一眼就能看出哪里不一样。这个功能帮我定位过好几次诡异的请求问题,比逐行读代码快多了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询