JavaScript Fetch请求的5大陷阱与统一封装实践
1. Fetch看着简单坑起来真要命先说个我自己的翻车案例。前阵子在做一个某跨平台系统的管理后台联调登录接口后端返回400字段错误提示也给了页面就该弹窗提示。结果倒好控制台连个红色报错都没有页面像死了一样用户点登录没反应后端日志显示请求其实发出去了也返回了就是前端像没收到一样。排查半天问题就出在fetch的then里少写了一段判断。这可不是偶然踩坑我把这段经历整理出来发现身边很多开发者——包括一些写了三五年前端的老手——对fetch的认知都停在“能用”层面真要处理边界情况照样翻车。JavaScript Fetch请求本质上是浏览器原生提供的HTTP请求能力替代老旧的XMLHttpRequestXHR用法更简洁、返回Promise、支持流式读取看着人畜无害。但正因为它“看着简单”大家反而忽略了很多底层的行为差异。这篇东西不打算讲fetch的基础用法那些文档里都有。我想聊的是日常开发中实打实遇到的5个致命陷阱每一个都可能导致线上故障、用户数据丢失或者白屏而绝大多数人根本没意识到。这篇内容适合谁看写过fetch但没深入研究过它的前端开发、刚转行做前端的新人、以及负责接口联调和性能优化的全栈工程师。看完你会发现之前很多“玄学”问题其实都是可解释、可规避的。我尽量把每一步的“为什么”也讲清楚不是光给结论毕竟搞懂原理才能真的记住。2. 陷阱解析5个最容易让人翻车的细节2.1 HTTP错误码不会自动抛异常这是最经典、也最容易被误解的一个点。很多新手甚至不少“老手”理所当然地认为fetch返回404、500这种非2xx状态码时Promise应该走reject也就是进catch。实际上恰恰相反只要服务器有响应不管状态码是200还是502fetch的Promise都会正常resolve。我见过有人写这样的代码try { const res await fetch(/api/user/info); const data await res.json(); // 直接用 data 渲染页面 renderUser(data); } catch (err) { showErrorToast(请求失败); }这段代码的问题在于如果接口返回500res.json()照样能解析很多网关会把错误信息以JSON形式返回renderUser拿到的是一个错误对象页面直接渲染异常或者白屏。而catch根本不会触发因为Promise根本没reject。用户看到的就是一个无法解释的空白页而且控制台干干净净啥报错也没有。正确做法是主动检查res.ok或res.statusconst res await fetch(/api/user/info); if (!res.ok) { // 这里可以进一步区分 401、403、500 等不同场景 throw new Error(HTTP ${res.status}: ${res.statusText}); } const data await res.json();用生活化的比喻来说fetch就像你叫了个外卖只要外卖员敲了门就算敲完门递给你一袋垃圾也算是“成功送达”。你总不能指望平台自动帮你把垃圾辨认出来扔掉得自己看一眼袋子里的东西。实操建议封装一个统一请求函数在入口处统一检查res.ok不通过就直接抛错业务代码里只管try/catch这样每个接口就不用写重复的状态判断了。如果你用的是TypeScript还能顺便把返回类型约束好省得后面联调时满屏any。2.2 默认不带Cookie跨域场景更加明显第二个坑跟鉴权有关。默认情况下fetch不会携带任何Cookie只有显式设置credentials时才会带。这跟XHR的默认行为不一样XHR在同域下默认会带Cookie的很多人习惯性地以为fetch也一样。举个例子你做了一个前后端分离的项目前端跑在某个开发端口后端接口在另一个域名下。后端登录后Set-Cookie到浏览器下次前端fetch请求这个接口想着“Cookie应该自动带上吧”结果后端收到的请求里啥都没有直接判定未登录返回401。解决方式很简单// 请求中显式带上凭证 fetch(/api/data, { credentials: same-origin // 同域时携带 }); // 跨域时用 include fetch(https://api.example.com/data, { credentials: include });需要注意的是跨域场景下credentials: include不是前端单方面设置就行的后端也必须配合响应头里必须有Access-Control-Allow-Credentials: true而且Access-Control-Allow-Origin不能是通配符*必须指定明确的域名。后端如果不配置浏览器会直接把响应拦截掉控制台报CORS错误接口看着“没通”其实是跨域策略把结果吞了。这里有个开发中的小经验如果你用代理模式做开发比如在某构建工具的devServer里配置代理转发前端发请求是同域的代理服务器帮你转发到真实接口。这种情况下credentials: same-origin就够了不用include。如果你非要用include代理也要跟着配置反而容易扯出更多问题。2.3 响应体只能读一次重读就报错这个坑藏得深平时不容易触发但一触发就是让人摸不着头脑的报错。fetch的响应体是一个ReadableStream流一旦被读取就不能再读了。什么意思const res await fetch(/api/blob/data); const text await res.text(); // 这里想再拿一次数据比如同时要用 JSON 和 text const json await res.json(); // 直接报错Body is unusable这类场景其实挺常见。比如你想先看一下响应内容是不是JSON格式如果不是就当作纯文本处理——这就要先读一遍text再做分支。但读取之后流就“耗尽”了无法第二次使用。我们的处理方案一般有两种方案一先用clone()复制一份响应再分别读取const res await fetch(/api/data); const resClone res.clone(); const text await resClone.text(); const json await res.json();方案二推荐干脆别做两次读取。先读text再用JSON.parse去解析如果解析失败就走文本逻辑const res await fetch(/api/data); const text await res.text(); try { const json JSON.parse(text); // 当作 JSON 处理 } catch { // 当作纯文本处理 }方案二更轻量方案一在响应体很大的时候会额外占用内存clone会缓存整个流内容自己权衡。还有一个细节如果你中途调用了cancel()中止了请求响应体也会被锁死这个后面讲取消请求时再说。2.4 没有超时机制接口卡住就是卡住XHR有个timeout属性可以直接设置超时时间但是fetch原生不提供这个功能。你没听错fetch没有内置超时控制如果接口一直pending你的页面就一直等着没有自动中断这一说。我曾经遇到过一个线上事故后端某个报表接口因为数据库死锁一直不返回前端就一直转圈用户刷新页面又发一次请求所有请求都堵在那里最后浏览器把页面卡到直接白屏。如果当时有超时控制至少能提示用户“加载失败请重试”而不是无限等待。实现超时的最常见方案是用AbortController配合setTimeoutconst controller new AbortController(); const timeoutId setTimeout(() controller.abort(), 8000); try { const res await fetch(/api/order/export, { signal: controller.signal }); clearTimeout(timeoutId); // 处理响应 } catch (err) { if (err.name AbortError) { console.error(请求超时已中止); } else { console.error(其他错误, err); } }如果你追求更省事也可以直接用一个带超时功能的封装函数。有一种做法是Promise.race把fetch的Promise和一个延时reject的Promise放在一起赛跑。但这个方案有个隐蔽问题Promise.race赢了不代表另一个Promise的底层请求被取消请求可能还在服务器上跑着白费流量。AbortController的优势在于它是真正的中止信号底层请求会被取消同时浏览器控制台也不会出现红色的网络错误干扰排查。AbortController不止能用来做超时还能配合用户操作做“取消请求”// 用户点击取消按钮时 cancelBtn.onclick () controller.abort();一个controller可以传多个请求signal一次abort就能取消一组请求这个特性在某些“切换页面时取消进行中的请求”场景下非常好用。2.5 盲目用res.json()解析响应第五个坑相对隐蔽但责任也最大。很多人拿到响应就习惯性。json()默认认为后端一定会返回JSON。但现实世界里接口可能返回204 No Content空的可能是纯文本错误信息可能是HTML错误页面甚至可能是空字符串。这些场景下强行。json()就会抛异常。比如const res await fetch(/api/delete/user/42, { method: DELETE }); // 后端删除成功返回 204没有 body const data await res.json(); // 直接进 catch明明业务是成功的204这种情况我在真实项目中遇到过不止一次。后端同学觉得“删除操作不需要返回内容返回204最规范”前端同学却默认要解析JSON然后报错。最后定位半天谁都觉得自己没错其实就是缺了一个“按需解析”的逻辑。稳妥的解析姿势是这样的const res await fetch(/api/delete/user/42, { method: DELETE }); if (res.status 204) { // 没有 body直接处理成功逻辑 return; } const text await res.text(); if (!text) { // 空 body当作成功处理 return; } // 尝试解析 JSON失败就降级为纯文本 let data; try { data JSON.parse(text); } catch { data text; }另一种常见的“盲目解析”跟Content-Type有关。有些后端接口在报错时返回的是text/html页面比如网关超时页面你拿到后照样。json()抛错然后只能看到一句“Unexpected token in JSON at position 0”——你根本不知道后端到底发生了什么。如果我们先读text再尝试JSON.parse至少可以把那段HTML打印出来一眼就能看出是网关超时还是应用报错。经验之谈所有接口的响应解析都走同一个封装函数统一判断状态码、统一解析body、统一抛错格式业务层只接收解析好的数据或错误对象。这样即使后端犯懒、不规范前端也不会动不动就“未捕获异常”堆在你的监控后台里。3. 正确打开方式一套能直接用的fetch封装既然上面讲了这么多坑我直接给一套我日常工作里用着的封装思路。不追求大而全但该有的都有统一状态码检查、超时控制、错误抛出、JSON自动解析。async function request(url, options {}) { const { timeout 8000, params, ...rest } options; // 拼接 query string let finalUrl url; if (params) { const qs new URLSearchParams(params).toString(); finalUrl (finalUrl.includes(?) ? : ?) qs; } const controller new AbortController(); const timeoutId setTimeout(() controller.abort(), timeout); try { const res await fetch(finalUrl, { ...rest, credentials: same-origin, signal: controller.signal, headers: { Content-Type: application/json, ...rest.headers, }, }); clearTimeout(timeoutId); // 统一处理 HTTP 错误状态 if (!res.ok) { const errText await res.text().catch(() ); throw new Error(HTTP ${res.status} - ${errText || res.statusText}); } // 204 No Content 直接返回 null if (res.status 204) { return null; } const text await res.text(); if (!text) return null; // 尝试 JSON 解析失败返回纯文本 try { return JSON.parse(text); } catch { return text; } } catch (err) { clearTimeout(timeoutId); if (err.name AbortError) { throw new Error(请求超时或已被取消); } throw err; } } // 用法 const user await request(/api/user/info); const list await request(/api/list, { method: POST, body: JSON.stringify({ page: 1 }), });几个设计上的解释credentials统一设成same-origin跨域接口单独传include覆盖避免默认不带Cookie的坑。超时的默认值8秒不是拍脑袋定的常规管理后台的接口响应速度超过8秒绝大多数用户已经没耐心了。具体项目可以自己调建议不要低于5秒太短会把慢接口统统截断。错误信息里把后端返回的文本拼进去排查问题的时候非常有用不然你只知道“失败”不知道后端说了什么。返回值统一处理有JSON就返回JSON没JSON就返回text没有body就返回null。调用方不需要关心底层细节。这个封装不能说多完美但应对绝大多数业务场景是够用的。你完全可以在这个基础上继续扩展比如统一携带请求头、接入监控打点、自动刷新Token重试等等。4. 实战问题与排查速查表做前端这些年我把fetch相关的疑难问题整理成了一个排查清单遇到类似症状可以先对照一下。现象常见原因处理方案接口返回500但页面没有报错表现像“静默失败”没检查res.ok拿错误响应当正常数据处理封装统一的ok判断非2xx主动抛错登录后请求接口仍提示未登录fetch默认不带Cookie设置credentials: include或same-origin并确认后端CORS配置允许同时读取text()和json()报错“Body is unusable”响应体只能读一次用res.clone()或者只读一次text后自行解析接口一直pending页面无限转圈fetch没有原生超时机制用AbortController setTimeout做超时中断204 No Content时调json()报错没有按状态码区分响应体先判断204直接返回再读text尝试解析跨域请求正常发出但浏览器拦截后端CORS配置不对或者credentials和通配符*冲突检查Access-Control-Allow-Origin、Allow-Credentials配置请求取消后继续处理响应逻辑忽略AbortErrorcatch里判断err.name AbortError做分流处理网关返回HTML错误页json()解析报错错误响应不是JSON格式先读text再尝试JSON.parse解析失败降级为文本重复发起同一请求导致性能问题没有做请求去重/竞态处理用AbortController取消前一个请求或维护请求map做去重这里重点说一个“竞态问题”当用户快速切换页面Tab时上一次请求可能比下一次请求晚返回页面最终渲染的是旧数据。这个用fetch也能解决——在切换时调用controller.abort()取消旧的请求并且配合一个“最新请求id”机制let requestId 0; async function loadData() { const currentId requestId; const controller new AbortController(); try { const data await request(/api/data, { signal: controller.signal }); if (currentId requestId) { render(data); } } catch (err) { if (err.name AbortError) return; // 处理错误 } // 如果切换到别的页面这里可以把controller预留出来用于手动取消 }这个“id比对”的思路在搜索框实时查询、Tab切换、分页场景下都很实用。核心就一句话只有最新一次请求的返回结果才有资格渲染页面。还有一个常见的疑问fetch是不是只能用在浏览器里其实在Node.js环境新版本中也内置了fetch但注意事项略有差异。比如Node环境里credentials的处理方式和浏览器不同AbortController的兼容性也有差异如果你在服务端写爬虫或代理建议还是用专门的HTTP客户端库功能更成熟。这个展开讲又是一篇东西就不在这里细说了。5. 补充几个容易被忽略的小细节除了上面五个大坑还有几个小细节在日常开发中也很容易踩到顺手一起说了。第一个是关于“错误信息可读性”的问题。fetch抛出来的错误在浏览器控制台往往不太直观尤其是网络中断时只会显示“Failed to fetch”。这句话新手看到完全是懵的。生产环境下建议把所有fetch错误统一转成带有业务语义的信息比如“网络异常请检查网络后重试”同时保留原始错误在console里打出来方便联调。我做监控告警的时候最怕的就是前端报错信息啥都没带上丢一个“Failed to fetch”过来连哪个接口、什么参数都不知道。所以封装函数里错误对象至少要带上url和methodconst reqError new Error([${method}] ${url} 请求失败); reqError.url url; reqError.method method; reqError.status res.status;第二个细节是“响应头处理”。fetch可以读取响应头但跨域场景下没有后端CORS暴露的情况下你能读取到的响应头是受限的只有Cache-Control、Content-Language、Content-Type、Expires、Last-Modified、Pragma几个基础字段。要读取自定义响应头比如X-Token或分页信息后端必须显式暴露Access-Control-Expose-Headers: X-Token, X-Page-Count不然你拿到的是一个空值却又不知道为什么会空。这个我在对接某个第三方数据服务时踩过当时查了半天最后发现是响应头没暴露。记住一句话跨域时响应头不是你想读就能读的后端不给你看的东西你永远看不到。第三个细节跟“PWA离线缓存”有关。如果你做的是需要离线可用的小程序或PWAfetch可以配合Cache API做请求缓存而且能区分“网络优先”还是“缓存优先”。但要注意cache选项控制的是HTTP缓存跟Service Worker里的Cache API是两回事别把两者混了。fetch的cache的可用值包括default、no-store、reload、no-cache、force-cache、only-if-cached不同模式的行为差异也比较大。平时开发用默认就好只有在做复杂缓存策略时才需要动它。6. 写在最后的一点体会做前端这几年fetch给我最大的感觉就是“外松内紧”表面API设计得简洁到让人轻视但底层行为细节多得能写一本书。很多线上事故说到底是大家对底层机制缺乏敬畏默认一切都很美好。如果你问我什么样的前端才算真正理解fetch我会说不是会写fetch(url).then(..)而是清楚知道请求发出后每一步会发生什么——状态码代表什么、响应体能读几次、Cookie什么时候会被带上、请求什么时候会一直悬挂、取消信号怎么传递。把这些边界吃透你写出来的代码才能在真实复杂的网络环境里站得住。我给团队做Code Review时有一条不成文的规定所有fetch调用不许散落在业务代码里必须走统一封装的请求层。这条规定看起来死板但它强行把上面所有坑的规避逻辑收敛到了一处改动一处全站生效。你也可以试试这个做法——先把这些坑记牢再动手改一个统一封装以后接口联调和排障的效率会明显不一样。