1. Fetch看着简单,坑起来真要命
先说个我自己的翻车案例。前阵子在做一个某跨平台系统的管理后台,联调登录接口,后端返回400,字段错误提示也给了,页面就该弹窗提示。结果倒好,控制台连个红色报错都没有,页面像死了一样,用户点登录没反应,后端日志显示请求其实发出去了,也返回了,就是前端像没收到一样。排查半天,问题就出在fetch的then里少写了一段判断。这可不是偶然踩坑,我把这段经历整理出来,发现身边很多开发者——包括一些写了三五年前端的老手——对fetch的认知都停在“能用”层面,真要处理边界情况,照样翻车。
JavaScript Fetch请求,本质上是浏览器原生提供的HTTP请求能力,替代老旧的XMLHttpRequest(XHR),用法更简洁、返回Promise、支持流式读取,看着人畜无害。但正因为它“看着简单”,大家反而忽略了很多底层的行为差异。这篇东西不打算讲fetch的基础用法,那些文档里都有。我想聊的是日常开发中实打实遇到的5个致命陷阱,每一个都可能导致线上故障、用户数据丢失或者白屏,而绝大多数人根本没意识到。
这篇内容适合谁看?写过fetch但没深入研究过它的前端开发、刚转行做前端的新人、以及负责接口联调和性能优化的全栈工程师。看完你会发现,之前很多“玄学”问题其实都是可解释、可规避的。我尽量把每一步的“为什么”也讲清楚,不是光给结论,毕竟搞懂原理才能真的记住。
2. 陷阱解析:5个最容易让人翻车的细节
2.1 HTTP错误码不会自动抛异常
这是最经典、也最容易被误解的一个点。很多新手(甚至不少“老手”)理所当然地认为,fetch返回404、500这种非2xx状态码时,Promise应该走reject,也就是进catch。实际上恰恰相反:只要服务器有响应,不管状态码是200还是502,fetch的Promise都会正常resolve。
我见过有人写这样的代码:
try { const res = await fetch('/api/user/info'); const data = await res.json(); // 直接用 data 渲染页面 renderUser(data); } catch (err) { showErrorToast('请求失败'); }这段代码的问题在于:如果接口返回500,res.json()照样能解析(很多网关会把错误信息以JSON形式返回),renderUser拿到的是一个错误对象,页面直接渲染异常或者白屏。而catch根本不会触发,因为Promise根本没reject。用户看到的就是一个无法解释的空白页,而且控制台干干净净,啥报错也没有。
正确做法是主动检查res.ok或res.status:
const 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,配合setTimeout:
const 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配置 |
| 请求取消后继续处理响应逻辑 | 忽略AbortError | catch里判断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和method:
const 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离线缓存”有关。如果你做的是需要离线可用的小程序或PWA,fetch可以配合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调用不许散落在业务代码里,必须走统一封装的请求层。这条规定看起来死板,但它强行把上面所有坑的规避逻辑收敛到了一处,改动一处,全站生效。你也可以试试这个做法——先把这些坑记牢,再动手改一个统一封装,以后接口联调和排障的效率会明显不一样。