资讯详情

资讯详情

Fetch实战:从XMLHttpRequest对比到取消、超时与封装

老项目里翻出一段当年用 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它的返回值是一个PromiseResponse。这个设计带来的好处很多人只体会到了“能 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 规则解析。反过来如果响应体根本不是合法 JSONres.json()会直接 reject。所以在舒服的环境里跑通接口不代表稳了真实接口的返回格式经常有意外。2.2 请求头、请求体与响应对象的细节第二个常见误区是把fetch的配置选项理解成“功能少”实际上只是参数结构比较扁平。整个请求配置写在一个对象里最核心的是method、headers、bodyconst 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.allconst 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是404ok属性是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 本身的锅而是使用方式需要调整。解决办法是在组件卸载时调用 abortuseEffect(() { 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 作为团队默认方案建议先把这个封装函数落到公共模块里再约定统一的返回结构和错误码规范。后续无论怎么迭代请求层都能稳定兜底。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →