Fetch API实战指南:从基础请求到项目级封装

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。内部做的事情其实可以拆成四个阶段:

  1. 构造请求:浏览器把传进来的URL和配置组装成一个Request对象,比如method、headers、body、credentials都会在这个时候被解析。
  2. 发起网络请求:浏览器按配置发出请求。这一步和老的XMLHttpRequest不同,fetch是基于Promise的,但它不是“请求发出去了就返回一个成功Promise”,而是“请求结束后返回一个代表响应的Promise”。
  3. 获取响应元信息:服务器一旦有响应头返回,哪怕是404、500,fetch的Promise都会被resolve,而不是reject。换句话说,HTTP状态码不会让fetch进入catch分支。
  4. 读取响应体:通过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面板翻来翻去,非常快。这个小习惯帮我省下过不少时间。

相关新闻

AI写作助手实战:课程论文从选题、大纲、扩写与降重的完整流程

AI写作助手实战:课程论文从选题、大纲、扩写与降重的完整流程

写课程论文最怕的不是“不会写”,而是打开文档之后,光标在空白页上闪了半小时,脑子里还是一团乱麻。我周围的同学大部分并不是没想法,而是卡在“怎么把想法变成一个像样的论证过程”这一步。去年开始我尝试把AI写作工具接入课程论…

2026/10/20 11:42:39 阅读更多 →
PROSAIL Python修正包:返回值错误排查与LUT反演实战

PROSAIL Python修正包:返回值错误排查与LUT反演实战

简介:修正后的Prosail模型Python实现资源,核心解决原版prosail.run_prosail()在典型参数组合下返回值异常为NAN的问题,面向从事遥感反演、植被参数模拟、冠层辐射传输研究的研究生与科研人员。压缩包约293KB,共25个文件&#xff0…

2026/10/20 11:42:39 阅读更多 →
ATSHA204嵌入式安全实战:从协议帧到状态机认证全解析

ATSHA204嵌入式安全实战:从协议帧到状态机认证全解析

简介:本资源是面向嵌入式安全开发工程师与STM32初学者的ATSHA204加密芯片实战入门套件,聚焦物联网设备身份认证、密钥安全存储与硬件级数据加密等核心需求。资源包含中文技术手册与完整STM32F103平台例程demo,覆盖芯片原理、IC/SPI通信协议、…

2026/10/20 11:41:38 阅读更多 →

最新新闻

水风光互补调度与净现值计算:MATLAB资源包实战指南

水风光互补调度与净现值计算:MATLAB资源包实战指南

简介:本资源面向计算机、电子信息工程、数学等专业的大学生及新能源调度方向研究者,提供水风光互补调度系统的净现值计算与不同网格装机容量、出力系数影响分析的完整MATLAB实现方案,可用于课程设计、期末大作业与毕业设计。压缩包共18个文件…

2026/10/20 12:26:14 阅读更多 →
情感化无感防刷与手势阻尼反馈:基于用户注意力疲劳度动态微调按钮交互阈值

情感化无感防刷与手势阻尼反馈:基于用户注意力疲劳度动态微调按钮交互阈值

在电商狂欢秒杀、限量潮流球鞋发售或火热赛事抢票的关键瞬间,前端页面承受着来自全球数百万用户的极速疯狂连击。 面对这种狂热的并发点击,传统的防刷防重复提交方案往往充斥着一种“居高临下的冰冷傲慢”: 方案一:粗暴弹窗报错—…

2026/10/20 12:26:14 阅读更多 →
推理引擎底层逻辑与优化实践:从计算图到量化部署

推理引擎底层逻辑与优化实践:从计算图到量化部署

做推理引擎方向有段时间了,经常有人拿着模型部署的报错来问我,聊到最后发现卡点往往不在算子实现上,而是对“推理”这件事本身的底层逻辑没吃透。这篇文章就是想把这些基础的东西掰开揉碎讲清楚,从模型是什么、推理时计算机到底在…

2026/10/20 12:26:14 阅读更多 →
AI智能体架构图绘制指南:四层框架与实战排查

AI智能体架构图绘制指南:四层框架与实战排查

画AI智能体架构图这件事,看着不难,翻车率却很高。前阵子帮一个小团队评审方案,对方贴出一张图:中间一个“LLM”的大框,左边拖进来几个接口,右边连着一个数据库,就敢叫“AI智能体架构图”。我说这…

2026/10/20 12:26:14 阅读更多 →
动效运行时弹簧物理引擎:纯 TypeScript 实现 RK4 龙格-库塔高精度数值积分

动效运行时弹簧物理引擎:纯 TypeScript 实现 RK4 龙格-库塔高精度数值积分

在现代自然交互动效中,传统的基于固定时间持续与三次贝塞尔曲线(Cubic-bezier)的过渡动画,正日益暴露出其致命的机械感与割裂感。 假设一个用户正在用手指拖拽一个浮动卡片:当手指松开时,卡片已经具有了初速…

2026/10/20 12:26:14 阅读更多 →
Flink SQL与DataStream本质差异:从API选择到生产就绪

Flink SQL与DataStream本质差异:从API选择到生产就绪

1. 为什么这节课是Flink学习者真正的分水岭我带过不少刚接触流处理的开发者,他们往往在学完DataStream API后会陷入一种“我已经会了”的错觉——能写个WordCount、能接Kafka、能做简单窗口聚合,就以为掌握了Flink。直到某天被业务方甩来一份需求&#x…

2026/10/20 12:25:14 阅读更多 →

日新闻

CLRC663芯片Pin-to-Pin替代实战:从评估到量产全记录

CLRC663芯片Pin-to-Pin替代实战:从评估到量产全记录

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:01:30 阅读更多 →
ESP32舵机控制指南:原理、多路驱动与避坑实战

ESP32舵机控制指南:原理、多路驱动与避坑实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:01:30 阅读更多 →
位移法实现一阶低通滤波:单片机ADC采样降噪实战

位移法实现一阶低通滤波:单片机ADC采样降噪实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:01:30 阅读更多 →

周新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型(GMM)的语音识别系统,很多人第一反应就是一堆数学公式,觉得门槛很高。但如果你真的在MATLAB里把它落地跑通,会发现核心其实就是三件事:把声音变成特征向量,用GMM给每个类别…

2026/10/20 10:02:13 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人,但落到具体操作上,无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程:对图像做反白、改调色板、彩色转灰度,最后把像素数据导出成txt文件。单看每一…

2026/10/19 17:32:41 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词,你会发现一个很有意思的现象:每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型,有人指的是运动控制系统,还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/20 2:50:50 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 10:02:08 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 9:50:17 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 9:50:45 阅读更多 →