Fetch API自带的能力在几个关键点上确实是XHR给不了的,后面我会逐个拆开讲。但先把结论放在这儿:即便你现在维护的是老项目,也值得把新代码里的网络请求逐步切到Fetch上,这是一笔长期收益很高的技术债偿还。
1. 从XHR到Fetch:为什么说这是一次网络请求方案的代际升级
1.1 一个经典场景:登录请求的三行代码对比
先说个我早期接手的真实项目。那是一个后台管理系统,所有网络请求都封装在一个工具类里,内部全部使用XMLHttpRequest。代码本身没什么问题,但每次新增一个请求就要写一大段样板代码,处理JSON序列化、错误码、超时逻辑,几处回调嵌套看得人头大。后来我们逐步迁移到Fetch,同一个登录请求,对比非常直观。
用XHR写,大概是这个样子:
function login(username, password) { return new Promise(function (resolve, reject) { var xhr = new XMLHttpRequest(); xhr.open('POST', '/api/login'); xhr.setRequestHeader('Content-Type', 'application/json'); xhr.onreadystatechange = function () { if (xhr.readyState === 4) { if (xhr.status >= 200 && xhr.status < 300) { resolve(JSON.parse(xhr.responseText)); } else { reject(new Error('请求失败: ' + xhr.status)); } } }; xhr.onerror = function () { reject(new Error('网络异常')); }; xhr.send(JSON.stringify({ username: username, password: password })); }); }同一个逻辑用Fetch写,是这个样子:
async function login(username, password) { const response = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }); if (!response.ok) throw new Error('请求失败: ' + response.status); return response.json(); }少了将近一半的代码,可读性提升了一个档次。这不是花哨的语法糖,而是设计思路的根本变化:XHR把“发请求”这件事拆成了readyState、status、responseText等多个分散的状态点,而Fetch把请求和响应都抽象成了对象,配合Promise天然地解决了回调地狱问题。
1.2 Fetch的设计核心:Promise、Stream、Request/Response对象
要理解Fetch为什么好用,得先看它设计上的三个支点。
第一个支点是Promise。所有异步操作都返回Promise,这意味着你可以用async/await写同步风格的代码,错误处理也能用try/catch统一接管。配合Promise.all做并发请求、Promise.race做超时控制,写起来非常顺手,不再需要手动维护回调函数数组。
第二个支点是Stream。Fetch的响应体不是一次性给你全部数据,而是一个ReadableStream。这意味着你可以边下载边处理数据,比如做一个大文件的下载进度条,或者处理服务器推送的流式日志。XHR虽然也支持progress事件,但Fetch配合流式API能做的事情更底层、更灵活。
第三个支点是Request和Response对象。Fetch的入参可以是一个Request对象,出参是一个Response对象。这两个对象都可以被复用、克隆、传递给Service Worker。如果你做过PWA,应该能体会到在Service Worker里拦截请求、读取缓存、构造自定义响应有多顺手,这一切都建立在Fetch的标准化设计之上。
1.3 Fetch与XHR的能力对比表
我把实际开发中最关心的几个维度整理成了表格,方便你评估切换成本:
| 能力维度 | Fetch API | XMLHttpRequest |
|---|---|---|
| 代码风格 | Promise / async / await | 事件回调,需手动封装Promise |
| 请求/响应对象 | Request / Response 标准化对象 | 无独立对象,散落在xhr属性上 |
| 流式数据处理 | 支持ReadableStream | 依赖responseText / responseXML,不灵活 |
| Service Worker集成 | 原生支持 | 不支持 |
| 请求取消 | AbortController | 原生支持abort(),但API较原始 |
| Cookie携带 | 默认不带,需配credentials | 同源默认携带 |
| 超时控制 | 需配合AbortController实现 | 有原生timeout属性 |
| 上传进度 | 需用Stream或第三方封装 | 原生支持upload.onprogress |
| 浏览器兼容 | 现代浏览器全支持,IE不支持 | 老浏览器也支持 |
有一说一,XHR有一个Fetch原生比不了的地方:上传进度事件。XHR的xhr.upload.onprogress拿来就能用,Fetch的Request对象虽然也能通过Stream去收集上传进度,但实现复杂度高不少。好在社区已经有成熟的方案,后面我讲上传场景的时候会给出一个封装思路。
2. 把Fetch真正用熟:基础用法与关键参数拆解
2.1 最常用的GET请求:参数拼接、请求头、缓存策略
GET请求看似简单,实际开发里踩坑点不少。我见过很多同事直接在url后面用字符串拼接参数,像'/api/users?page=' + page + '&size=' + size,一旦参数值里有特殊字符,整个URL就炸了。我建议统一用URLSearchParams来构造query。
看这段代码:
async function fetchUsers(page, size, keyword) { const params = new URLSearchParams({ page, size }); if (keyword) params.append('keyword', keyword); const response = await fetch(`/api/users?${params.toString()}`); if (!response.ok) { // 这里可以细分状态码,比如401跳登录、403提示无权限等 throw new Error(`请求失败: ${response.status}`); } return response.json(); }URLSearchParams会自动对参数做encodeURIComponent处理,空格转成%20,中文转成对应的UTF-8编码,不会再出现因为参数值里带&或#导致请求被截断的问题。
关于请求头,有一个很常见的认知偏差:fetch里的headers不是一劳永逸的。如果你需要设置自定义Header,比如Authorization,记得每次请求都要带上;如果你用的是Cookie鉴权,还要关注下一节要讲的credentials。另外,浏览器对自定义Header有CORS预检机制,跨域请求带自定义Header时,后端需要正确响应Access-Control-Allow-Headers,否则请求会在预检阶段就失败,这个点我后面会专门展开。
至于缓存策略,Fetch的cache选项是一个容易被忽略但很好用的参数。举个例子:
// 优先走缓存,缓存过期再发网络请求 const response = await fetch('/api/config', { cache: 'default' }); // 完全不读缓存 const response = await fetch('/api/config', { cache: 'no-store' }); // 刷新缓存 const response = await fetch('/api/config', { cache: 'reload' });注意,这个cache控制的是HTTP缓存层的行为,跟Service Worker的Cache Storage是两回事。如果你在做公共服务类页面,比如活动配置,用no-store可以避免用户看到旧数据;如果是实时性要求不高的数据,用default让浏览器正常走HTTP缓存校验,能省下不少无谓的请求。
2.2 POST与JSON:Content-Type这个坑,body序列化的取舍
POST请求里最经典的坑就是Content-Type和body格式不匹配。先说一个我调整过很多次的场景。
后端是一个SpringBoot项目,接口用@RequestBody接收JSON。前端一开始写成了:
fetch('/api/save', { method: 'POST', body: JSON.stringify(data) });结果后端一直报参数解析失败。为什么?因为你没告诉服务器你发的是什么格式的数据。JSON.stringify确实生成了一段JSON字符串,但浏览器不会自动帮你设置Content-Type,请求体就被默认成了text/plain;charset=UTF-8,SpringBoot不知道如何把纯文本解析成一个Java对象。
处理方式就是在header里显式声明:
fetch('/api/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) });反过来,如果你要提交一个表单格式的数据,两种常见写法:
第一种,用URLSearchParams,浏览器会自动生成application/x-www-form-urlencoded;charset=UTF-8:
const formData = new URLSearchParams(); formData.append('username', '张三'); formData.append('age', '18'); await fetch('/api/form', { method: 'POST', body: formData });第二种,用FormData,浏览器会自动生成multipart/form-data,适合包含文件的场景:
const formData = new FormData(); formData.append('file', fileInput.files[0]); formData.append('description', '用户上传的文件'); await fetch('/api/upload', { method: 'POST', body: formData });这里有个细节:如果你手动给FormData设置Content-Type: application/json,反而会出错,因为multipart/form-data需要浏览器自动生成的boundary分隔符。所以,用FormData时建议不要手动设置Content-Type,让浏览器自己处理。这是我一直提醒团队同学的一句话:body用什么格式,header就配什么Content-Type,配错了后端一定看不懂。
关于body,还有一个JSON序列化的取舍。常见的做法是直接JSON.stringify(data),但如果数据里有BigInt类型,JSON.stringify会直接报错。如果你确实需要传输超出JavaScript安全整数范围的数字,建议后端提供一个接收字符串类型的字段,前端把大数字转成字符串再放进JSON里。这个问题的解决方案很多,但核心原则是:前后端要对“什么样的数据格式是可传输的”达成共识,否则迟早要在联调阶段吵一架。
2.3 响应对象的正确打开方式:text/json/blob/arrayBuffer
fetch返回的response对象,本身并不包含业务数据。你得从它的body里“读”数据,至于怎么读,取决于响应的Content-Type。常用的读取方法有四个:
response.text():把响应体读成字符串,适合接口返回纯文本、HTML片段或者你想自己解析JSON的情况。response.json():把响应体解析成JSON对象,本质上是先读文本再JSON.parse,所以如果接口返回的不是合法JSON,它会抛错。response.blob():把响应体读成二进制对象,适合图片、音频、视频等资源文件。response.arrayBuffer():把响应体读成ArrayBuffer,适合需要做底层二进制操作的场景。
有一个高频报错我必须要讲清楚:response.json()报“Unexpected token < in JSON at position 0”之类的话。如果你是前端,连什么后端请先别慌,这通常意味着响应体的内容不是JSON,而是一段HTML。最常见的原因是后端返回了一个错误页面,比如404页面、网关超时页面、登录页重定向后的HTML。遇到这种问题,第一件事就是在Network面板看响应体的原始内容,它大概率能直接告诉你后端到底返回了什么。
还有一种情况是接口返回的JSON是有BOM头的,或者编码不是UTF-8,这时候response.json()也会解析失败。处理思路有两种:一是用response.text()拿到原始字符串,手动去除BOM或转码后再JSON.parse;二是让后端统一返回标准UTF-8 JSON。我更倾向第二种,因为前端的补丁代码只会让问题越来越隐蔽。
2.4 超时、取消:AbortController的完整落地
原生Fetch没有timeout属性,很多人第一次用就踩了这个坑:请求挂在那里半天不返回,页面转圈,用户拼命点按钮。解决办法是用AbortController手动取消请求,原理不复杂:创建一个AbortController,把它的signal传给fetch,然后调用abort()就能取消。
封装一个带超时的fetch,代码像这样:
function fetchWithTimeout(url, options = {}, timeout = 15000) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); return fetch(url, { ...options, signal: controller.signal }) .finally(() => clearTimeout(timer)); }注意,controller.abort()会抛出一个AbortError,在调用方的catch里要区分一下是超时还是网络错误,这样用户才能看到不同的提示,比如“请求超时,请重试”而不是笼统的“网络错误”。
AbortController还有一个实际应用:组件卸载时取消在途请求。比如用户在列表页点开一个详情,还没等接口返回就退出了,这时候如果请求响应回来再更新状态,就会触发“在已卸载组件上调用setState”的警告。解决办法就是在useEffect的清理函数里调用abort()。
我用React写一个示例,这是我在业务组件里常用的模式:
useEffect(() => { const controller = new AbortController(); let cancelled = false; fetch(`/api/detail/${id}`, { signal: controller.signal }) .then(res => res.json()) .then(data => { if (!cancelled) setDetail(data); }) .catch(err => { // 如果错误类型是AbortError,说明是主动取消,不需要提示 if (err.name === 'AbortError') return; setError('加载失败'); }); return () => { cancelled = true; controller.abort(); }; }, [id]);这套组合拳虽然代码量多了一点,但它是保障页面稳定性的基础。特别是在弱网环境下,用户一会儿切后台一会儿切回来,请求生命周期管理做得不好,很容易积累一堆无用请求,白白消耗流量和服务器资源。
3. 上传与下载场景实战:从代码包超限到真机调试报错排查
3.1 用Fetch做文件上传:FormData、进度与终止
文件上传可能是所有业务场景里最容易出“网络请求错误”的环节。用Fetch做文件上传,基础写法就是前面提到的FormData。但问题往往出现在大文件上传:一个几百MB的视频,用户等了一分钟,没有任何进度反馈,这体验是灾难级的。
Fetch原生不支持上传进度事件,但我们可以用ReadableStream来构造请求体,从而拿到上传的字节数。不过,这个方案实现起来比较复杂,需要自己处理流的切片和中断逻辑。我在项目中用的方案其实是一个包:axios或者umi-request,它们对上传进度有成熟的封装。但如果你坚持用原生Fetch,这里我给一个兼容方案:把上传进度埋在自己的业务逻辑里。
最简单的思路是分片上传。把一个大文件切成若干小片,每片用Fetch上传,每传完一片就更新一次进度。这样既能监听进度,又能做断点续传。下面是一个简化版的分片上传客户端,完整逻辑可以被设计成一个小工具类:
async function uploadInChunks(file, chunkSize = 2 * 1024 * 1024, onProgress) { const totalChunks = Math.ceil(file.size / chunkSize); let uploadedChunks = 0; for (let i = 0; i < totalChunks; i++) { const start = i * chunkSize; const end = Math.min(file.size, start + chunkSize); const chunk = file.slice(start, end); const formData = new FormData(); formData.append('file', chunk); formData.append('chunkIndex', i); formData.append('totalChunks', totalChunks); formData.append('fileName', file.name); const response = await fetch('/api/upload/chunk', { method: 'POST', body: formData }); if (!response.ok) { // 失败了重试当前分片,重试次数要控制 throw new Error(`第${i + 1}片上传失败`); } uploadedChunks++; onProgress(uploadedChunks / totalChunks); } // 通知后端合并分片 await fetch('/api/upload/merge', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileName: file.name, totalChunks }) }); }注意,分片上传需要后端配合,不是改前端就能单独完成的事。如果后端没做合并接口,那这个方案就用不了。实际项目中我建议先确认后端接口协议,再决定采用整包上传还是分片上传。另外,分片大小选2MB到5MB之间通常比较合适,太小会导致请求数太多、网络开销大,太大又失去了分片的意义。
3.2 真机调试报错“网络请求错误”:为什么这个场景特别容易翻车
热搜词里有一条很典型:“真机调试 error: 上传失败:网络请求错误”。这个报错我在开发小程序和混合App时遇到过很多次,但很多时候根本不是接口挂了,而是调试环境的问题。
先说最常见的几种原因。第一,真机调试时你的手机和电脑不在同一个局域网,或者后端接口绑定的是localhost。手机访问localhost实际上是访问手机自己,自然连不上你电脑上的服务。解决办法是让接口地址指向电脑的局域网IP,比如http://192.168.1.100:8080,并且确保后端服务监听了0.0.0.0而不是只有127.0.0.1。
第二,域名校验问题。很多小程序和App环境对网络请求有域名白名单限制,你在开发者工具里关闭了校验,但真机上白名单是强制生效的。如果你在真机上请求了一个不在白名单里的域名,就会被拦下来,报“网络请求错误”。遇到这种情况,要么把域名加入白名单,要么在调试阶段开启“不校验合法域名”的选项,具体开关位置因平台而异。
第三,HTTPS证书问题。真机调试时如果接口是https的但证书链不完整,或者用了自签名证书,iOS和Android的行为还不一样。Android可能直接请求失败,iOS有时候会弹证书信任窗但也会在代码层面报错。我的建议是:测试环境统一用HTTP,生产环境统一用HTTPS,避免在调试阶段被证书问题反复纠缠。
这里有一个实战细节:真机调试的报错信息往往不准确。很多封装框架会把底层错误统一转成“网络请求错误”,真正的错误原因被吞掉了。所以排查的时候不要只盯着报错文案,而是去抓底层错误。比如在小程序里,用wx.request失败后可以在callback里打日志,看看具体的statusCode和errMsg,有时候错误信息会明确告诉你“url not in domain list”或者“certificate has expired”。
3.3 代码包大小超过限制:构建产物和请求侧的配合排查
热搜词里还有一条:“(async upload fail error: 代码包大小超过限制”。这个报错通常出现在小程序或云端构建的场景,本质上是你的代码包体积超过了平台的上限要求。它跟Fetch API本身没什么直接关系,但会间接影响网络请求的加载耗时——包太大,用户下载JS和静态资源的时间就会变长,首屏请求自然也会变慢。
我在处理这类问题时,会把排查分成两个方向。第一个方向是代码侧:检查是不是把不该打进包里的东西放进去了。比如把一张几MB的图片直接import进了JS,或者把完整的第三方库全部引入了,而实际只用到了其中一个小函数。这就是我们常说的“构建产物分析”,用webpack-bundle-analyzer或者小程序自带的代码分析工具,一眼就能看出哪个模块占比异常。
第二个方向是请求侧:把静态资源从代码包里挪到CDN上。比如图片、字体、视频这些大文件,不应该在发布时不经过检查就塞进包。改成上传到CDN后,前端代码里引用CDN地址,既减小了代码包体积,又能利用CDN的缓存加速。举个例子,一个项目里如果有一张2MB的启动图,直接放着不管,打包后体积一定超限;改为CDN链路,代码包可能立刻缩水一半以上。
排查完体积超限,还要顺手把“上传失败:网络请求错误”这个报错一起看。因为当你把上传方式从本地直传改成CDN直传或后端中转时,网络请求的链路变了,很可能出现新的跨域问题、签名问题,或者证书问题。这些都是连带反应,所以我经常建议团队在排查体积问题时,把构建日志、上传日志和请求日志三条链路一起打,不要只盯着一处。
3.4 一个500MB文件上传的完整方案:分片+并发+幂等
前面讲了分片上传的基础版,但只适合小规模场景。如果你的产品真的要支持500MB甚至1GB的文件上传,还需要解决三个问题:并发控制、失败重试和断点续传。
首先说并发控制。如果按2MB一片,500MB就是250个分片。假设同时发出50个请求,后端和网络的瞬时压力都很高,很容易触发限流或超时。我通常会把并发数限制在3到5个,用简单的方法就能实现。一个可行方式是维护一个任务队列:
async function runWithConcurrency(tasks, limit) { const results = []; const executing = []; for (const task of tasks) { const p = Promise.resolve().then(task); results.push(p); if (tasks.length >= limit) { const e = p.finally(() => executing.splice(executing.indexOf(e), 1)); executing.push(e); if (executing.length >= limit) { await Promise.race(executing); } } } return Promise.all(results); }然后把每个分片的上传逻辑放进task里,传入并发数,就能控制同时上传的分片数量。
其次说失败重试。分片上传最怕网络抖动,一个分片失败会导致整个任务失败。我的做法是给每个分片做指数退避重试:第一次失败等1秒重试,第二次失败等2秒,第三次等4秒,最多重试3次。这个策略在实际弱网环境下表现很稳定。
最后说断点续传。后端在合并的时候需要对分片做校验。前端可以在每个分片上传成功后,把该分片的索引记录到本地存储里;如果中途失败,下次启动时先读取本地记录,跳过已经传完的分片。当然,更完备的方案是后端提供一个“查询已上传分片”的接口,前端据此决定跳过哪些分片。这个方案要前后端一起设计,不是单纯前端能解决的。
4. 常见错误与排查实录
4.1 报错“网络请求错误”的常见根因速查表
我把这些年遇到的“网络请求错误”根因整理成了一张速查表,基本覆盖了90%以上的场景。你在排查的时候可以先对照这张表,按图索骥:
| 报错场景 | 可能根因 | 排查建议 |
|---|---|---|
| 浏览器控制台报Failed to fetch | 跨域CORS配置错误 | 查看Network面板的预检请求,检查Access-Control-Allow-Origin |
| 真机调试报网络请求错误 | 域名白名单、证书问题、局域网IP不通 | 先抓底层errMsg,再看域名校验和证书 |
| 上传文件报网络请求错误 | 文件太大超时、后端限流、请求体类型错误 | 先看Network面板请求是否发出,再看响应时长 |
| 请求成功但response.json()报错 | 返回的不是合法JSON,可能是HTML错误页 | 在Network面板看响应体原始内容 |
| 请求报401/403 | Token过期、权限不足 | 在请求拦截器里统一做鉴权刷新逻辑 |
| 后端返回500 | 后端代码异常 | 看后端日志,不要在前端反复重试 |
| 请求永远pending | 服务器未响应、代理未生效、跨域预检未响应 | 看Network面板的耗时瀑布图,定位卡在哪一步 |
我自己排查这类问题有个习惯:先看Network面板,后看代码。网络面板能告诉你请求是否发出、请求头是否正确、响应状态码是什么、耗时集中在哪一段。很多时候,报错信息写得云里雾里,但Network面板是诚实的。
4.2 [object Object]是给前端看的,不是给用户看的
热搜词里那条“真机调试 error: 上传失败:网络请求错误 ([object object])”特别有意思,因为[object Object]正是很多前端新手在拼接错误信息时最容易犯的错误。你可能是这么写的:
catch (err) { toast('上传失败:' + err); }如果err是一个对象,直接和字符串拼接,就会得到[object Object]。用户看到这个提示,除了懵还是懵。正确的做法是:
catch (err) { const message = err instanceof Error ? err.message : JSON.stringify(err); console.error('上传失败详情:', err); toast('上传失败:' + message); }这里要养成一个习惯:给用户看的提示要友好,给自己看的错误要详尽。所以我通常会让错误对象包含code、message、details三个字段,details里存后端返回的原始错误信息。这样前端可以针对不同code做不同的用户提示,也能在日志里还原完整的问题现场,不至于拿到一个[object Object]无从下手。
4.3 跨域、代理、TLS证书在Fetch下的表现
跨域绝对是Fetch开发里最折磨人的一件事。浏览器出于安全策略,默认禁止跨域请求读取响应。但“禁止”不是一刀切,它有一套CORS预检机制。简单请求(比如GET、POST且Content-Type为text/plain)不触发预检,直接发请求;但如果你设置了自定义Header或用了application/json,就会触发预检。
所谓预检,就是浏览器先发一个OPTIONS请求,问后端:“我可以带这个Header、用这个Content-Type来请求你的接口吗?”后端需要返回对应的Access-Control-Allow-Headers、Access-Control-Allow-Methods等响应头,浏览器才会放行真正的请求。
实际开发中,我见过很多后端同学只配置了Access-Control-Allow-Origin,没有配置Access-Control-Allow-Headers,结果前端的自定义Header请求全部失败在预检阶段。排查方法很简单:打开Network面板,找到那个红色的OPTIONS请求,看看响应头是否缺少必要的字段。通常解决方式是在SpringBoot的WebMvcConfigurer里重写addCorsMappings方法,把允许的Header和Methods配置完整。这个配置还要注意一点:如果用了Spring Security,CORS配置和Security的过滤器链要能配合,否则预检请求可能在Security层面就被拦截了。
代理问题也值得单独说。本地开发时,前端跑在localhost:3000,后端跑在localhost:8080,如果不做代理,直接请求http://localhost:8080/api就一定跨域。常见的解决方案是用构建工具自带的devServer代理,比如Vite里配置server.proxy,Webpack里配置devServer.proxy。代理的好处是浏览器看到的请求是同源的,自然没有跨域问题,同时你还能在代理层做一些路径重写、Header注入的操作。
TLS证书问题在Fetch里表现为两种:一种是请求失败,控制台报证书相关错误;另一种是请求成功但浏览器地址栏有安全警告。对于开发阶段的本地环境,我见过不少团队直接用https://localhost,但本地没有有效证书,所以每次请求都会被拦截。妥善的做法是为本地环境生成自签名证书并在系统里信任它,或者直接用HTTP。如果是在真机上测试,更推荐用内网HTTP地址,把证书问题留到生产环境统一处理。
4.4 模拟网络请求:调试弱网和异常场景的三种方式
热搜词里“模拟网络请求”说得比较宽泛,但我想聊的是调试弱网和异常场景的三种实操方式。
第一种是浏览器自带的网络模拟。Chrome DevTools的Network面板有Throttling选项,可以模拟Slow 3G、Fast 3G等网络环境。我一般会在测试弱网场景时选Slow 3G,再看页面加载时的请求耗时和失败率。这种方式不用改代码,适合快速验证。
第二种是Mock Service Worker(MSW)。它可以在Service Worker层拦截请求,返回你预设的数据。如果你要模拟“上传失败:网络请求错误”这种异常场景,可以在MSW里针对某个上传接口直接返回500,或者直接抛出一个网络错误。这样做的好处是前后端可以并行开发,前端不依赖后端真实接口就能联调。
第三种是后端故障注入。如果你们用的是SpringBoot,可以临时写一个异常处理切面,让指定接口随机失败,看看前端的重试逻辑是否可靠。这个方式最接近真实线上故障,但实现成本较高,一般是压测或演练时才用。
5. 工程化进阶:重试、并发与日志
5.1 给Fetch封装一个看得见错误的客户端
原生Fetch用起来虽然舒服,但直接裸用还是有几个痛点:没有统一错误码、没有统一超时、没有统一鉴权头、没有统一的日志上报。所以我会在项目里封装一个httpClient,把公共逻辑收敛起来。
一个比较实用的封装,大概包括这么几个能力:
- 基础URL前缀拼接
- 请求头和鉴权信息的统一注入
- 超时控制
- 响应拦截与统一错误格式化
- 错误日志上报
- 可选的请求重试
下面是我在多个中后台项目里用过的一个简化版本:
class HttpClient { constructor(baseURL, options = {}) { this.baseURL = baseURL; this.defaultTimeout = options.timeout || 15000; this.maxRetries = options.maxRetries || 0; } async request(url, config = {}) { const fullUrl = this.baseURL + url; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), config.timeout || this.defaultTimeout); let currentRetry = 0; try { const response = await fetch(fullUrl, { ...config, signal: controller.signal, headers: this.buildHeaders(config.headers) }); const data = await this.parseResponse(response); return data; } catch (error) { if (currentRetry < this.maxRetries && this.shouldRetry(error)) { currentRetry += 1; return this.request(url, config); } throw this.normalizeError(error); } finally { clearTimeout(timer); } } buildHeaders(customHeaders) { const authToken = getTokenFromStorage(); return { 'Content-Type': 'application/json', ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}), ...customHeaders }; } async parseResponse(response) { const text = await response.text(); let data; try { data = JSON.parse(text); } catch { data = text; } if (!response.ok) { const error = new Error(data.message || `请求失败: ${response.status}`); error.status = response.status; error.data = data; throw error; } return data; } normalizeError(error) { if (error.name === 'AbortError') { error.message = '请求超时'; } return error; } shouldRetry(error) { // 只有网络错误或5xx错误才重试,4xx不重试 return error.name === 'TypeError' || (error.status && error.status >= 500); } }封装之后,业务代码就很简洁了:
const http = new HttpClient('/api', { timeout: 10000, maxRetries: 2 }); async function getUserInfo(userId) { return http.get(`/user/${userId}`); }这个封装还有一个好处:如果你想从Fetch切换到其他网络库,或者在后端增加统一的时间戳签名,只需要改HttpClient一个文件,业务代码完全不受影响。这种隔离设计我们在后端叫“防腐层”,在前端同样适用。
5.2 并发控制与请求重试:指数退避的落地
上一节提到的重试是简单重试,实际场景里我更推荐指数退避。原因很简单:如果服务端已经过载了,你立即重试只会让它更过载。指数退避的意思是第一次失败后等一段时间再试,每次等待时间翻倍,比如1秒、2秒、4秒、8秒。
用一个函数来实现:
async function requestWithRetry(url, options, maxRetries = 3) { let lastError; for (let i = 0; i <= maxRetries; i++) { try { return await fetch(url, options); } catch (error) { lastError = error; if (i === maxRetries) break; const delay = Math.min(1000 * Math.pow(2, i), 5000); await sleep(delay); } } throw lastError; } function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }但这里有个必须想清楚的问题:不是所有请求都适合重试。POST请求如果被重复执行,可能导致后端重复下单、重复扣款、重复创建记录。所以重试策略要区分请求类型:GET请求可以放心重试;POST请求要配合后端幂等设计才能重试。比较常见的幂等方案是前端在请求头里带一个Idempotency-Key,比如用UUID生成,后端根据这个Key判断请求是否已经处理过。话题虽然超出了Fetch本身,但如果你要在真实项目里做重试,这个知识点是绕不开的。
5.3 请求日志:源码里看不到的问题,日志里看得到
很多时候,用户报“网络请求错误”,你的第一反应是打开本地代码看逻辑,但很可能看半天也找不出问题。原因很简单——这个问题只在特定环境、特定设备、特定用户身上出现,你的开发环境复现不了。这个时候,请求日志就是你排查问题的最强底牌。
我习惯在请求的三个阶段都埋点:发请求前、响应回来后、报错时。日志内容包括:
request_id:每一次请求的唯一标识,可以用Date.now()加随机数生成url和method:完整的请求地址和方式request_body:请求参数,注意脱敏,不要把密码、token直接打出来status_code:响应状态码time_cost:请求总耗时error_message:错误信息user_id:当前用户ID
埋点代码很简单,在HttpClient里加一行console.info或者上报到日志平台即可。我见过不少团队把日志只停留在console阶段,但这只能在自己开发时用;线上用户的环境你是进不去的,所以必须把日志上报到远程日志平台,才能做聚合分析。
有一次我们线上有一个接口在某一时段突然报错率飙升,但后端监控显示接口正常。后来靠前端上报的日志发现,出问题的请求全部集中在某个旧版本App上,那个版本的Fetch实现有一个已知的bug:特定情况下请求头会被错误地序列化。这个结论,如果没有日志支撑,几乎不可能靠猜想到。
最后再分享一个我踩过三次的坑
说了这么多,最后聊一个我印象特别深的实战细节。有一次排查一个上传接口的“网络请求错误”,前端代码检查了很多遍都没问题,后端日志也没有收到请求。最后发现,问题出在请求头里:我们给上传接口统一设置了一个Content-Type: application/json,但请求体实际上是FormData。浏览器在构造multipart/form-data请求时,一旦检测到手动设置的Content-Type,就不会自动追加boundary参数。后端拿到的请求头像是Content-Type: application/json,自然无法解析上传的分片数据。
从那以后,我在代码规范里加了一条铁律:使用FormData时,绝对不能手动设置Content-Type。这个教训看起来很小,但它提醒我,网络请求的问题往往藏在“理所当然”的细节里。Fetch API虽然简单,但它的行为细节远比语法本身丰富。希望这篇文章能把你在Fetch上踩过的、还没踩过的坑都串起来,让你在下一个项目里少走几段弯路。
如果你现在正准备把老项目里的XHR切换成Fetch,我的建议是从最核心的GET和POST请求开始,先封装一层HttpClient,把超时、取消、错误处理都固化下来,再逐步迁移文件上传等复杂场景。不用急着一次改完,但方向对了,代码就能一步步变得更清爽。