老项目里翻出一段当年用 XMLHttpRequest 写的文件下载逻辑,看到readyState和onreadystatechange那瞬间,我就明白为什么现在团队里所有新功能都直接上 Fetch 了。Fetch 的使用在今天的前端开发里已经算基本功,但很多人只是从老教程里学会了.then()链,遇到实际业务里的异常状态码、请求取消、超时控制,依然会被坑得一头包。
这篇不是 API 文档的复读,而是基于这几年在真实项目里踩坑、封装、排查后的一次完整梳理。内容包括 Fetch 和 XHR 的本质差异、最容易被忽略的参数细节、并发与取消的实战写法,以及我封装请求工具函数时的一些取舍。适合刚把 Fetch 当.then()用的新手,也适合正在重构老旧代码、想把请求层做得更稳的开发者。
1. Fetch API 与老伙计 XMLHttpRequest 的对比拆解
1.1 为什么如今更倾向用 Fetch?一个老项目的真实痛点
之前接手某个内部后台系统,代码里至少两百处是在用 XMLHttpRequest 封装的上世纪古董。每次加一个接口,都要复制粘贴一大段模板,然后手动处理onreadystatechange,四层 if 嵌套判断状态码,看着就头疼。维护起来最难受的不是功能实现,而是事件回调带来的状态管理混乱——一个请求的生命周期被拆散在多个回调里,读代码的人得在脑子里把事件顺序拼回去。
举个直观对比,老的 XHR 写法大概是这样的:
const xhr = new XMLHttpRequest(); xhr.open('GET', '/api/user/profile', true); xhr.onreadystatechange = function () { if (xhr.readyState === XMLHttpRequest.DONE) { if (xhr.status === 200) { const data = JSON.parse(xhr.responseText); render(data); } else { handleError(xhr.status); } } }; xhr.send();换成 Fetch 之后,配合async/await可以写成:
const res = await fetch('/api/user/profile'); if (res.ok) { const data = await res.json(); render(data); } else { handleError(res.status); }同样是两段逻辑,后者的执行顺序是线性的,代码读起来就像在读一个同步流程。这里最关键的不是少写了几行,而是把异步流程重新交还给了业务逻辑本身。回调地狱的本质是“顺序被事件割裂”,而 Promise 把“请求发出”和“结果回来”建模成了链上的节点,想取消、想超时、想并发,都有明确的语法位置可以插入逻辑。
另外一个不可忽视的现实因素是,XHR 在浏览器里的能力边界已经明显落后。上传进度、分块读取、流式处理这些能力,XHR 要么不支持,要么实现极其别扭。Fetch 从一开始就把请求和响应设计成了可流式读取的对象,这在处理大文件和实时数据时是质变。
1.2 Fetch 的核心设计:Promise 与流式响应
Fetch 的本质是一个围绕 Promise 设计的标准 Web API,它的返回值是一个Promise<Response>。这个设计带来的好处,很多人只体会到了“能 await”,但没注意到它真正的内涵:响应体是一个可读流,而不是一次性到位的字符串。
response.body是一个ReadableStream,理论上你可以边下载边解析,不必等整个响应体全部落地方能处理。这在大文件下载场景里特别有用。比如我要展示一个下载进度,传统 XHR 有progress事件,而 Fetch 需要自己读流:
const res = await fetch('/api/large-file'); const reader = res.body.getReader(); const decoder = new TextDecoder('utf-8'); let content = ''; while (true) { const { done, value } = await reader.read(); if (done) break; content += decoder.decode(value, { stream: true }); // 这里可以更新进度状态 updateProgress(content.length); }这种流式处理方式,放在流式对话、日志实时推送这类场景里是真正能落地的东西。很多人在学习阶段没接触过ReadableStream,于是碰到 SSR 流式渲染、长连接消息推送时,第一反应还是去绕路用 WebSocket,其实是没必要。
另外一个经常被忽略的点是,Fetch 在浏览器和 Node.js 环境里已经实现了标准统一。Node 18 之后原生支持全局fetch,意味着前后端可以共享同一套请求逻辑和类型定义,这对全栈项目的工程化是省心的事。
2. 最核心的用法:从基础参数到请求头设置
2.1 最小可用的 Fetch 请求怎么写
一般教程会直接从fetch(url).then(res => res.json())讲起,这个例子本身没问题,但会让初学者产生一个错觉:fetch返回的 Response 对象里已经装好了数据。实际情况是,Response 对象里装的是“响应元信息 + 响应体流”,你必须要调用对应的方法把流读出来。
最常见的三种读取方法是res.json()、res.text()、res.blob()。它们都会消费掉响应体流,一旦被消费过一次,第二次调用就会抛错。这个限制隐藏了一个实战点:如果你既想看 JSON 结构,又想把原始文本缓存一份,得先用res.clone()复制出一个副本再分别读取。
const res = await fetch('/api/user'); const clone = res.clone(); const jsonData = await res.json(); const rawText = await clone.text();clone()的作用是允许对同一个响应做多次消费,但仍然要求“在响应体流没有被读过之前”才能调用。如果你已经调过一次res.json(),再想res.clone()就会抛异常,这个顺序一定不能搞反。
关于 MIME 类型,很多后端接口返回的Content-Type是乱写的,可能接口实际返回 JSON,但响应头里写的是text/plain。此时res.json()依然能正常工作,因为它只是把文本按 JSON 规则解析。反过来,如果响应体根本不是合法 JSON,res.json()会直接 reject。所以在舒服的环境里跑通接口不代表稳了,真实接口的返回格式经常有意外。
2.2 请求头、请求体与响应对象的细节
第二个常见误区是把fetch的配置选项理解成“功能少”,实际上只是参数结构比较扁平。整个请求配置写在一个对象里,最核心的是method、headers、body:
const res = await fetch('/api/order/submit', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'Authorization': 'Bearer ' + localStorage.getItem('token') }, body: JSON.stringify({ skuId: 10086, quantity: 2, remark: '包装要求气泡膜' }) });这里有几个需要特别说明的细节。第一个是,设置Content-Type并不代表浏览器会自动帮你把对象转成 JSON 字符串,你依然要手动JSON.stringify,否则请求体会变成[object Object]这样的无效内容,后端解析直接报错。很多新人第一次调接口失败,就是栽在忘记 stringify 上。
第二个细节是,如果你提交的是FormData,不要手动设置Content-Type。浏览器会根据FormData自动生成带 boundary 的multipart/form-data头。一旦你手动写死常见的application/x-www-form-urlencoded,后端解析 multipart 时大概率会失败,而且这个错误非常隐蔽,因为接口可能偶尔能通,但文件字段永远拿不到。
const formData = new FormData(); formData.append('file', fileInput.files[0]); formData.append('name', '测试文件'); const res = await fetch('/api/upload', { method: 'POST', // 不要手动设置 Content-Type body: formData });第三点是关于res.headers。这个对象是只读的,而且它的get()方法对响应头名称不区分大小写,获取方式是res.headers.get('content-type')。在判断响应类型时,我通常不会直接依赖content-type,因为它可能缺失或写错。更靠谱的判断方式是优先尝试res.json(),如果解析失败再回退到文本内容;或者根据业务约定统一约定 JSON 返回。
3. 实战中的高级写法:超时、并发与取消请求
3.1 用 AbortController 解决取消请求与竞态问题
Fetch 默认没有“取消这次请求”的功能,每次请求一旦发出,只能等它自然结束。要实现取消,得配合AbortController使用。这个 API 的原理并不复杂:外部持有一个AbortController实例,把它身上的signal传给fetch,然后在合适的时机调用controller.abort(),浏览器就会中断这次请求并让 Promise reject。
const controller = new AbortController(); const resPromise = fetch('/api/slow-query', { signal: controller.signal }); // 3 秒后取消请求 setTimeout(() => controller.abort(), 3000);最经典的实战场景是搜索框防抖配合竞态处理。用户在输入框里连续输入字母,前端不断向后端发关键词搜索。如果每次都发,前一个请求可能比后一个请求晚回来,那么用户看到的结果就会闪回旧内容。正确做法是:在发出新请求前,先 abort 掉上一次未完成的请求。
let currentController = null; async function search(keyword) { // 取消上一次请求 if (currentController) { currentController.abort(); } currentController = new AbortController(); try { const res = await fetch('/api/search?kw=' + keyword, { signal: currentController.signal }); const data = await res.json(); renderResults(data); } catch (err) { // 如果是主动取消导致的中止错误,不提示用户 if (err.name === 'AbortError') return; // 其他错误正常处理 showToast('搜索失败'); } }这里有个实战经验:abort()后返回的 rejected Promise,错误对象的name属性固定是AbortError。在处理错误时,要优先判断这个字段,避免把“用户主动取消”误报成“请求失败”弹错误提示。
另外,AbortController并不局限在“取消单个请求”上。同一种signal可以同时传给多个请求,假如页面上有多个接口需要联动取消(比如退出登录时把所有 pending 请求全停掉),只要共享同一个 controller 就能做到。
3.2 请求超时控制的两种实用思路
Fetch 原生没有直接提供timeout参数,这是它比 axios 缺少的一个开箱即用能力。要模拟超时,有两种常用思路。
第一种思路是用AbortController作为“定时炸弹”:
async function fetchWithTimeout(url, options = {}, timeout = 8000) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); try { const res = await fetch(url, { ...options, signal: controller.signal }); clearTimeout(timer); return res; } catch (err) { clearTimeout(timer); if (err.name === 'AbortError') { throw new Error(`请求超时(超过 ${timeout}ms)`); } throw err; } }第二种思路是“Promise 竞速”:把真实请求和一个定时器放进Promise.race里,哪个先完成就取哪个的结果。
function fetchWithTimeoutByRace(url, options = {}, timeout = 8000) { return Promise.race([ fetch(url, options), new Promise((_, reject) => { setTimeout(() => reject(new Error('请求超时')), timeout); }) ]); }这两种方法看起来都能“超时”,但实际体验差距很大。AbortController方案是真正的“取消了网络请求”,后面不会再浪费网络资源;而Promise.race方案只是“先返回了超时错误”,底层请求仍然可能在运行,后续还可能触发 unexpected 的 setState,甚至因为结果晚到覆盖新数据。
所以我建议在实际业务里一律用AbortController方案。Promise.race只适合那种“只关心最早结果、不在乎底层请求死活”的场景,一旦涉及 UI 状态更新,必须有取消动作才安全。
3.3 Promise.all 处理并发请求时的注意事项
并发请求是前端非常常见的需求,比如详情页需要同时拉用户信息、订单列表、商品推荐三个接口。一个传统做法是写三个await串行执行,请求耗时会累加;更好的做法是Promise.all并行:
const [userInfo, orderList, recommendList] = await Promise.all([ fetch('/api/user/info').then(r => r.json()), fetch('/api/user/orders').then(r => r.json()), fetch('/api/recommend').then(r => r.json()) ]);这样三个请求同时发出,总耗时取决于最慢的那个接口。节奏上比串行快很多,特别适合移动端弱网环境。
但Promise.all有一个致命问题:只要其中一个请求失败,整个 Promise 都会 reject,其他成功结果也会全部丢失。这在某些页面里是不可接受的。比如订单列表挂了,但用户信息是正常的,如果整体 reject,页面可能一片空白。
针对这种情况,工程上的做法是先把每个请求包一层“归一化”逻辑,把异常结果转换成“失败标记”,再去Promise.all:
const safeRequest = async (url) => { try { const res = await fetch(url); if (res.ok) { const data = await res.json(); return { ok: true, data }; } return { ok: false, status: res.status }; } catch (err) { return { ok: false, error: err }; } }; const [userInfo, orderList] = await Promise.all([ safeRequest('/api/user/info'), safeRequest('/api/user/orders') ]);这样每个请求的结果都稳定地“返回成功”,只是内部多了ok字段。这种写法也让我在处理局部加载失败时能精准定位到具体是哪一块数据出问题,而不是让整个页面崩掉。配合加载态分开管理,用户体验会好很多。
另外要注意浏览器对同源请求有并发连接数限制,超过一定数量后会排队等待。之前在一个报表页面踩过坑,一次性发了 10 多个接口请求,结果其中几个响应被浏览器延迟了差不多半秒。后来拆成两批用Promise.all处理,或者通过合并接口减少请求次数,体感明显变快。
4. 常见错误处理与排查心得(避坑实录)
4.1 网络错误与 HTTP 状态码错误必须分开看待
很多刚用 Fetch 的朋友都有一个困惑:为什么fetch拿到 404 或者 500 时不会走 catch?这是 Fetch 和 axios 最大的行为差异之一。
fetch的 Promise 只在以下情况 reject:
- 网络断连
- 域名解析失败
- 请求被 CORS 阻断
- 请求被主动 abort
而 404、500、403 这些 HTTP 状态码,对fetch来说只是“响应成功返回”了,Promise 走的是 fulfilled 分支。这时候你拿到的Response对象里,status是404,ok属性是false,但不会触发catch。
所以正确的处理姿势是:
const res = await fetch('/api/xxx'); if (!res.ok) { const errMsg = `请求失败,状态码 ${res.status}`; // 这里可以根据 status 做提示,但不要 throw 到 catch 当成“网络错误” throw new BusinessError(errMsg, res.status); } const data = await res.json();我见过不少项目直接把fetch当作“状态码不对就 catch”的工具,结果后端返回 400 时,前端捕获到的错误信息是“Failed to fetch”,完全无法定位问题。这是 Fetch 使用中最容易踩的坑,没有之一。
另外,CORS 错误在fetch里也表现得很迷。前端代码看到的错误不是明确的“跨域被拦截”,而是一个看起来像网络问题的TypeError: Failed to fetch。这时候如果前端和接口服务不在同一域名下,第一反应应该是检查响应头里有没有Access-Control-Allow-Origin,而不是纠结前端代码哪里写错了。
4.2 封装一个适配业务场景的 fetch 工具函数
基于前面的实战经验,我在项目中通常会给fetch包一层薄薄的封装,职责是处理超时、统一错误格式、自动携带请求头、以及把各种响应格式规范化。不封装太厚,因为封得越厚,Debug 时越难越过抽象层定位问题。
const request = async (url, options = {}) => { const controller = new AbortController(); const timeout = options.timeout || 10000; const timer = setTimeout(() => controller.abort(), timeout); const defaultHeaders = { 'Content-Type': 'application/json', ...(localStorage.getItem('token') ? { Authorization: `Bearer ${localStorage.getItem('token')}` } : {}) }; try { const res = await fetch(url, { ...options, headers: { ...defaultHeaders, ...(options.headers || {}) }, signal: controller.signal }); if (!res.ok) { const error = new Error(`HTTP ${res.status}`); error.status = res.status; // 尝试读取后端返回的业务错误信息 try { const errorBody = await res.json(); error.message = errorBody.message || error.message; error.code = errorBody.code; } catch (_) { // 忽略响应体解析失败 } throw error; } const contentType = res.headers.get('content-type') || ''; if (contentType.includes('application/json')) { return await res.json(); } const text = await res.text(); return text ? JSON.parse(text) : null; } catch (err) { if (err.name === 'AbortError') { throw new Error(`请求超时(${timeout}ms)`); } throw err; } finally { clearTimeout(timer); } };这个封装的几个设计和实际体验值得展开说一下。超时时间我默认给 10 秒,超过就 abort。内部 catch 里把AbortError翻译成“请求超时”的错误文案,这样调用方不用关心底层 AbortController 的存在。请求头自动带Authorization,同时允许调用方按需覆盖。
这里有个细节:默认设置了Content-Type: application/json,但如果调用方要传FormData或Blob,需要显式把 headers 里的Content-Type覆盖掉。我在封装里故意保留了调用方覆盖的能力,而不是死板地写死逻辑。
这个封装函数的好处是,所有业务请求都走同一个错误处理流程,不会出现“这个接口自己写了个 try catch,那个接口没写”的不一致。团队协作时,新人接手新页面,只需要写业务逻辑,不用重复造请求层的轮子。
4.3 请求状态与浏览器并发限制的排查手段
Fetch 请求出问题时,除了看业务代码的 console,更有效的路径是打开浏览器 DevTools 的Network 面板。重点看几个地方:请求是否真的发送出去、请求耗时分布、响应状态码、以及是否有 pending 状态卡住。
之前排查一个下载功能卡死的问题,发现请求在 Network 面板里一直处于 pending。最终定位不是后端接口问题,而是 router 切换页面后,组件已经卸载,但请求还在跑,且组件卸载时没有取消请求。这种问题的本质是生命周期与异步请求不同步,不一定是 Fetch 本身的锅,而是使用方式需要调整。
解决办法是在组件卸载时调用 abort:
useEffect(() => { const controller = new AbortController(); async function loadData() { try { const res = await fetch('/api/data', { signal: controller.signal }); // 更新UI } catch (err) { if (err.name === 'AbortError') return; // 真实错误处理 } } loadData(); return () => controller.abort(); }, []);另一个排查重点是浏览器最大连接数。同域名下浏览器一般只允许少量并发连接,超过的会在 Queueing 阶段等待。之前在某个老页面上,一次性塞了 20 个接口请求,Network 面板里至少有五六个在排队。优化思路是拆接口或者分批发送,用Promise.all每批 4 个,实测下来页面加载时间从 3 秒左右降到了 1.2 秒。
最后聊一下很多朋友会问的:业务里已经用了 axios,还需要关心 Fetch 吗?我的看法是,axios 的底层在浏览器环境本质就是 XHR,它在封装体验上确实更便利。但 Fetch 是标准原生 API,适合需要流式读取、流式上传、最小依赖的场景,也适合在 Node 环境里直接复用。更现实的一点是,很多现代框架和工具链内部就是基于 Fetch 实现的,如果你对 Fetch 的行为边界不熟,遇到底层报错时很容易两眼一抹黑。
根据个人经验,新项目里如果只是常规接口调用,axios 和 Fetch 都行;但只要涉及大文件下载、请求取消、流式日志这种偏底层能力,Fetch 的表现会更干净利落。如果你打算把 Fetch 作为团队默认方案,建议先把这个封装函数落到公共模块里,再约定统一的返回结构和错误码规范。后续无论怎么迭代,请求层都能稳定兜底。