1. 从一个真实翻车现场说起:为什么我换掉了XMLHttpRequest先说一件小事。前阵子帮人维护一个老系统,列表页有个导出功能,前同事用XMLHttpRequest写了一大段逻辑:监听onreadystatechange、判断readyState === 4、再判断status、还要处理responseType为blob时兼容不同浏览器的坑。光初始化就三十多行,我改一个上传进度条,翻了半天才找到upload.onprogress在哪。那会儿我就想,同样是发请求,如果当初用Fetch,这段代码至少能压缩一半。后来我把那段老代码重构掉,顺手把项目里所有新增的网络请求都切到了Fetch。做这个决定之前我其实犹豫过,因为当时项目里已经有人在用axios,团队内部也有争议。但写完几个模块之后,我自己的结论很明确:Fetch是浏览器原生的能力,不需要引第三方库,API设计更贴近现代JavaScript,Promise语法天然契合async/await,日常八九成的请求场景它都覆盖了。这篇文章不是来吹Fetch天下第一的,恰恰相反,我想把一个真实使用者在项目里反复踩坑之后积累的经验完整写出来——什么时候该用Fetch、用的时候要注意什么、遇到404为什么不会走catch、怎么实现超时、怎么取消请求、怎么读流式接口,以及为什么很多人用Fetch用的很痛苦只是因为没做封装。读完你大概率能直接把Fetch用出axios七成的爽感,还不用背那几十KB的依赖包。如果你是个刚接触前端的新手,这篇文章可以当Fetch使用手册看;如果你已经在项目里写过fetch,那你应该能在我写的“坑”里找到共鸣——我们大概率在同一个地方摔过。核心关键词就一个:Fetch的使用。我尽量不堆术语,能用大白话解释的就用大白话解释,但涉及原理的部分一点不省,毕竟后端返回数据的时候可不管前端爽不爽。2. fetch不是axios平替:先弄懂请求与响应背后的流程很多教程一上来就给你看fetch(url).then(res => res.json()),读者跟着跑通一个GET接口就以为学会了,结果一到项目里就懵:为什么catch没抓到后端返回的500?为什么读完了响应想再取一次res.text()却是空的?为什么跨域请求莫名其妙丢了Cookie?这些问题的根子在于,没有理解fetch的请求和响应到底是怎么在背后流转的。2.1 一次请求的完整生命周期fetch()接收两个参数:第一个是资源地址,第二个是可选的配置对象options。内部做的事情其实可以拆成四个阶段:构造请求:浏览器把传进来的URL和配置组装成一个Request对象,比如method、headers、body、credentials都会在这个时候被解析。发起网络请求:浏览器按配置发出请求。这一步和老的XMLHttpRequest不同,fetch是基于Promise的,但它不是“请求发出去了就返回一个成功Promise”,而是“请求结束后返回一个代表响应的Promise”。获取响应元信息:服务器一旦有响应头返回,哪怕是404、500,fetch的Promise都会被resolve,而不是reject。换句话说,HTTP状态码不会让fetch进入catch分支。读取响应体:通过res.json()、res.text()、res.blob()等方法,把响应流读取出来变成实际可用的数据。这里最反直觉的就是第三阶段。我刚用fetch那会儿写过一个接口,后端参数校验失败返回了400,我以为catch能接到,结果页面一直没反应,控制台也没报错,数据区空荡荡的。后来才明白:fetch只在网络层面失败时才reject,比如断网、DNS解析失败、跨域被拦截这种“请求根本没完成”的情况。而只要服务器给了响应,不管状态码是多少,Promise都会resolve。所以判断请求成不成功,最核心的检查点是res.ok这个属性——它是个布尔值,true表示状态码在200-299之间,false就是其他状态。习惯了axios那种“2xx走then、其他走catch”的同学,切到fetch后必须把这个思维扭转过来:fetch的成功是“响应到达”的成功,不是“业务处理成功”的成功。2.2 Body只能读一次:响应体是一次性消费的这个坑几乎是每个人都会踩的。你说“我先读一下res.text()看看返回啥,再调res.json()转成对象”,抱歉,不行。Response对象背后的数据是一股流(stream),就像水龙头里的水,你拿杯子接了一次,水就没了,再拿碗去接只能接到空气。这种现象在代码上的表现是:调用过res.json()之后再调res.text(),会抛异常,或者直接从pending变成rejected。我早期写调试代码就干过这种事,想先看看纯文本再解析JSON,被这个行为卡了半小时。正确的做法是用一种方式读完,要么res.text()、要么res.json()、要么res.blob(),读完就没了。如果确实需要同时看原始文本和解析结果,可以先把res.text()的结果存到变量里,再用JSON.parse()去转:const res = await fetch('/api/user'); const rawText = await res.text(); // 先读文本 const data = JSON.parse(rawText); // 自己解析,而不是再调 res.json()明确了这两点之后,fetch的基本心理模型就建立起来了。它不是axios那种“我给你配置,你把处理好的数据给我”的黑盒,而是更底层的浏览器能力。你现在知道了它到底怎么工作,后面那些高级用法自然不晕。3. 核心操作拆解:从最简单的GET到带请求体的POST别急着看高级玩法,先把日常最高频的几种请求写法吃透。我按真实开发中的出现频率来排:GET、POST JSON、提交表单、上传文件。每一种我都会给可运行的示例和对应的注意事项。3.1 GET请求:默认配置其实够用不给第二个参数直接调fetch,发出去的就是GET请求。比如拉一个用户列表:async function getUserList() { const res = await fetch('https://api.example.com/users'); if (!res.ok) { throw new Error(`请求失败:${res.status}`); } const data = await res.json(); return data; }这里有个小细节值得说:如果你要传查询参数,直接在URL上拼接就好,不需要在配置里额外声明。有些人会用new URLSearchParams()构造字符串,我建议你也这么做,它能自动处理编码问题:const params = new URLSearchParams({ page: 2, keyword: '框架', }); const res = await fetch(`https://api.example.com/users?${params.toString()}`);URLSearchParams会把中文变成%E6%A1%86%E6%9E%B6这种编码结果,比自己手动拼字符串安全得多。前端的URL编码坑非常多,以前有同事把用户输入的原样塞进URL,只要内容里带了&或?,后端拿到的参数就是错的,这种问题非常难排查。3.2 POST JSON:注意Content-Type别漏发JSON格式的POST请求,最典型的三件套是:method改为POST、body传JSON.stringify()的结果、headers里设置Content-Type为application/json。少一个都可能出问题。const res = await fetch('https://api.example.com/login', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ username: 'admin', password: '123456', }), });为什么Content-Type这么重要?因为后端框架(尤其是一些Java系的接口框架)要依靠请求头来判断怎么解析body。如果没设置application/json,而body你又是用JSON字符串传的,好几个框架会直接报“Required request body is missing”,百思不得其解。反过来,如果你不JSON.stringify而是直接传对象,浏览器会自动调用toString(),最终的body会变成[object Object],后端一样收不到正确数据。这里提一嘴:如果你请求的接口跨域,前端能设置的headers是受限制的。自定义请求头需要后端在响应头里放Access-Control-Allow-Headers,并且显式列出允许的字段。这也是为什么很多项目里大家习惯把所有请求头都封装成统一配置,避免每加一个header就去麻烦后端加一次白名单。3.3 提交表单与文件上传:用FormData不用手工处理边界如果项目里遇到表单提交、文件上传,直接上FormData。它会把请求体的格式自动处理成multipart/form-data,并且自动加上正确的Content-Type(带boundary边界符),你完全不碰headers就可以:const formData = new FormData(); formData.append('name', '张三'); formData.append('avatar', fileInput.files[0]); const res = await fetch('https://api.example.com/profile', { method: 'POST', body: formData, });这里有一个很多新人会踩的误区:想当然地给这种请求手动设置Content-Type为multipart/form-data。不要这么做。因为浏览器生成的multipart请求头会包含一个随机的boundary字符串,你手动设置的Content-Type会丢失这个boundary,后端解析文件时直接失败。正确做法是:让浏览器自己生成请求头,你只传body就够了。如果你只是提交纯文本字段,不涉及文件,还有一种更轻量的方案——URLSearchParams配合application/x-www-form-urlencoded格式:const formBody = new URLSearchParams(); formBody.append('title', '我是标题'); formBody.append('content', '我是正文'); const res = await fetch('https://api.example.com/article', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: formBody.toString(), });这个格式和原生HTML表单提交是一致的,有些老的后端接口只认这种格式,灵活切换就行。3.4 处理响应:JSON之外的其他数据类型响应体的读取不止json()一种。根据接口返回内容,还有:方法用途典型场景res.json()解析JSON数据绝大多数接口res.text()读取纯文本接口返回字符串或HTML片段res.blob()读取二进制数据文件下载、图片res.arrayBuffer()读取二进制原始缓冲需要自己解码的场景res.formData()解析表单格式很少用,一般配合特殊服务文件下载是个很好的例子。我做过一个报表导出功能,下载后的Excel要触发浏览器保存,当时就是调接口拿blob,再通过URL.createObjectURL生成临时链接下载:const res = await fetch('https://api.example.com/export'); const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = '报表.xlsx'; a.click(); URL.revokeObjectURL(url);download属性加上createObjectURL的组合是比较标准的浏览器端文件下载方案。需要注意URL.revokeObjectURL()一定要在触发点击之后调用,提前调用会导致下载链接失效,这个顺序我踩过一次,后来养成了习惯先click再revoke。4. 容易被忽略的硬核能力:并发、取消与流式读取基础操作了解后,直接上大多数教程不会认真讲的部分:并发请求、取消请求、读取流式接口。这三项能力是fetch真正区别于普通请求库的核心价值所在,也是实战中最容易被忽略、但用好了体验提升极其明显的三块。4.1 并发请求:Promise.all之外还要考虑失败并发请求最简单的方式就是Promise.all,比如页面同时需要用户信息和配置信息:const [userRes, configRes] = await Promise.all([ fetch('/api/user'), fetch('/api/config'), ]); const user = await userRes.json(); const config = await configRes.json();但Promise.all有个行为你要清楚:如果其中一个请求失败,整个Promise立即reject,其他请求的结果你也拿不到了。真实场景里,用户信息挂了不应该影响配置信息的展示。这时可以换Promise.allSettled,它会等所有请求都结束,然后把每个请求的结果以数组形式给你,每个元素包含status、value或reason:const results = await Promise.allSettled([ fetch('/api/user'), fetch('/api/config'), ]); for (const result of results) { if (result.status === 'fulfilled') { // 这个请求成功,可以解析 } else { // 这个请求失败,单独处理 } }这个API在大多数现代浏览器里都支持了,老项目用之前查一下目标浏览器兼容性。如果你还在维护古董浏览器,可以自己封装一个简易版本,核心思路就是让每个Promise不管成功失败都resolve包一层结果。4.2 取消请求:AbortController是唯一标准方案很多人不知道fetch是可以取消的,而且取消方式非常优雅——AbortController。如果你需要做一个关键词搜索功能,用户每输入一个字就发一个请求,结果上一次还没返回、下一次就发出了,经常出现“后面的先到、前面的后到”导致展示错乱的问题。这时候就需要取消前面的请求。let controller = null; async function search(keyword) { // 如果上一次请求还没完成,先取消它 if (controller) { controller.abort(); } controller = new AbortController(); try { const res = await fetch('/api/search?q=' + keyword, { signal: controller.signal, }); const data = await res.json(); // 渲染结果 } catch (err) { // 注意:取消请求会抛 AbortError,需要单独判断 if (err.name === 'AbortError') { console.log('上一个请求已被取消'); return; } // 其他错误正常处理 } }AbortController就是一个控制器对象,它有abort()方法,以及一个signal属性。你把signal传进fetch配置里,当调用abort()时,浏览器会终止这个请求并抛出一个名为AbortError的异常。这里有个细节非常重要:取消后catch到的错误名字是AbortError,你不做判断就直接弹“网络错误”,用户会看到一闪而过的报错提示。所以捕获异常时一定先判断err.name。还有一个很容易被忽视的点:一个AbortController实例只能服务于一次请求的取消。如果你第二次fetch复用了同一个controller,调用abort()会把上一次的请求一起取消,即使它已经结束了也会引发一些奇怪的副作用。正确用法就是我上面写的,每次请求都新建一个controller。4.3 流式读取:大文件下载、日志和AI接口都能用fetch最被低估的能力是它对**流(Stream)**的原生支持。响应体不是一次性把所有内容倒给你,而是可以一块一块地读。这带来的直接好处是:你可以做下载进度、可以逐行读取日志、可以做类似ChatGPT那种打字机效果的流式回复界面。很多开发者以为只有WebSocket能实现实时效果,其实HTTP的流式响应加fetch就够了。读流的核心API是response.body.getReader()。配合TextDecoder把字节解码成字符串:const res = await fetch('/api/stream'); const reader = res.body.getReader(); const decoder = new TextDecoder(); let receivedLength = 0; const chunks = []; while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); receivedLength += value.length; // 可以在这里把已收到的内容追加到页面上,实现实时展示 const text = decoder.decode(value, { stream: true }); // 比如:el.innerHTML += text; } const fullText = chunks.reduce((acc, chunk) => { return acc + decoder.decode(chunk, { stream: false }); }, '');TextDecoder的{ stream: true }参数是必要的:因为二进制流是按块切的,多字节的中文字符可能被切到两个块里,不开启stream模式的话边界处会乱码。这个细节我是在做流式日志展示的时候发现的,第一版没加这个参数,英文日志正常,中文日志每隔几个字就出一个乱码,排查了半天,加了个参数就好了。如果你只需要下载进度,不用像上面那样手动拼字符串,可以只累计value.length,然后拿它和res.headers.get('Content-Length')算百分比。注意Content-Length是字节数,value.length也是字节数,两者是齐的,不要拿字符串的length(字符数)去比,一个中文就是一个字符但三个字节,算出来的进度就超了。5. 错误处理不能只catch:HTTP状态码和网络异常要分清说实话,fetch的错误处理是它最容易劝退新人的地方,因为把错误全推给catch的人一定会挨揍。我把实践中总结的错误分层模型画成一个“三层错误金字塔”,从上到下:网络层错误:断网、DNS失败、跨域被拦截、请求取消。这类错误fetch会reject,你能在catch里抓到。HTTP层错误:400、401、404、500等。fetch不会reject,需要你检查res.ok或res.status。业务层错误:HTTP状态码返回200,但JSON主体里的code字段是1,表示“用户名已存在”。这类错误fetch更不会知道,得你自己解析JSON后判断。三层错误对应的处理策略完全不同。我最常用的封装模式是:async function request(url, options = {}) { let res; try { res = await fetch(url, options); } catch (err) { if (err.name === 'AbortError') { // 请求被主动取消(比如用户连点搜索),静默处理 throw err; } // 网络层错误:断网、超时、跨域 throw new Error('网络连接异常,请检查网络后重试'); } // HTTP层:非2xx都是失败 if (!res.ok) { throw new Error(`请求失败,状态码:${res.status}`); } // 业务层:约定 code 为 0 表示成功 const data = await res.json(); if (data.code !== 0) { throw new Error(data.message || '业务处理失败'); } return data; }这个模式几乎是所有前端项目的标准雏形,后来被不断扩展:加token、加统一弹窗、加错误上报。但核心就三句话:catch只负责网络层,res.ok负责HTTP层,data.code负责业务层。各层不混淆,就不会出现“明明接口失败了我还在渲染空数据”这种低级bug。5.1 超时控制:fetch没有默认超时,需要自己实现默认情况下,fetch发起请求后,只要服务器不返回,它会一直挂在那里,没有时间限制,这在实际项目里非常致命。用户点了一个按钮,如果请求挂死,页面就永远转圈。所以超时控制是一定要自己加的,而且实现方式恰好用到了前面说的AbortController:async function fetchWithTimeout(url, options = {}, timeout = 10000) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); try { const res = await fetch(url, { ...options, signal: controller.signal, }); return res; } catch (err) { if (err.name === 'AbortError') { throw new Error(`请求超时:${url}`); } throw err; } finally { clearTimeout(timer); } }实现思路很简单:设定一个定时器,到时间就controller.abort(),然后fetch就会以AbortError的形式reject。但要注意区分“主动取消”和“超时取消”。我上面代码里两者都走了AbortError分支,如果你需要区分,可以加一个标记变量:let timedOut = false; const timer = setTimeout(() => { timedOut = true; controller.abort(); }, timeout);然后catch里判断timedOut。不过多数情况下,超时和用户取消都当成“请求没有正常完成”处理就行,具体看你项目里需不需要更细的提示语。5.2 响应解析失败:别让你的页面直接崩掉还记得前面说的body只能读一次吗?如果服务器返回的JSON格式不合法(比如网关挂了返回了一坨HTML),res.json()会抛异常。这个异常不在fetch的reject范围里,它是在你读取响应体时发生的同步异常,在async函数里用await调用时,会被捕获成Promise rejection,所以外面的try/catch还是能接到的,但如果你没包try/catch,页面就直接报undefined了。稳妥的做法是给res.json()加一层防御:let data; try { data = await res.json(); } catch (err) { throw new Error('服务器返回的数据格式异常'); }我在实际项目中见过太多次“接口挂的时候页面白屏”的案例,排查到最后都是res.json()解析异常导致未捕获。写接口的人换了一个网关、或者反代层返回了502页面,就会触发这种问题。防御性解析是必须的。6. 项目级封装经验:把fetch用成顺手的状态管理器如果你只是在业务代码里直接fetch三四处,不封装问题不大。但项目一旦上了规模,直接写fetch的体验就是灾难:每个请求都要写headers、都要判断res.ok、都要解析JSON、都要处理超时,重复代码成堆。所以团队里一定会有一个统一的request封装。下面是我自己在几个项目中不断迭代出来的封装模板,不算最强,但胜在实用。6.1 统一封装:baseURL、token、超时、业务码全包const request = (baseURL = '/api') => { const controllers = new Map(); function http(url, options = {}) { const controller = new AbortController(); const key = options.signalKey || url + JSON.stringify(options.body || {}); // 同一个key的未完成请求先取消 if (controllers.has(key)) { controllers.get(key).abort(); } controllers.set(key, controller); const config = { ...options, headers: { 'Content-Type': 'application/json', ...(options.headers || {}), Authorization: `Bearer ${localStorage.getItem('token') || ''}`, }, signal: controller.signal, }; const timeout = options.timeout || 10000; const timer = setTimeout(() => controller.abort(), timeout); return new Promise((resolve, reject) => { fetch(baseURL + url, config) .then(async (res) => { let data = null; try { data = await res.json(); } catch (e) { // 响应不是JSON,保留null } if (!res.ok) { const error = new Error(data?.message || `HTTP ${res.status}`); error.status = res.status; error.code = data?.code; throw error; } if (data && data.code !== 0) { const error = new Error(data.message || '请求失败'); error.code = data.code; throw error; } return data; }) .then(resolve) .catch((err) => { if (err.name === 'AbortError') { const abortError = new Error('请求超时或已取消'); abortError.code = 'ABORT'; reject(abortError); } else { reject(err); } }) .finally(() => { clearTimeout(timer); controllers.delete(key); }); }); } return { get: (url, options) => http(url, { ...options, method: 'GET' }), post: (url, body, options) => http(url, { ...options, method: 'POST', body: JSON.stringify(body) }), put: (url, body, options) => http(url, { ...options, method: 'PUT', body: JSON.stringify(body) }), delete: (url, options) => http(url, { ...options, method: 'DELETE' }), cancelAll: () => { controllers.forEach((c) => c.abort()); controllers.clear(); }, }; }; const api = request(); export default api;这个封装的核心设计有几个点值得展开说一下:controllers Map管理取消:我故意把controller存在Map里,key是url + body的组合。这样同一个接口的连续请求会被自动取消,防止用户在表格里频繁点击操作按钮导致请求堆积。如果你希望每个请求都独立不被取消,可以在调用时传一个随机key。业务码和HTTP状态码分开处理:先检查res.ok,再检查data.code。两个判断分层,后端返回的错误信息能被带出来,不会丢。超时和取消共用一套AbortController:超时到了就abort(),返回的错误被统一包装成{code: 'ABORT'},调用方可以根据code做不同提示,而不是一股脑弹“请求失败”。响应体解析做了防呆:res.json()包了try/catch,遇到非JSON返回时data为null,后面的逻辑不会因为解析异常而中断。6.2 拦截用户退出:401统一跳登录业务中最常见的一个需求是:token过期后,所有接口都返回401。如果每个请求都自己判断一次再跳登录页,代码会非常啰嗦。统一封装的第二个优势在这里就体现出来了——在错误处理分支里加一个全局处理器:if (error.status === 401) { localStorage.removeItem('token'); // 跳转到登录页,同时把当前页面的路由存起来,登录后可以跳回来 window.location.href = '/login?redirect=' + encodeURIComponent(window.location.pathname); }这个逻辑放在http函数的catch分支里,所有请求就都覆盖到了。好处是不用每个页面都写一遍“token失效了怎么办”,出问题只会在一个地方出现,排查成本很低。6.3 请求进度体验:结合UI状态的最佳实践还有一个对用户体感影响很大的小技巧:在封装层做“请求中提示”的自动管理。比如showLoading选项开启时,请求发出去就展示全局loading,请求结束时隐藏。但这个特性要做得克制,否则用户会觉得页面一直在闪。我的建议是:只有预计超过300ms的请求才展示loading,短请求不需要,否则视觉上会看到loading一闪而过,反而更乱。let showLoadingTimer = null; function showLoading() { showLoadingTimer = setTimeout(() => { // 显示全局loading }, 300); } function hideLoading() { clearTimeout(showLoadingTimer); // 隐藏全局loading }请求开始时调用showLoading(),结束时调用hideLoading(),这样小于300ms的请求用户根本感知不到loading出现。7. 实际踩过的一些坑,以及最后的经验补充最后这部分,我想把这几年来用fetch踩过的坑浓缩一下,每条都是真实发生过、而且特别有代表性的。7.1 默认不带Cookie:同源请求丢了登录态fetch有一个行为很多人不知道:默认情况下,fetch不会携带Cookie,除非你显式设置credentials。哪怕你的接口和页面是同源的,只要用了fetch,它默认也是same-origin模式,但老浏览器上默认甚至可能是omit,也就是完全不带Cookie。有一次我给一个系统做对接,登录接口能通,但登录之后所有需要身份认证的接口全部返回401。排查了两小时,最后发现是fetch的credentials没有设置。加上之后一切正常:fetch('/api/private-data', { credentials: 'include', // 跨域也带Cookie // 或者 credentials: 'same-origin',仅同源带 });这个属性有三个值:omit(不带Cookie)、same-origin(同源带,默认)、include(跨域也带,但要配合后端Access-Control-Allow-Credentials: true)。如果你的项目用credentials: 'include',还要注意后端必须允许这个凭证,否则浏览器会直接拦截响应。7.2 响应体读取和内容类型不匹配服务端明明是JSON接口,但因为报错走了别的逻辑,返回了纯文本;前端用res.json()解析,抛异常,页面崩溃。我前面已经说了要加防御,但这里再强调一个点:接口的Content-Type头不可信,实际内容才是关键。我遇到过有些老后端框架,错误信息用text/plain返回,成功时才用application/json,前端直接res.json()就是一个雷。我现在的习惯是:request里先res.text()拿到字符串,如果能JSON.parse就转成对象,不能就直接当字符串处理。这样兼容性最强,但代价是多一次字符串到对象的转换,性能上几乎可以忽略。7.3 中文乱码与编码问题用res.text()读取接口时,浏览器默认按UTF-8解码。但有些老系统(尤其是Windows平台上部署的Java应用)返回的是GBK或GB2312编码,直接res.text()读出来全是乱码。解决办法是用arrayBuffer()读取原始字节,再用TextDecoder指定编码解码:const res = await fetch('/api/legacy-data'); const buffer = await res.arrayBuffer(); const text = new TextDecoder('gbk').decode(buffer);早期的TextDecoder只支持UTF-8,现在主流浏览器都支持gbk了。遇到乱码先别慌,先看服务器返回的Content-Type头里charset写的是什么,再决定用哪种解码方式。7.4 取消请求的误伤问题AbortController不仅能取消fetch,还能取消一些其他接受signal参数的API,但它不能取消已经被读取到一半的响应体读取。比如你已经进入reader.read()的循环,这时再触发controller.abort(),行为可能会因浏览器而异,有的会抛错,有的会正常结束。如果你在流式读取中间需要停止消费,建议用一个自定义的shouldStop标记来主动中断循环,而不是完全依赖abort()。另外,取消请求这个能力本身是一柄双刃剑。我见过有人在路由切换时把所有未完成请求全部abort(),结果有一个请求是给埋点系统报数据的,也被误杀了,导致这个页面的访问日志丢失。所以cancelAll()这种能力一定要谨慎使用,最好只取消你确信不重要的请求。7.5 用fetch的最终体感从第一次在项目里用fetch到现在,我的体感是:它不像axios那样“开箱即用”,但一旦你理解了错误分层、body流、AbortController这三个核心概念,再配上一套自己顺手或团队统一维护的封装,fetch完全能扛住绝大多数业务需求。而且它没有第三方依赖,不需要build时额外打包,这对一直在长大的前端工程来说,每少一个依赖就少一分供应链风险和体积焦虑。个人建议是:新项目没有特殊要求,优先考虑直接用fetch;老项目已经用axios且没有迁移成本的话,不急着大动干戈。但只要你决定用fetch,一定先把超时、取消、错误分层这三个基础能力补齐,否则后面业务复杂起来,你一定会回来骂它难用。最后分享一个很小的技巧。调试的时候,你可以直接在浏览器控制台跑一行fetch('/api/xxx').then(r => r.json()).then(console.log),不用打开DevTools的Network面板翻来翻去,非常快。这个小习惯帮我省下过不少时间。
1. 从一个真实翻车现场说起:为什么我换掉了XMLHttpRequest先说一件小事。前阵子帮人维护一个老系统,列表页有个导出功能,前同事用XMLHttpRequest写了一大段逻辑:监听onreadystatechange、判断readyState === 4、再判断status、还要处理responseType为blob时兼容不同浏览器的坑。光初始化就三十多行,我改一个上传进度条,翻了半天才找到upload.onprogress在哪。那会儿我就想,同样是发请求,如果当初用Fetch,这段代码至少能压缩一半。后来我把那段老代码重构掉,顺手把项目里所有新增的网络请求都切到了Fetch。做这个决定之前我其实犹豫过,因为当时项目里已经有人在用axios,团队内部也有争议。但写完几个模块之后,我自己的结论很明确:Fetch是浏览器原生的能力,不需要引第三方库,API设计更贴近现代JavaScript,Promise语法天然契合async/await,日常八九成的请求场景它都覆盖了。这篇文章不是来吹Fetch天下第一的,恰恰相反,我想把一个真实使用者在项目里反复踩坑之后积累的经验完整写出来——什么时候该用Fetch、用的时候要注意什么、遇到404为什么不会走catch、怎么实现超时、怎么取消请求、怎么读流式接口,以及为什么很多人用Fetch用的很痛苦只是因为没做封装。读完你大概率能直接把Fetch用出axios七成的爽感,还不用背那几十KB的依赖包。如果你是个刚接触前端的新手,这篇文章可以当Fetch使用手册看;如果你已经在项目里写过fetch,那你应该能在我写的“坑”里找到共鸣——我们大概率在同一个地方摔过。核心关键词就一个:Fetch的使用。我尽量不堆术语,能用大白话解释的就用大白话解释,但涉及原理的部分一点不省,毕竟后端返回数据的时候可不管前端爽不爽。2. fetch不是axios平替:先弄懂请求与响应背后的流程很多教程一上来就给你看fetch(url).then(res => res.json()),读者跟着跑通一个GET接口就以为学会了,结果一到项目里就懵:为什么catch没抓到后端返回的500?为什么读完了响应想再取一次res.text()却是空的?为什么跨域请求莫名其妙丢了Cookie?这些问题的根子在于,没有理解fetch的请求和响应到底是怎么在背后流转的。2.1 一次请求的完整生命周期fetch()接收两个参数:第一个是资源地址,第二个是可选的配置对象options。内部做的事情其实可以拆成四个阶段:构造请求:浏览器把传进来的URL和配置组装成一个Request对象,比如method、headers、body、credentials都会在这个时候被解析。发起网络请求:浏览器按配置发出请求。这一步和老的XMLHttpRequest不同,fetch是基于Promise的,但它不是“请求发出去了就返回一个成功Promise”,而是“请求结束后返回一个代表响应的Promise”。获取响应元信息:服务器一旦有响应头返回,哪怕是404、500,fetch的Promise都会被resolve,而不是reject。换句话说,HTTP状态码不会让fetch进入catch分支。读取响应体:通过res.json()、res.text()、res.blob()等方法,把响应流读取出来变成实际可用的数据。这里最反直觉的就是第三阶段。我刚用fetch那会儿写过一个接口,后端参数校验失败返回了400,我以为catch能接到,结果页面一直没反应,控制台也没报错,数据区空荡荡的。后来才明白:fetch只在网络层面失败时才reject,比如断网、DNS解析失败、跨域被拦截这种“请求根本没完成”的情况。而只要服务器给了响应,不管状态码是多少,Promise都会resolve。所以判断请求成不成功,最核心的检查点是res.ok这个属性——它是个布尔值,true表示状态码在200-299之间,false就是其他状态。习惯了axios那种“2xx走then、其他走catch”的同学,切到fetch后必须把这个思维扭转过来:fetch的成功是“响应到达”的成功,不是“业务处理成功”的成功。2.2 Body只能读一次:响应体是一次性消费的这个坑几乎是每个人都会踩的。你说“我先读一下res.text()看看返回啥,再调res.json()转成对象”,抱歉,不行。Response对象背后的数据是一股流(stream),就像水龙头里的水,你拿杯子接了一次,水就没了,再拿碗去接只能接到空气。这种现象在代码上的表现是:调用过res.json()之后再调res.text(),会抛异常,或者直接从pending变成rejected。我早期写调试代码就干过这种事,想先看看纯文本再解析JSON,被这个行为卡了半小时。正确的做法是用一种方式读完,要么res.text()、要么res.json()、要么res.blob(),读完就没了。如果确实需要同时看原始文本和解析结果,可以先把res.text()的结果存到变量里,再用JSON.parse()去转:const res = await fetch('/api/user'); const rawText = await res.text(); // 先读文本 const data = JSON.parse(rawText); // 自己解析,而不是再调 res.json()明确了这两点之后,fetch的基本心理模型就建立起来了。它不是axios那种“我给你配置,你把处理好的数据给我”的黑盒,而是更底层的浏览器能力。你现在知道了它到底怎么工作,后面那些高级用法自然不晕。3. 核心操作拆解:从最简单的GET到带请求体的POST别急着看高级玩法,先把日常最高频的几种请求写法吃透。我按真实开发中的出现频率来排:GET、POST JSON、提交表单、上传文件。每一种我都会给可运行的示例和对应的注意事项。3.1 GET请求:默认配置其实够用不给第二个参数直接调fetch,发出去的就是GET请求。比如拉一个用户列表:async function getUserList() { const res = await fetch('https://api.example.com/users'); if (!res.ok) { throw new Error(`请求失败:${res.status}`); } const data = await res.json(); return data; }这里有个小细节值得说:如果你要传查询参数,直接在URL上拼接就好,不需要在配置里额外声明。有些人会用new URLSearchParams()构造字符串,我建议你也这么做,它能自动处理编码问题:const params = new URLSearchParams({ page: 2, keyword: '框架', }); const res = await fetch(`https://api.example.com/users?${params.toString()}`);URLSearchParams会把中文变成%E6%A1%86%E6%9E%B6这种编码结果,比自己手动拼字符串安全得多。前端的URL编码坑非常多,以前有同事把用户输入的原样塞进URL,只要内容里带了&或?,后端拿到的参数就是错的,这种问题非常难排查。3.2 POST JSON:注意Content-Type别漏发JSON格式的POST请求,最典型的三件套是:method改为POST、body传JSON.stringify()的结果、headers里设置Content-Type为application/json。少一个都可能出问题。const res = await fetch('https://api.example.com/login', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ username: 'admin', password: '123456', }), });为什么Content-Type这么重要?因为后端框架(尤其是一些Java系的接口框架)要依靠请求头来判断怎么解析body。如果没设置application/json,而body你又是用JSON字符串传的,好几个框架会直接报“Required request body is missing”,百思不得其解。反过来,如果你不JSON.stringify而是直接传对象,浏览器会自动调用toString(),最终的body会变成[object Object],后端一样收不到正确数据。这里提一嘴:如果你请求的接口跨域,前端能设置的headers是受限制的。自定义请求头需要后端在响应头里放Access-Control-Allow-Headers,并且显式列出允许的字段。这也是为什么很多项目里大家习惯把所有请求头都封装成统一配置,避免每加一个header就去麻烦后端加一次白名单。3.3 提交表单与文件上传:用FormData不用手工处理边界如果项目里遇到表单提交、文件上传,直接上FormData。它会把请求体的格式自动处理成multipart/form-data,并且自动加上正确的Content-Type(带boundary边界符),你完全不碰headers就可以:const formData = new FormData(); formData.append('name', '张三'); formData.append('avatar', fileInput.files[0]); const res = await fetch('https://api.example.com/profile', { method: 'POST', body: formData, });这里有一个很多新人会踩的误区:想当然地给这种请求手动设置Content-Type为multipart/form-data。不要这么做。因为浏览器生成的multipart请求头会包含一个随机的boundary字符串,你手动设置的Content-Type会丢失这个boundary,后端解析文件时直接失败。正确做法是:让浏览器自己生成请求头,你只传body就够了。如果你只是提交纯文本字段,不涉及文件,还有一种更轻量的方案——URLSearchParams配合application/x-www-form-urlencoded格式:const formBody = new URLSearchParams(); formBody.append('title', '我是标题'); formBody.append('content', '我是正文'); const res = await fetch('https://api.example.com/article', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: formBody.toString(), });这个格式和原生HTML表单提交是一致的,有些老的后端接口只认这种格式,灵活切换就行。3.4 处理响应:JSON之外的其他数据类型响应体的读取不止json()一种。根据接口返回内容,还有:方法用途典型场景res.json()解析JSON数据绝大多数接口res.text()读取纯文本接口返回字符串或HTML片段res.blob()读取二进制数据文件下载、图片res.arrayBuffer()读取二进制原始缓冲需要自己解码的场景res.formData()解析表单格式很少用,一般配合特殊服务文件下载是个很好的例子。我做过一个报表导出功能,下载后的Excel要触发浏览器保存,当时就是调接口拿blob,再通过URL.createObjectURL生成临时链接下载:const res = await fetch('https://api.example.com/export'); const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = '报表.xlsx'; a.click(); URL.revokeObjectURL(url);download属性加上createObjectURL的组合是比较标准的浏览器端文件下载方案。需要注意URL.revokeObjectURL()一定要在触发点击之后调用,提前调用会导致下载链接失效,这个顺序我踩过一次,后来养成了习惯先click再revoke。4. 容易被忽略的硬核能力:并发、取消与流式读取基础操作了解后,直接上大多数教程不会认真讲的部分:并发请求、取消请求、读取流式接口。这三项能力是fetch真正区别于普通请求库的核心价值所在,也是实战中最容易被忽略、但用好了体验提升极其明显的三块。4.1 并发请求:Promise.all之外还要考虑失败并发请求最简单的方式就是Promise.all,比如页面同时需要用户信息和配置信息:const [userRes, configRes] = await Promise.all([ fetch('/api/user'), fetch('/api/config'), ]); const user = await userRes.json(); const config = await configRes.json();但Promise.all有个行为你要清楚:如果其中一个请求失败,整个Promise立即reject,其他请求的结果你也拿不到了。真实场景里,用户信息挂了不应该影响配置信息的展示。这时可以换Promise.allSettled,它会等所有请求都结束,然后把每个请求的结果以数组形式给你,每个元素包含status、value或reason:const results = await Promise.allSettled([ fetch('/api/user'), fetch('/api/config'), ]); for (const result of results) { if (result.status === 'fulfilled') { // 这个请求成功,可以解析 } else { // 这个请求失败,单独处理 } }这个API在大多数现代浏览器里都支持了,老项目用之前查一下目标浏览器兼容性。如果你还在维护古董浏览器,可以自己封装一个简易版本,核心思路就是让每个Promise不管成功失败都resolve包一层结果。4.2 取消请求:AbortController是唯一标准方案很多人不知道fetch是可以取消的,而且取消方式非常优雅——AbortController。如果你需要做一个关键词搜索功能,用户每输入一个字就发一个请求,结果上一次还没返回、下一次就发出了,经常出现“后面的先到、前面的后到”导致展示错乱的问题。这时候就需要取消前面的请求。let controller = null; async function search(keyword) { // 如果上一次请求还没完成,先取消它 if (controller) { controller.abort(); } controller = new AbortController(); try { const res = await fetch('/api/search?q=' + keyword, { signal: controller.signal, }); const data = await res.json(); // 渲染结果 } catch (err) { // 注意:取消请求会抛 AbortError,需要单独判断 if (err.name === 'AbortError') { console.log('上一个请求已被取消'); return; } // 其他错误正常处理 } }AbortController就是一个控制器对象,它有abort()方法,以及一个signal属性。你把signal传进fetch配置里,当调用abort()时,浏览器会终止这个请求并抛出一个名为AbortError的异常。这里有个细节非常重要:取消后catch到的错误名字是AbortError,你不做判断就直接弹“网络错误”,用户会看到一闪而过的报错提示。所以捕获异常时一定先判断err.name。还有一个很容易被忽视的点:一个AbortController实例只能服务于一次请求的取消。如果你第二次fetch复用了同一个controller,调用abort()会把上一次的请求一起取消,即使它已经结束了也会引发一些奇怪的副作用。正确用法就是我上面写的,每次请求都新建一个controller。4.3 流式读取:大文件下载、日志和AI接口都能用fetch最被低估的能力是它对**流(Stream)**的原生支持。响应体不是一次性把所有内容倒给你,而是可以一块一块地读。这带来的直接好处是:你可以做下载进度、可以逐行读取日志、可以做类似ChatGPT那种打字机效果的流式回复界面。很多开发者以为只有WebSocket能实现实时效果,其实HTTP的流式响应加fetch就够了。读流的核心API是response.body.getReader()。配合TextDecoder把字节解码成字符串:const res = await fetch('/api/stream'); const reader = res.body.getReader(); const decoder = new TextDecoder(); let receivedLength = 0; const chunks = []; while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); receivedLength += value.length; // 可以在这里把已收到的内容追加到页面上,实现实时展示 const text = decoder.decode(value, { stream: true }); // 比如:el.innerHTML += text; } const fullText = chunks.reduce((acc, chunk) => { return acc + decoder.decode(chunk, { stream: false }); }, '');TextDecoder的{ stream: true }参数是必要的:因为二进制流是按块切的,多字节的中文字符可能被切到两个块里,不开启stream模式的话边界处会乱码。这个细节我是在做流式日志展示的时候发现的,第一版没加这个参数,英文日志正常,中文日志每隔几个字就出一个乱码,排查了半天,加了个参数就好了。如果你只需要下载进度,不用像上面那样手动拼字符串,可以只累计value.length,然后拿它和res.headers.get('Content-Length')算百分比。注意Content-Length是字节数,value.length也是字节数,两者是齐的,不要拿字符串的length(字符数)去比,一个中文就是一个字符但三个字节,算出来的进度就超了。5. 错误处理不能只catch:HTTP状态码和网络异常要分清说实话,fetch的错误处理是它最容易劝退新人的地方,因为把错误全推给catch的人一定会挨揍。我把实践中总结的错误分层模型画成一个“三层错误金字塔”,从上到下:网络层错误:断网、DNS失败、跨域被拦截、请求取消。这类错误fetch会reject,你能在catch里抓到。HTTP层错误:400、401、404、500等。fetch不会reject,需要你检查res.ok或res.status。业务层错误:HTTP状态码返回200,但JSON主体里的code字段是1,表示“用户名已存在”。这类错误fetch更不会知道,得你自己解析JSON后判断。三层错误对应的处理策略完全不同。我最常用的封装模式是:async function request(url, options = {}) { let res; try { res = await fetch(url, options); } catch (err) { if (err.name === 'AbortError') { // 请求被主动取消(比如用户连点搜索),静默处理 throw err; } // 网络层错误:断网、超时、跨域 throw new Error('网络连接异常,请检查网络后重试'); } // HTTP层:非2xx都是失败 if (!res.ok) { throw new Error(`请求失败,状态码:${res.status}`); } // 业务层:约定 code 为 0 表示成功 const data = await res.json(); if (data.code !== 0) { throw new Error(data.message || '业务处理失败'); } return data; }这个模式几乎是所有前端项目的标准雏形,后来被不断扩展:加token、加统一弹窗、加错误上报。但核心就三句话:catch只负责网络层,res.ok负责HTTP层,data.code负责业务层。各层不混淆,就不会出现“明明接口失败了我还在渲染空数据”这种低级bug。5.1 超时控制:fetch没有默认超时,需要自己实现默认情况下,fetch发起请求后,只要服务器不返回,它会一直挂在那里,没有时间限制,这在实际项目里非常致命。用户点了一个按钮,如果请求挂死,页面就永远转圈。所以超时控制是一定要自己加的,而且实现方式恰好用到了前面说的AbortController:async function fetchWithTimeout(url, options = {}, timeout = 10000) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); try { const res = await fetch(url, { ...options, signal: controller.signal, }); return res; } catch (err) { if (err.name === 'AbortError') { throw new Error(`请求超时:${url}`); } throw err; } finally { clearTimeout(timer); } }实现思路很简单:设定一个定时器,到时间就controller.abort(),然后fetch就会以AbortError的形式reject。但要注意区分“主动取消”和“超时取消”。我上面代码里两者都走了AbortError分支,如果你需要区分,可以加一个标记变量:let timedOut = false; const timer = setTimeout(() => { timedOut = true; controller.abort(); }, timeout);然后catch里判断timedOut。不过多数情况下,超时和用户取消都当成“请求没有正常完成”处理就行,具体看你项目里需不需要更细的提示语。5.2 响应解析失败:别让你的页面直接崩掉还记得前面说的body只能读一次吗?如果服务器返回的JSON格式不合法(比如网关挂了返回了一坨HTML),res.json()会抛异常。这个异常不在fetch的reject范围里,它是在你读取响应体时发生的同步异常,在async函数里用await调用时,会被捕获成Promise rejection,所以外面的try/catch还是能接到的,但如果你没包try/catch,页面就直接报undefined了。稳妥的做法是给res.json()加一层防御:let data; try { data = await res.json(); } catch (err) { throw new Error('服务器返回的数据格式异常'); }我在实际项目中见过太多次“接口挂的时候页面白屏”的案例,排查到最后都是res.json()解析异常导致未捕获。写接口的人换了一个网关、或者反代层返回了502页面,就会触发这种问题。防御性解析是必须的。6. 项目级封装经验:把fetch用成顺手的状态管理器如果你只是在业务代码里直接fetch三四处,不封装问题不大。但项目一旦上了规模,直接写fetch的体验就是灾难:每个请求都要写headers、都要判断res.ok、都要解析JSON、都要处理超时,重复代码成堆。所以团队里一定会有一个统一的request封装。下面是我自己在几个项目中不断迭代出来的封装模板,不算最强,但胜在实用。6.1 统一封装:baseURL、token、超时、业务码全包const request = (baseURL = '/api') => { const controllers = new Map(); function http(url, options = {}) { const controller = new AbortController(); const key = options.signalKey || url + JSON.stringify(options.body || {}); // 同一个key的未完成请求先取消 if (controllers.has(key)) { controllers.get(key).abort(); } controllers.set(key, controller); const config = { ...options, headers: { 'Content-Type': 'application/json', ...(options.headers || {}), Authorization: `Bearer ${localStorage.getItem('token') || ''}`, }, signal: controller.signal, }; const timeout = options.timeout || 10000; const timer = setTimeout(() => controller.abort(), timeout); return new Promise((resolve, reject) => { fetch(baseURL + url, config) .then(async (res) => { let data = null; try { data = await res.json(); } catch (e) { // 响应不是JSON,保留null } if (!res.ok) { const error = new Error(data?.message || `HTTP ${res.status}`); error.status = res.status; error.code = data?.code; throw error; } if (data && data.code !== 0) { const error = new Error(data.message || '请求失败'); error.code = data.code; throw error; } return data; }) .then(resolve) .catch((err) => { if (err.name === 'AbortError') { const abortError = new Error('请求超时或已取消'); abortError.code = 'ABORT'; reject(abortError); } else { reject(err); } }) .finally(() => { clearTimeout(timer); controllers.delete(key); }); }); } return { get: (url, options) => http(url, { ...options, method: 'GET' }), post: (url, body, options) => http(url, { ...options, method: 'POST', body: JSON.stringify(body) }), put: (url, body, options) => http(url, { ...options, method: 'PUT', body: JSON.stringify(body) }), delete: (url, options) => http(url, { ...options, method: 'DELETE' }), cancelAll: () => { controllers.forEach((c) => c.abort()); controllers.clear(); }, }; }; const api = request(); export default api;这个封装的核心设计有几个点值得展开说一下:controllers Map管理取消:我故意把controller存在Map里,key是url + body的组合。这样同一个接口的连续请求会被自动取消,防止用户在表格里频繁点击操作按钮导致请求堆积。如果你希望每个请求都独立不被取消,可以在调用时传一个随机key。业务码和HTTP状态码分开处理:先检查res.ok,再检查data.code。两个判断分层,后端返回的错误信息能被带出来,不会丢。超时和取消共用一套AbortController:超时到了就abort(),返回的错误被统一包装成{code: 'ABORT'},调用方可以根据code做不同提示,而不是一股脑弹“请求失败”。响应体解析做了防呆:res.json()包了try/catch,遇到非JSON返回时data为null,后面的逻辑不会因为解析异常而中断。6.2 拦截用户退出:401统一跳登录业务中最常见的一个需求是:token过期后,所有接口都返回401。如果每个请求都自己判断一次再跳登录页,代码会非常啰嗦。统一封装的第二个优势在这里就体现出来了——在错误处理分支里加一个全局处理器:if (error.status === 401) { localStorage.removeItem('token'); // 跳转到登录页,同时把当前页面的路由存起来,登录后可以跳回来 window.location.href = '/login?redirect=' + encodeURIComponent(window.location.pathname); }这个逻辑放在http函数的catch分支里,所有请求就都覆盖到了。好处是不用每个页面都写一遍“token失效了怎么办”,出问题只会在一个地方出现,排查成本很低。6.3 请求进度体验:结合UI状态的最佳实践还有一个对用户体感影响很大的小技巧:在封装层做“请求中提示”的自动管理。比如showLoading选项开启时,请求发出去就展示全局loading,请求结束时隐藏。但这个特性要做得克制,否则用户会觉得页面一直在闪。我的建议是:只有预计超过300ms的请求才展示loading,短请求不需要,否则视觉上会看到loading一闪而过,反而更乱。let showLoadingTimer = null; function showLoading() { showLoadingTimer = setTimeout(() => { // 显示全局loading }, 300); } function hideLoading() { clearTimeout(showLoadingTimer); // 隐藏全局loading }请求开始时调用showLoading(),结束时调用hideLoading(),这样小于300ms的请求用户根本感知不到loading出现。7. 实际踩过的一些坑,以及最后的经验补充最后这部分,我想把这几年来用fetch踩过的坑浓缩一下,每条都是真实发生过、而且特别有代表性的。7.1 默认不带Cookie:同源请求丢了登录态fetch有一个行为很多人不知道:默认情况下,fetch不会携带Cookie,除非你显式设置credentials。哪怕你的接口和页面是同源的,只要用了fetch,它默认也是same-origin模式,但老浏览器上默认甚至可能是omit,也就是完全不带Cookie。有一次我给一个系统做对接,登录接口能通,但登录之后所有需要身份认证的接口全部返回401。排查了两小时,最后发现是fetch的credentials没有设置。加上之后一切正常:fetch('/api/private-data', { credentials: 'include', // 跨域也带Cookie // 或者 credentials: 'same-origin',仅同源带 });这个属性有三个值:omit(不带Cookie)、same-origin(同源带,默认)、include(跨域也带,但要配合后端Access-Control-Allow-Credentials: true)。如果你的项目用credentials: 'include',还要注意后端必须允许这个凭证,否则浏览器会直接拦截响应。7.2 响应体读取和内容类型不匹配服务端明明是JSON接口,但因为报错走了别的逻辑,返回了纯文本;前端用res.json()解析,抛异常,页面崩溃。我前面已经说了要加防御,但这里再强调一个点:接口的Content-Type头不可信,实际内容才是关键。我遇到过有些老后端框架,错误信息用text/plain返回,成功时才用application/json,前端直接res.json()就是一个雷。我现在的习惯是:request里先res.text()拿到字符串,如果能JSON.parse就转成对象,不能就直接当字符串处理。这样兼容性最强,但代价是多一次字符串到对象的转换,性能上几乎可以忽略。7.3 中文乱码与编码问题用res.text()读取接口时,浏览器默认按UTF-8解码。但有些老系统(尤其是Windows平台上部署的Java应用)返回的是GBK或GB2312编码,直接res.text()读出来全是乱码。解决办法是用arrayBuffer()读取原始字节,再用TextDecoder指定编码解码:const res = await fetch('/api/legacy-data'); const buffer = await res.arrayBuffer(); const text = new TextDecoder('gbk').decode(buffer);早期的TextDecoder只支持UTF-8,现在主流浏览器都支持gbk了。遇到乱码先别慌,先看服务器返回的Content-Type头里charset写的是什么,再决定用哪种解码方式。7.4 取消请求的误伤问题AbortController不仅能取消fetch,还能取消一些其他接受signal参数的API,但它不能取消已经被读取到一半的响应体读取。比如你已经进入reader.read()的循环,这时再触发controller.abort(),行为可能会因浏览器而异,有的会抛错,有的会正常结束。如果你在流式读取中间需要停止消费,建议用一个自定义的shouldStop标记来主动中断循环,而不是完全依赖abort()。另外,取消请求这个能力本身是一柄双刃剑。我见过有人在路由切换时把所有未完成请求全部abort(),结果有一个请求是给埋点系统报数据的,也被误杀了,导致这个页面的访问日志丢失。所以cancelAll()这种能力一定要谨慎使用,最好只取消你确信不重要的请求。7.5 用fetch的最终体感从第一次在项目里用fetch到现在,我的体感是:它不像axios那样“开箱即用”,但一旦你理解了错误分层、body流、AbortController这三个核心概念,再配上一套自己顺手或团队统一维护的封装,fetch完全能扛住绝大多数业务需求。而且它没有第三方依赖,不需要build时额外打包,这对一直在长大的前端工程来说,每少一个依赖就少一分供应链风险和体积焦虑。个人建议是:新项目没有特殊要求,优先考虑直接用fetch;老项目已经用axios且没有迁移成本的话,不急着大动干戈。但只要你决定用fetch,一定先把超时、取消、错误分层这三个基础能力补齐,否则后面业务复杂起来,你一定会回来骂它难用。最后分享一个很小的技巧。调试的时候,你可以直接在浏览器控制台跑一行fetch('/api/xxx').then(r => r.json()).then(console.log),不用打开DevTools的Network面板翻来翻去,非常快。这个小习惯帮我省下过不少时间。