1. 为什么 Fetch API 值得你花时间彻底搞懂
前端圈子里有个挺有意思的现象:很多人天天用fetch发请求,但真被问到“它和 XMLHttpRequest 到底差在哪”“为什么我拿不到 404 的错误”“怎么取消一个请求”的时候,能答利索的没几个。我自己带过几个新人,几乎每个人都在fetch上踩过坑——有人以为fetch会自动 reject 非 2xx 状态码,结果线上接口报 500 的时候页面直接白屏;有人不知道response.json()返回的是 Promise,写了个const data = response.json()然后对着undefined调试了一下午。
这篇东西就是把我这些年用fetch的经验、踩过的坑、以及那些文档里不会明说的细节,一次性讲透。Fetch API 是 ES6 之后浏览器原生提供的网络请求接口,它基于 Promise 设计,用来替代老旧的 XMLHttpRequest。它能做什么?简单说,浏览器里所有需要跟服务端交换数据的场景——加载列表、提交表单、上传文件、流式读取——它都能干。适合谁看?只要你在写前端,不管你是刚入门还是已经工作两三年,这篇都值得过一遍,因为里面很多细节是真正到了生产环境才会暴露出来的。
我写这篇不是要给你复述 MDN 文档,而是想让你看完之后能直接上手写出一套健壮的请求封装,知道每个参数为什么这么设,出了问题该往哪个方向排查。下面我会从设计思路、核心细节、完整实操、问题排查几个维度展开,尽量把“为什么”讲清楚,而不是只告诉你“怎么写”。
2. Fetch 的整体设计与选型思路拆解
2.1 从 XMLHttpRequest 到 Fetch 的演进逻辑
要理解fetch为什么长这样,得先知道它要解决什么问题。早期的 XMLHttpRequest(简称 XHR)是事件驱动的,写起来大概是这样:先new一个对象,然后open,再挂onreadystatechange或onload回调,最后send。这套写法在简单场景下还能忍,但一旦涉及多个请求的串行、并行、错误处理,回调就会层层嵌套,也就是大家常说的“回调地狱”。
fetch的核心变化是把整个请求过程抽象成一个 Promise。Promise 的好处是可以用.then链式调用,也可以用async/await写成同步风格的代码。这带来的不只是写法上的简洁,更重要的是错误处理路径统一了——你可以用try/catch一次性捕获整个异步流程里的异常,而不是在每个回调里单独判断。
另一个关键设计是fetch把请求和响应拆成了两个对象:Request和Response。这两个对象都遵循 Web 标准里的 Body 混入(Body mixin),意味着它们共享一套读取 body 的方法,比如.json()、.text()、.blob()、.arrayBuffer()。这种设计让流式处理成为可能——你可以一边接收响应一边处理数据,而不必等整个响应体下载完。
2.2 为什么 Fetch 基于 Promise 而不是回调
这个问题其实值得展开说。Promise 相比回调,最大的优势是状态不可逆和值可传递。一个 Promise 一旦 resolve 或 reject,状态就固定了,不会出现回调被调用多次的诡异情况。而且 Promise 的返回值可以继续.then,形成一条清晰的数据管道。
但 Promise 也带来一个容易误解的点:fetch()返回的 Promise只在网络层面出错时才 reject,比如断网、DNS 解析失败、请求被阻止。而 HTTP 层面的错误——404、500、502——都会让 Promise 正常 resolve,只是response.ok为false。这个设计当初被很多人吐槽,但站在标准制定者的角度,它其实是有道理的:HTTP 状态码是应用层的语义,网络请求本身是成功的,服务端确实返回了一个响应,只是这个响应表示“没找到”或“服务器错误”。是否把 404 当成异常,应该由业务代码决定,而不是由底层 API 替你决定。
理解这一点非常关键,因为它直接决定了你封装请求时的错误处理策略。我见过太多人写fetch(url).then(res => res.json())然后以为 404 会进 catch,结果拿到一个内容为{error: "not found"}的 JSON,页面逻辑全乱套。
2.3 Fetch 与 Axios 的选型对比
实际项目里,很多人会在fetch和 Axios 之间纠结。我把两者的核心差异整理成一张表,方便你对照自己的场景做选择。
| 对比维度 | Fetch API | Axios |
|---|---|---|
| 来源 | 浏览器原生,无需安装 | 第三方库,需要引入 |
| 错误处理 | 非 2xx 不 reject,需手动判断 | 非 2xx 自动 reject |
| 请求取消 | 需要 AbortController | 内置 CancelToken |
| 超时控制 | 需要 AbortController + setTimeout | 内置 timeout 配置 |
| 请求/响应拦截 | 需要自己封装 | 内置拦截器 |
| 自动 JSON 转换 | 需要手动调.json() | 自动转换 |
| 上传进度 | 需要 ReadableStream,兼容性一般 | 内置 onUploadProgress |
| 包体积 | 0(浏览器自带) | 约 30KB(gzip 后更小) |
| 兼容性 | 现代浏览器,IE 完全不支持 | 支持到 IE11 |
选型建议很直接:如果你的项目需要兼容老浏览器,或者需要上传进度、拦截器这些开箱即用的功能,Axios 更省事。如果你追求零依赖、想要更贴近标准、或者在做一些需要精细控制请求流的场景(比如流式读取、Service Worker 里发请求),fetch是更好的选择。我个人的习惯是:中小型项目直接用fetch加一层薄封装,大型项目看团队习惯,但即便用 Axios,理解fetch的底层机制也能帮你更好地排查问题。
3. Fetch 核心细节与实操要点全解析
3.1 请求配置对象的每个字段到底怎么用
fetch的第二个参数是一个配置对象,字段不少,但常用的就那么几个。我按使用频率从高到低说。
method指定请求方法,默认是GET。这里有个细节:GET和HEAD请求不能带 body,如果你传了会直接报错。POST、PUT、PATCH、DELETE可以带 body。
headers用来设置请求头。它可以是普通对象,也可以是Headers实例。这里有个坑:请求头的值必须是字符串,如果你传了数字,浏览器会自动转成字符串,但某些自定义头如果值不是字符串可能会被忽略。另外,设置Content-Type的时候要注意,如果你用FormData传文件,千万不要手动设Content-Type,因为浏览器需要自动生成带boundary的 multipart 头,你手动设了反而会导致服务端解析失败。
body是请求体,支持的类型有:字符串、FormData、Blob、ArrayBuffer、URLSearchParams、ReadableStream。如果你传的是普通对象,浏览器不会自动序列化,会直接报错。所以发 JSON 的时候必须自己JSON.stringify,同时设Content-Type: application/json。
mode控制跨域行为,常用值有cors、no-cors、same-origin。默认是cors。no-cors模式下你只能发简单请求,而且响应是 opaque 的,读不到任何内容,基本只在 Service Worker 里缓存资源时用。
credentials控制是否携带 cookie,值有omit、same-origin、include。默认是same-origin,也就是同源请求才带 cookie。跨域要带 cookie 必须设成include,同时服务端也要设置Access-Control-Allow-Credentials: true,而且Access-Control-Allow-Origin不能是*,必须是具体域名。
cache控制缓存策略,值有default、no-store、reload、no-cache、force-cache、only-if-cached。这个字段在调试的时候特别有用,比如你想强制拿最新数据,可以设no-store。
signal用来关联AbortController,实现请求取消,这个后面单独讲。
3.2 Response 对象里那些容易忽略的属性
拿到response之后,大部分人只关心.json(),但其实 Response 对象上还有不少有用的信息。
response.ok是个布尔值,等价于status在 200 到 299 之间。这是判断请求是否成功最直接的方式。
response.status是 HTTP 状态码,response.statusText是状态文本。注意statusText在 HTTP/2 里可能为空,所以不要依赖它做逻辑判断。
response.headers是一个Headers对象,读取的时候用.get('content-type'),注意 header 名是大小写不敏感的。有个限制:在跨域请求里,你只能读取 CORS 安全列表里的响应头,比如Content-Type、Cache-Control、Last-Modified等。如果你想读自定义头,服务端必须通过Access-Control-Expose-Headers显式暴露。
response.url是最终请求的 URL,经过重定向之后的值。response.redirected表示是否发生过重定向。这两个在排查重定向问题时很有用。
response.type表示响应类型,常见值有basic(同源)、cors(跨域)、opaque(no-cors)、error。opaque类型的响应读不到任何内容,status也是 0。
response.body是一个ReadableStream,这是流式处理的关键。你可以用getReader()拿到 reader,然后逐块读取数据。这在处理大文件下载或者 SSE(Server-Sent Events)的时候特别有用。
3.3 Body 读取方法的区别与选择
Response 对象提供了五个读取 body 的方法,它们都会返回 Promise,而且只能调用一次。一旦 body 被读取(哪怕只读了一部分),再调用其他方法就会报TypeError: body stream already read。
.json()把 body 解析成 JSON 对象。如果 body 不是合法 JSON,会 reject 一个SyntaxError。这是最常用的方法。
.text()把 body 解析成字符串。适合处理纯文本、HTML、XML 等。
.blob()把 body 解析成Blob对象,适合处理图片、视频等二进制文件。
.arrayBuffer()把 body 解析成ArrayBuffer,适合处理需要底层操作的二进制数据。
.formData()把 body 解析成FormData,适合处理表单提交的响应。
如果你需要多次读取 body,可以先clone()一份。response.clone()会创建一个副本,两个副本可以独立读取。但注意,clone()必须在 body 被读取之前调用,而且会占用额外的内存,大响应体慎用。
3.4 请求取消与超时控制的正确姿势
fetch本身没有超时配置,也没有取消方法,这两个功能都要靠AbortController实现。
AbortController的用法很直接:创建一个 controller,把它的signal传给fetch,然后在需要取消的时候调用controller.abort()。这时候fetch返回的 Promise 会 reject 一个AbortError。
超时控制的常见写法是用setTimeout配合abort:
function fetchWithTimeout(url, options = {}, timeout = 5000) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); return fetch(url, { ...options, signal: controller.signal }) .finally(() => clearTimeout(timer)); }这里有个细节:abort()之后,fetch的 Promise 会 reject,但如果你在finally里清理定时器,要注意finally是在 Promise settle 之后执行的,所以定时器一定会被清掉。另外,AbortError和网络错误要区分对待,因为用户主动取消不应该被当成错误上报。
还有一个进阶用法:AbortSignal.timeout(5000)是较新的 API,可以直接生成一个超时后自动 abort 的 signal,省去了手动管理定时器。不过兼容性还不算特别广,生产环境用之前记得查一下目标浏览器支持情况。
4. 从零封装一个生产级 Fetch 请求库
4.1 基础封装:统一错误处理与 JSON 解析
裸用fetch最大的问题是错误处理分散,每个调用点都要判断response.ok,都要手动.json()。所以第一步是封装一个基础请求函数,把这两件事统一掉。
async function request(url, options = {}) { const config = { headers: { 'Content-Type': 'application/json', ...options.headers, }, ...options, }; // 如果 body 是普通对象,自动序列化 if (config.body && typeof config.body === 'object' && !(config.body instanceof FormData)) { config.body = JSON.stringify(config.body); } const response = await fetch(url, config); // 非 2xx 统一抛错 if (!response.ok) { const error = new Error(`HTTP ${response.status}: ${response.statusText}`); error.status = response.status; error.response = response; throw error; } // 根据 content-type 决定解析方式 const contentType = response.headers.get('content-type') || ''; if (contentType.includes('application/json')) { return response.json(); } return response.text(); }这段代码有几个设计决策值得说明。第一,默认Content-Type设为application/json,因为现在绝大多数接口都是 JSON 交互。第二,自动序列化 body,但排除FormData,因为FormData需要浏览器自动处理。第三,错误对象上挂了status和response,方便上层根据状态码做不同处理。第四,根据响应头的content-type决定解析方式,避免对非 JSON 响应调.json()导致报错。
4.2 进阶封装:拦截器与请求重试
基础封装解决了统一处理的问题,但实际项目里还需要拦截器(比如统一加 token)和重试(比如网络抖动时自动重试)。
拦截器的实现思路是在请求发出前和响应返回后插入钩子:
const interceptors = { request: [], response: [], }; async function requestWithInterceptors(url, options) { let config = { url, ...options }; // 请求拦截器 for (const interceptor of interceptors.request) { config = await interceptor(config); } let response = await fetch(config.url, config); // 响应拦截器 for (const interceptor of interceptors.response) { response = await interceptor(response); } return response; }重试的逻辑要小心,不是所有请求都适合重试。GET请求天然幂等,重试安全;POST请求可能产生副作用,重试要谨慎。而且重试要加退避策略,不能立即重试,否则可能加剧服务端压力。
async function fetchWithRetry(url, options = {}, retries = 3, delay = 1000) { for (let i = 0; i < retries; i++) { try { return await request(url, options); } catch (error) { // 只对网络错误和 5xx 重试 const shouldRetry = !error.status || error.status >= 500; if (!shouldRetry || i === retries - 1) throw error; await new Promise(resolve => setTimeout(resolve, delay * Math.pow(2, i))); } } }这里的退避策略用的是指数退避:第一次等 1 秒,第二次 2 秒,第三次 4 秒。这样既能给服务端恢复的时间,又不会让用户等太久。
4.3 文件上传与进度监控的实现
fetch上传进度一直是个痛点,因为标准里没有直接提供上传进度事件。不过较新的浏览器支持通过ReadableStream包装请求体来实现进度监控,但兼容性和实现复杂度都不太友好。
更实际的做法是:如果上传进度是硬需求,用XMLHttpRequest或者 Axios;如果只是普通文件上传,fetch配合FormData完全够用。
async function uploadFile(url, file, onProgress) { const formData = new FormData(); formData.append('file', file); // 注意:不要手动设 Content-Type const response = await fetch(url, { method: 'POST', body: formData, }); return response.json(); }如果确实需要进度,可以用XMLHttpRequest包一层,对外暴露 Promise 接口,这样既保留了fetch风格的调用,又能拿到进度事件。这种混合方案在实际项目里很常见,不用觉得“用了 fetch 就不能用 XHR”。
4.4 流式读取与大文件下载
fetch相比 XHR 的一个显著优势是支持流式读取。response.body是一个ReadableStream,你可以逐块读取数据,边读边处理。
async function downloadWithProgress(url, onChunk) { const response = await fetch(url); const contentLength = response.headers.get('content-length'); const total = contentLength ? parseInt(contentLength, 10) : 0; let loaded = 0; const reader = response.body.getReader(); const chunks = []; while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); loaded += value.length; if (onChunk) onChunk(loaded, total); } // 合并所有 chunk const allChunks = new Uint8Array(loaded); let position = 0; for (const chunk of chunks) { allChunks.set(chunk, position); position += chunk.length; } return allChunks; }这段代码的关键点是:reader.read()返回的value是Uint8Array,需要自己合并。content-length响应头可能不存在(比如分块传输),所以要做兜底。流式读取的另一个用途是处理 SSE,服务端持续推送数据,客户端逐条解析,这在实时通知、日志监控等场景很有用。
5. 常见问题与排查技巧实录
5.1 跨域问题的完整排查路径
跨域是fetch最常见的报错来源,但浏览器的报错信息往往很模糊,只说“CORS policy blocked”,不告诉你具体哪里不对。我整理了一套排查路径。
第一步,看请求有没有发出去。打开 Network 面板,如果请求显示为(blocked: origin)或者根本没出现,说明是预检请求(OPTIONS)失败了。预检失败通常是服务端没有正确响应 OPTIONS 请求,或者Access-Control-Allow-Headers没有包含你自定义的头。
第二步,看响应头。如果请求发出去了但响应被拦截,检查Access-Control-Allow-Origin是否匹配当前源。注意这个头不能是*如果带了 credentials。
第三步,看 credentials 配置。如果前端设了credentials: 'include',服务端必须设Access-Control-Allow-Credentials: true,且Access-Control-Allow-Origin必须是具体域名。
第四步,看自定义头。如果你发了Authorization之类的头,服务端必须在Access-Control-Allow-Headers里列出来。
| 报错关键词 | 可能原因 | 排查方向 |
|---|---|---|
| No 'Access-Control-Allow-Origin' | 服务端没设 CORS 头 | 检查服务端配置 |
| Credentials flag is true | 带 cookie 但 Allow-Origin 是 * | 改成具体域名 |
| Request header field X is not allowed | 自定义头未在 Allow-Headers 中 | 服务端补充 |
| Method PUT is not allowed | 预检未允许该方法 | 检查 Allow-Methods |
| Preflight response is not successful | OPTIONS 请求返回非 2xx | 服务端处理 OPTIONS |
5.2 “body stream already read” 的成因与解决
这个报错的意思是 body 已经被读过了。常见场景有两个:一是你在拦截器里读了response.json(),然后在业务代码里又读了一次;二是你在错误处理里读了 body 做日志,然后又把 response 抛给上层。
解决办法是在读取之前先clone():
const response = await fetch(url); const clonedResponse = response.clone(); // 一份用于日志 const logData = await clonedResponse.text(); console.log('Response body:', logData); // 一份用于业务 const data = await response.json();但要注意,clone()会占用额外内存,大响应体慎用。更好的做法是在拦截器里只读必要的信息(比如状态码、部分头),不要读整个 body。
5.3 请求被浏览器缓存导致的“数据不更新”
有时候你改了服务端数据,刷新页面发现还是旧的,这很可能是浏览器缓存了 GET 请求。fetch默认遵循 HTTP 缓存策略,如果服务端返回了Cache-Control: max-age=3600,那一个小时内都会用缓存。
解决办法有几个:一是服务端设置合适的缓存头;二是请求时加时间戳参数?t=${Date.now()};三是fetch配置里设cache: 'no-store'。我一般推荐第一种,因为缓存本身是好事,不该因为调试方便就全局禁用。调试的时候临时用第二种或第三种就行。
5.4 常见问题速查表
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
| 404 不进入 catch | fetch 只在网络错误时 reject | 手动判断 response.ok |
| 拿不到自定义响应头 | CORS 未暴露 | 服务端设 Access-Control-Expose-Headers |
| POST 请求 body 丢失 | 没设 Content-Type 或没序列化 | 设 application/json 并 JSON.stringify |
| 上传文件服务端解析失败 | 手动设了 Content-Type | 删掉手动设置,让浏览器自动生成 |
| abort 后报错未捕获 | AbortError 未被处理 | catch 里判断 error.name === 'AbortError' |
| 跨域 cookie 不生效 | credentials 配置或服务端头不对 | 前端 include,服务端 Allow-Credentials |
| 大文件下载内存暴涨 | 一次性读取整个 body | 改用流式读取逐块处理 |
5.5 几个我踩过的坑和独家心得
第一个坑:fetch在页面卸载时发出的请求会被浏览器取消。如果你在beforeunload里发请求上报数据,很可能发不出去。解决办法是用navigator.sendBeacon,它专门用于这种场景,浏览器会保证请求发出。
第二个坑:fetch的keepalive选项。它可以让请求在页面卸载后继续,但有大小限制(64KB),而且不能和流式 body 一起用。适合发送小的埋点数据。
第三个坑:response.json()在响应体为空时会报错。有些接口返回 204 No Content,这时候调.json()会抛SyntaxError。稳妥的做法是先判断response.status === 204或者content-length === '0',直接返回 null。
第四个坑:AbortController的 signal 可以复用,但一旦 abort 就不能再用了。如果你要发多个请求共用一个 controller,abort 会取消所有关联的请求。这个特性可以用来实现“页面切换时取消所有进行中的请求”。
第五个坑:fetch在 Service Worker 里的行为和页面里略有不同,比如credentials的默认值可能不一样。写 PWA 的时候要特别注意。
6. 把 Fetch 用好的几个关键认知
6.1 理解 Fetch 的“底层”定位
fetch不是一个开箱即用的请求库,它是一个底层 API。这意味着它把很多决策权交给了开发者:错误怎么处理、超时怎么控制、重试怎么做、拦截器怎么实现,都需要你自己设计。这既是负担也是自由。负担在于你要写更多代码,自由在于你可以完全按照项目需求定制,不被第三方库的设计束缚。
我的建议是:不要试图把fetch封装成一个“万能请求库”,那样只会越封越复杂。更好的做法是根据项目实际需求,封装一层薄薄的、刚好够用的工具函数,保持简单可维护。
6.2 错误处理的分层策略
一个健壮的请求层应该有清晰的错误分层。我一般分三层:网络层错误(断网、DNS 失败、超时)、HTTP 层错误(4xx、5xx)、业务层错误(响应体里的 code 字段表示业务失败)。
网络层错误由fetch的 reject 捕获,HTTP 层错误由response.ok判断,业务层错误由业务代码根据响应体内容判断。三层错误应该有不同的处理策略:网络层错误可以提示“网络异常,请检查网络”,HTTP 层 401 可以跳登录,403 可以提示无权限,5xx 可以提示“服务繁忙”,业务层错误则根据具体 code 做对应处理。
6.3 性能与体验的平衡
fetch的性能优化有几个方向。一是合理利用缓存,GET 请求该缓存就缓存,减少重复请求。二是并发控制,同时发太多请求会拖慢页面,可以用Promise.all配合分批,或者用队列控制并发数。三是请求去重,同一个接口在短时间内被多次调用时,可以复用同一个 Promise,避免重复请求。
体验方面,加载状态、错误提示、重试按钮这些都要考虑。fetch本身不提供这些,但配合 UI 框架的状态管理,可以做出很流畅的交互。
6.4 兼容性与降级方案
fetch在现代浏览器里支持很好,但如果你需要兼容 IE 或者很老的移动端浏览器,就需要 polyfill。常见的方案是引入whatwg-fetch,它用 XHR 模拟了fetch的接口。但要注意,polyfill 无法完全模拟所有特性,比如流式读取、AbortController在旧浏览器上可能不支持,需要额外的 polyfill。
我的经验是:如果目标用户里有超过 5% 还在用不支持fetch的浏览器,就老老实实用 Axios;如果只是极少数,可以用 polyfill 加降级提示。不要为了技术先进性牺牲用户体验。
6.5 安全相关的注意事项
用fetch的时候有几个安全点要注意。一是不要在前端代码里硬编码敏感 token,虽然这是常识,但还是经常看到。二是注意 CSRF 防护,带 cookie 的请求要配合 CSRF token。三是注意响应内容的处理,不要直接把服务端返回的 HTML 插入 DOM,防止 XSS。四是跨域请求要严格校验来源,不要盲目信任第三方接口返回的数据。
这些不是fetch特有的问题,但用fetch的时候因为配置灵活,更容易不小心引入安全漏洞。比如mode: 'no-cors'虽然能发请求,但响应不可读,如果用来做数据上报,要确保不会泄露敏感信息。
7. 写在最后的一些个人体会
fetch这个 API 我用了很多年,从最开始觉得它比 XHR 简洁,到后来被它的各种“坑”折腾,再到现在能比较从容地处理各种场景,中间走了不少弯路。回头看,最大的体会是:不要把它当成一个黑盒,要理解它背后的设计哲学。它为什么不在 404 时 reject?为什么 body 只能读一次?为什么没有超时配置?这些设计都有其道理,理解了这些道理,你就能预判它的行为,而不是每次遇到问题都去搜答案。
另一个体会是,封装要克制。我见过太多项目把fetch封装得无比复杂,拦截器、重试、缓存、并发控制全塞进去,结果新人接手根本看不懂,出了问题也不知道从哪查。好的封装应该是透明的,让调用者清楚知道发生了什么,而不是隐藏所有细节。
最后分享一个小技巧:调试fetch的时候,可以在 Network 面板里右键请求选择“Copy as fetch”,浏览器会生成一段可以直接在控制台运行的fetch代码,包含所有请求头和 body。这在复现问题时特别有用,省得你手动拼参数。这个功能我几乎每天都在用,强烈推荐。