先说结论:如果你还在用 XMLHttpRequest 或者封了一层又一层 AJAX 库来发请求,是时候认真看看 Fetch 了。它把网络请求这件事简化到了极致,代码干净、基于 Promise、浏览器原生支持,没有额外依赖。这些年我用 Fetch 处理过图片上传、断点下载、接口超时控制、请求取消这些场景,踩过不少坑,也积累了一些真正管用的技巧,整理成这篇文章分享给大家。
这篇文章会从 Fetch 的基础概念讲到完整实战,穿插大量我在真实项目中验证过的代码片段和避坑经验。适合刚接触前端不久、准备抛弃旧写法的开发者,也适合用了 Fetch 但遇到各种诡异问题的中级选手。读完你不仅能正常发请求,还能处理超时、取消、并发、错误分类这些进阶需求。
1. 为什么大家都在抛弃老写法、拥抱 Fetch
1.1 从 XMLHttpRequest 说起:到底麻烦在哪
前端开发者对 XMLHttpRequest(简称 XHR)应该不陌生,它确实能完成所有网络请求任务,但用起来实在啰嗦。一个最简单的 GET 请求,你要写事件监听、处理 readyState 状态码、判断 status 是否 200,还要手动解析 responseText。代码量不小,逻辑还很绕。
// 老式 XHR 写法:代码冗长,回调地狱 const xhr = new XMLHttpRequest(); xhr.open('GET', '/api/user', true); xhr.onreadystatechange = function () { if (xhr.readyState === 4) { if (xhr.status === 200) { console.log(JSON.parse(xhr.responseText)); } } }; xhr.send();这还只是 GET,如果再加上 POST、设置请求头、处理上传进度,代码量直接翻倍。更糟的是多层嵌套请求时很容易变成回调地狱,可读性和维护性都很差。我在某公司接手过一个老项目,里面有一串五层嵌套的 XHR 请求,每次改需求都像在拆炸弹。
1.2 Fetch 的核心优势:Promise、原生支持和干净语法
Fetch 从设计思路上就完全不一样,它基于 Promise,用链式调用的方式组织逻辑,代码结构扁平自然。浏览器自带,不需要引入任何第三方库。
// Fetch 写法:简洁直观,链式调用 fetch('/api/user') .then((res) => res.json()) .then((data) => console.log(data)) .catch((err) => console.error(err));更漂亮的是 async/await 的配合。
// async/await 风格:更像在写同步代码 async function getUser() { const res = await fetch('/api/user'); const data = await res.json(); console.log(data); }我第一次在项目里用这种写法的时候,感觉整个思路都清晰了。不用再去记忆 XHR 那一堆状态码,也不用为同一个请求的 load、error、abort 分别挂监听。Fetch 就把请求和响应拆成两个阶段,异常统一走 catch,设计非常干净。
1.3 Fetch 和 axios 怎么选:不是二选一,而是看场景
很多朋友会问:既然 Fetch 这么好,为什么还有那么多人用 axios?我的看法是,两者解决的问题有重叠,但定位不同。axios 提供的请求/响应拦截器、自动 JSON 转换、请求取消、错误分类这些能力,都不是 Fetch 默认自带的。如果你在大型项目里需要统一的请求层,axios 确实省事。但 Fetch 的优势也很明显,零依赖、体积小、浏览器原生支持,尤其适合小程序、轻量页面、边缘函数这类环境。我自己的习惯是:简单项目、Demo、学习场景直接用 Fetch;大型中后台项目用封装后的 Fetch 也完全够用,看你需要多省事。
2. Fetch 的底层设计:Response、Promise 和流
2.1 请求发出后,你拿到的是什么
很多人用 Fetch 遇到过同一个困惑:为什么fetch()返回的是一个响应对象,而不是直接返回数据?这就要说到 Fetch 的设计理念了。fetch()返回的是一个 Promise,这个 Promise 在收到响应头(HTTP header)时就 resolve 了,注意,是响应头到达时,不是 body 完全下载完。所以你拿到的是一个Response对象,body 还没真正读取。
const res = await fetch('/api/user'); console.log(res); // Response 对象:包含状态码、响应头等 const data = await res.json(); // 这一步才是读取 body 内容这个设计带来的好处是:你可以先检查状态码、判断内容类型,决定要不要读取 body。比如一个接口返回了 500,你就没必要再去读 body 了。我在项目里就是这么做的,先用res.ok判断,不满足条件直接抛错,省掉不必要的数据解析。
2.2 Response 对象里有什么:一次梳理清楚
Response对象是 Fetch 最核心的概念,它上面有一堆属性和方法。我挑最常用的列出来:
| 属性/方法 | 作用 | 注意点 |
|---|---|---|
res.ok | 布尔值,status 在 200-299 时为 true | 快速判断请求是否成功 |
res.status | HTTP 状态码,如 200、404、500 | 做精细化错误处理时用 |
res.statusText | 状态文本,如 OK、Not Found | 一般用不到,调试时看 |
res.headers | 响应头对象,可遍历 | 取 token、content-type 等 |
res.url | 请求的最终 URL | 重定向后可看到实际地址 |
res.json() | 读取 body 并解析为 JSON | 只能调用一次 |
res.text() | 读取 body 为字符串 | 解析 HTML、XML 时用 |
res.blob() | 读取 body 为二进制 Blob | 下载文件、图片时用 |
res.formData() | 读取 body 为 FormData | 不常见 |
注意,res.json()、res.text()这些方法只能调用一次。因为它们背后是流式读取,一旦读取过,body 就被消费完了。这个坑我踩过,在一个需求里既想拿 JSON 数据又想拿原始内容来调试,结果第二次读返回空,排查了半天才发现是流被消费了。
2.3 流式读取:大文件的处理思路
既然说到流,顺便提一个进阶场景。如果接口返回的是大文件,比如上百兆的视频或日志,res.json()这种方式会把整个内容一次性加载进内存,体验很差也容易崩溃。Fetch 的 body 本身是一个ReadableStream,可以分段读取。
const res = await fetch('/api/large-file'); const reader = res.body.getReader(); const decoder = new TextDecoder(); while (true) { const { value, done } = await reader.read(); if (done) break; console.log(decoder.decode(value, { stream: true })); }这种方式读取一点处理一点,内存占用非常平稳。我在做日志实时预览功能时就是用这个思路,服务端返回不断追加的日志流,前端边读边渲染,体验很好。不过这个玩法日常开发用得不多,知道有这回事,真遇到大文件下载时能想起来就够了。
3. Fetch 核心实操:从基础请求到完整项目落地
3.1 先把五种最常见的请求方式写熟
GET、POST、PUT、DELETE、PATCH 这五种请求几乎覆盖了日常开发的所有操作。用 Fetch 写起来很直观,我直接上代码。
// GET:默认方式,不需要额外配置 fetch('/api/posts?page=1') .then((res) => res.json()) .then((data) => console.log(data)); // POST:发送 JSON 数据给服务端 fetch('/api/posts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'Fetch 实战', content: '...' }), }) .then((res) => res.json()) .then((data) => console.log(data)); // PUT:更新整个资源 fetch('/api/posts/1', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: '修改后的标题' }), }); // DELETE:删除资源 fetch('/api/posts/1', { method: 'DELETE' }); // PATCH:局部更新 fetch('/api/posts/1', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ views: 99 }), });这里有个关键点要说透。我们常看到 POST 请求写headers: { 'Content-Type': 'application/json' },这个头告诉服务端发送的数据是 JSON 格式。如果你不写这个头,服务端收到的 body 可能会被解析成普通的文本流,很多后端框架会直接拒绝或解析失败。但 GET 请求一般不用显式设 Content-Type,因为 GET 没有 body,设置也没意义。
还有一个小细节:JSON 字符串里的键名顺序无所谓,但值必须是 JSON 支持的类型,undefined和函数会被忽略或转成null,这会导致数据丢失。我见过有人把Map或Set直接放进 JSON.stringify 里,结果发现变成了空对象,排查很久。如果是这些类型,先转成普通数组或普通对象再传。
3.2 表单提交和文件上传:Content-Type 的坑与解法
搞定了最基础的 JSON 请求后,表单和文件上传是另一个高频场景。有些后端接口要求的是application/x-www-form-urlencoded格式,有些要求multipart/form-data,这个必须分清楚。
URL 编码表单,用URLSearchParams这个内置对象来处理,浏览器会自动帮你编码:
const params = new URLSearchParams(); params.append('username', '张三'); params.append('age', '18'); fetch('/api/login', { method: 'POST', body: params, // 浏览器自动设置 Content-Type 为 x-www-form-urlencoded });文件上传用 FormData,这个我相信大家都接触过:
const formData = new FormData(); formData.append('file', fileInput.files[0]); formData.append('description', '项目文档'); fetch('/api/upload', { method: 'POST', body: formData, // 注意:不要手动设置 Content-Type });这里有个非常重要的经验:FormData请求绝不能手动设置Content-Type。因为 FormData 的 Content-Type 需要带上一个自动生成的 boundary 分隔符,类似multipart/form-data; boundary=----WebKitFormBoundaryxxxx,这个 boundary 是浏览器根据内容生成的,你要是手动写成multipart/form-data,等于丢掉了 boundary,后端根本解析不出来。我在群里看到过好几个人栽在这上面,统一症状就是后端报“上传数据为空”或“格式错误”。
3.3 请求头配置:认证、缓存和自定义头的正确姿势
项目中几乎每个接口都要带 token 认证,请求头配置就成了每天都要写的东西。Fetch 的 headers 参数除了传统对象写法,还支持Headers实例,后者功能更强。
const headers = new Headers(); headers.append('Authorization', `Bearer ${token}`); headers.append('X-Custom-Header', 'CustomValue'); // headers.set() 可以修改已有头的值 fetch('/api/secure-data', { headers }) .then((res) => res.json()) .then((data) => console.log(data));需要注意的是,有些请求头是受浏览器限制的,你手动设置也不生效。比如Cookie、Host、Origin,这些属于“受限头”,浏览器出于安全考虑不允许前端修改。我之前想手动覆盖 Referer 来做一个简单的统计埋点,发现根本不生效,后来是在服务端处理的。所以遇到这种情况,别在前端耗时间,直接找后端同事商量方案。
3.4 下载文件和图片:blob、URL.createObjectURL 的配合
在浏览器端实现文件下载,用 Fetch 加 blob 是最干净的做法。尤其是当你需要带认证 token 才能下载文件时,传统的<a href="/api/file">方式做不到,Fetch 就派上用场了。
async function downloadFile(url, filename) { const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` }, }); if (!res.ok) throw new Error('下载失败'); const blob = await res.blob(); const blobUrl = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = blobUrl; a.download = filename; a.click(); // 关键操作:创建完要立即释放 URL,节省内存 URL.revokeObjectURL(blobUrl); }这段代码里最后那行URL.revokeObjectURL(blobUrl)极其重要。很多人写完下载功能发现内存不断飙升,就是忘了释放 blob URL。但这里有个细节:如果先 revoke 再点击 a 标签,某些浏览器会下载失败,因为这时链接已经失效。所以实际顺序是先在内存中 click,然后再 revoke,或者用setTimeout延迟一下。我写的时候是在a.click()之后再执行 revoke,实测在 Chrome 和 Safari 里都没问题。
另外,需要从响应头读取文件名时,可以用res.headers.get('Content-Disposition')来解析。这个头通常长这样:attachment; filename="report.pdf",提取其中的文件名需要做一点字符串处理。我写过一个解析函数,处理了中文文件名用filename*=编码的情况,有需要可以直接参考:
function getFilenameFromDisposition(disposition) { // 优先处理 filename* 编码形式,它可以表示中文 const starMatch = disposition.match(/filename\*=/i); if (starMatch) { const encoded = disposition.split("'").pop(); return decodeURIComponent(encoded); } const plainMatch = disposition.match(/filename="?([^"]+)"?/i); return plainMatch ? plainMatch[1] : 'download'; }3.5 超时控制和请求取消:AbortController 是唯一解
Fetch 有一个很出名的毛病:默认没有超时限制!一个请求可能一直挂着,几分钟、几小时都不结束。用户看到的是白屏转圈,体验极差。解决办法就是AbortController,它用来真正阻止一个还没完成的请求。
const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 5000); try { const res = await fetch('/api/slow-data', { signal: controller.signal, }); const data = await res.json(); clearTimeout(timeoutId); console.log(data); } catch (err) { if (err.name === 'AbortError') { console.log('请求超时,已中止'); } else { console.log('请求失败', err); } }核心流程就是三步:创建 controller,把 signal 传给 fetch,在回调里调用 controller.abort()。中止的那一刻,fetch 的 Promise 会 reject,我们通过判断err.name是否是AbortError来区分「主动取消」和「接口报错」。
那如何区分超时和用户主动取消?可以用两个 AbortController 组合,或者用一个标记位来记录原因。我提供一个我一直在用的封装思路:先把超时 abort 标记为 timeout,再把用户取消标记为 cancel,在外层用变量区分。这个方法比较简单,代码也不复杂,下面讲封装的时候会写一版完整可用的。
4. 错误处理与拦截请求的完整思路
4.1 200 不等于成功:HTTP 标准错误要自己判断
这是 Fetch 跟 axios 最大的区别之一。axios 在网络正常但服务端返回 404、500 时,会走进 catch 分支。但 Fetch 不一样,只要服务器有响应(即使状态码是 500),fetch()这个 Promise 都会 resolve,不会走 catch。只有断网、DNS 解析失败这种「根本没拿到响应」的情况才会 reject。
const res = await fetch('/api/not-exist'); console.log(res.status); // 404 console.log(res.ok); // false // 什么异常都没抛,代码继续往下走所以,使用 Fetch 时判断请求是否成功,必须手动处理状态码。最基本的做法是判断res.ok,更精细的是根据res.status分类处理。
const res = await fetch('/api/secure-data'); if (!res.ok) { switch (res.status) { case 401: // token 失效,跳转登录页 window.location.href = '/login'; break; case 403: // 权限不足,提示用户 console.error('没有访问权限'); break; case 404: console.error('接口不存在'); break; case 500: case 502: case 503: console.error('服务器错误,稍后重试'); break; default: console.error(`未知错误 ${res.status}`); } throw new Error(`HTTP Error: ${res.status}`); } const data = await res.json();顺便说一下,res.status === 200和res.ok并不是一回事。res.ok表示状态码在 200 到 299 之间,涵盖 201、204 这些常见状态。如果你判断status === 200,遇到 204 No Content 时会漏掉成功分支。我一般直接用res.ok,只有需要精确区分的场景才会去看具体 status。
4.2 不是 JSON 的响应:别让 res.json() 给你捣乱
我遇到过一个特别隐蔽的坑:接口正常返回字符串,但代码用了res.json(),结果抛异常,然后整个请求被当成失败处理。后来发现是后端某个接口返回了纯文本,甚至有的网关在错误时会返回 HTML 页面。
稳妥的做法是先看Content-Type再决定怎么解析,或者统一封装一个解析函数:
async function parseResponse(res) { const contentType = res.headers.get('Content-Type') || ''; if (contentType.includes('application/json')) { return res.json(); } return res.text(); }这里多问一句为什么:res.json()一旦遇到非法 JSON,会抛出一个 SyntaxError,这个错误是发生在请求「成功」层面之后的,如果你不做异常捕获,它会导致页面出现未预期的错误,而且不好排查。我见过一个同事排查了半天,最后在控制台看到一行Unexpected token '<'才反应过来,原来是网关把 500 错误渲染成了 HTML,然后res.json()想去解析 HTML 必然失败。这就是典型的需要同时判断状态码和内容类型的案例。
4.3 全局请求拦截与统一错误提示:写一个 request 工具函数
真实项目里不可能每个请求都手动判断状态码、处理 401,太累了。所以我们需要一个统一的请求封装。下面这版我用了很久,几乎可以直接抄进项目:
async function request(url, options = {}) { const token = localStorage.getItem('token'); const defaultOptions = { method: 'GET', headers: { 'Content-Type': 'application/json', Authorization: token ? `Bearer ${token}` : '', }, }; const fetchOptions = { ...defaultOptions, ...options }; fetchOptions.headers = { ...defaultOptions.headers, ...options.headers }; // 超时与取消 const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); fetchOptions.signal = controller.signal; try { const res = await fetch(fetchOptions.url ? fetchOptions.url : url, fetchOptions); clearTimeout(timeoutId); if (!res.ok) { if (res.status === 401) { // 登出处理,跳转登录页 localStorage.removeItem('token'); window.location.href = '/login'; } throw new Error(`请求失败:${res.status}`); } // 204 等无内容响应不解析 body if (res.status === 204) return null; const data = await parseResponse(res); return data; } catch (err) { if (err.name === 'AbortError') { throw new Error('请求超时,请重试'); } throw err; } }这段封装解决了三个核心问题:自动携带 token、统一超时控制、统一错误处理。值得说明的是为什么 headers 要合并而不是覆盖。因为调用方可能只传了Authorization或某一个自定义头,如果直接整体覆盖,默认的Content-Type就丢了,某些接口会解析失败。我用了一个浅合并,调用方的 headers 优先于默认值,兼顾灵活性和可靠性。
4.4 并发请求与控制顺序:Promise.all 和串行遍历的区别
前端经常会遇到需要同时请求多个接口的情况。Fetch 天生基于 Promise,天然支持并发,配合Promise.all非常爽。
async function getPageData() { const [userInfo, userPosts, userComments] = await Promise.all([ fetch('/api/user').then((res) => res.json()), fetch('/api/user/posts').then((res) => res.json()), fetch('/api/user/comments').then((res) => res.json()), ]); console.log(userInfo, userPosts, userComments); }这种方式三个请求同时发出,用时是三个请求中最长的那一个,而不是三个加起来。但要注意Promise.all有一个特性:其中一个请求失败,整个 Promise.all 都失败,其他成功的数据拿不到。如果希望有一个失败不影响其他返回,可以用Promise.allSettled,它会等所有请求结束后,把每个请求的成功或失败状态都返回给你。
const results = await Promise.allSettled([ fetch('/api/a').then((res) => res.json()), fetch('/api/b').then((res) => res.json()), ]); results.forEach((result) => { if (result.status === 'fulfilled') { console.log('成功数据:', result.value); } else { console.log('失败原因:', result.reason); } });还有一种需求是多个请求必须依次执行,后一个依赖前一个的结果。这时候不要用forEach加async,因为 forEach 不会暂停等待。我用过一个比较优雅的写法:
async function sequentialRequests() { const ids = [1, 2, 3, 4, 5]; const results = []; for (const id of ids) { const data = await fetch(`/api/posts/${id}`).then((res) => res.json()); results.push(data); } return results; }for...of循环配合 await,会老老实实一个一个排着来,不用引入任何第三方并发控制库。
5. 实战踩坑与调试技巧:把 Fetch 用稳的最后一公里
5.1 CORS 为什么要后端配合:跨域不是前端能解决的
几乎所有用 Fetch 的开发者都会遇到一次 CORS 报错,控制台红字写的很吓人:Access to fetch at 'xxx' from origin 'yyy' has been blocked by CORS policy。我第一次遇到时还以为代码写错了,查了半天发现代码没问题,问题出在服务端没有返回对应的 CORS 响应头。
CORS 本身的含义是跨源资源共享,它是由服务端决定允许哪些来源访问的。浏览器在收到跨域响应后,会检查响应头里的Access-Control-Allow-Origin,如果这个头不包含当前页面域名,或者根本没有,浏览器就会直接拦掉响应,哪怕服务端已经返回了数据。这不是 Fetch 的特殊机制,而是整个浏览器安全模型的一部分。所以遇到 CORS 报错,正确做法是让后端配置:
Access-Control-Allow-Origin: * Access-Control-Allow-Methods: GET, POST, PUT, DELETE Access-Control-Allow-Headers: Content-Type, Authorization在开发环境中,你还可以通过配置本地代理来绕过跨域问题。比如用构建工具内置的 proxy 把/api前缀的请求转发到真实后端地址,这样浏览器看到的是同源请求,就不会有 CORS 拦截。我在一次对接第三方接口时,对方迟迟不改 CORS 头,我临时用代理解决,先把联调推进了下去,这个思路应急时很管用。
5.2 提交数据校验失败、字段丢失?多半是格式问题
我在这里汇总几个高频请求格式错误,这些情况我都真实遇到过:
一个情况是 content-type 写成了application/json,但 body 不是 JSON 字符串,而是对象本身,比如你直接写body: { name: '张三' },这会触发浏览器报body is not a string类型错误,请求根本发不出去。正确做法是JSON.stringify(...)。第二个情况是 form-urlencoded 格式不用 URLSearchParams,而是手动拼接字符串,比如'name=张三&age=18',中文没有做 encode,后端拿到的就是乱码。用URLSearchParams会自动处理编码问题。第三个情况是 FormData 手动设置了Content-Type导致 boundary 丢失,后端解析不到文件字段。这个上面专门讲过原因。第四个情况是 GET 请求带了 body,很多服务器会忽略甚至报错,GET 请求的参数应该拼在 URL query 上,用URLSearchParams或手动拼接都行。
一个小技巧:发送 JSON 的时候,确保JSON.stringify结果是个合法 JSON 字符串。可以在浏览器里直接跑console.log(JSON.stringify({a: undefined, b: () => {}}))看看输出,你会发现undefined和函数会被直接丢弃,返回{}。如果后端是强校验,这种静默丢弃会引发一些难以排查的数据缺失问题。
5.3 缓存问题:Fetch 的 cache 选项怎么调
Fetch 默认的缓存策略和浏览器行为一致,但如果你在做数据实时性要求很高的功能,比如取验证码、查最新订单状态,可能需要绕过 HTTP 缓存。常见做法是请求 URL 后加时间戳:
fetch(`/api/code?timestamp=${Date.now()}`)这种做法叫 Cache Busting,本质是通过改变 URL 让缓存命中失败。但 Fetch 本身也提供了更优雅的 cache 配置:
// no-store:完全不使用缓存,每次请求都到服务器拿新数据 fetch('/api/latest-data', { cache: 'no-store' });cache选项的取值有多个,比较常用的有default、no-store、reload、no-cache。我个人的经验是:需要实时数据的接口优先用cache: 'no-store',代码语义清晰,也比手动加时间戳更干净。不过这个字段的兼容性并不统一,某些浏览器对它的支持并不完善,所以在关键场景里我还是习惯保留时间戳方案作为兜底,双保险。
5.4 如何调试 Fetch 请求:这份经验可以让你少掉一半头发
网络请求调试是最常见的问题。我推荐优先打开浏览器开发者工具,点开 Network 面板,刷新页面或重新触发请求,找到对应请求名,然后看三个关键标签页。
Headers 标签里可以看请求 URL、请求方法、状态码、请求头和响应头。响应头里的 Content-Type 能告诉你返回数据是什么格式的,如果这里显示text/html,那前面res.json()解析失败的原因就找到了。Preview 标签可以直接预览解析后的 JSON 数据,很直观。但如果响应不是 JSON,浏览器可能会渲染成一个页面或乱码,这时就切换到 Response 标签看原始响应内容。
在 Fetch 和 XHR 列表里过滤请求也很实用,控制台顶部有这个栏,可以快速筛出 XHR/Fetch 类型。不要忘了,Promise 式请求的报错经常出现在 console 里,判定是否走了 catch 分支,也一直是排查重点之一。如果你发现控制台没有抛错但页面就是不渲染数据,可以先在 catch 里console.error('请求异常', err),很多诡异问题其实都是静默失败导致的。
5.5 兼容性考量:Fetch 在哪些环境需要小心
Fetch 在现代浏览器里已经全面可用,但如果你需要支持特别老的浏览器,比如 IE,那基本是无解的,Fetch 原生不提供任何降级方案,必须通过引入 promise polyfill 等方案来弥补,但兼容成本依然不低。我的建议是:新项目直接用 Fetch,但是要在入口文件里引入 polyfill 库;老项目如果已经用得挺稳定,就没必要强行迁移,毕竟 XHR 只是丑,不是不能用。
在小程序环境里,比如某些平台上,全局的fetchAPI 可能不是标准实现,或者行为有差异,调用前先typeof fetch === 'function'判断一下比较稳妥。Node.js 从 18 版本起也内置了全局 fetch,做服务端开发或者写脚本抓数据时可以直接用,不需要额外依赖。
5.6 服务端渲染(SSR)环境下的 Fetch 注意事项
SSR 项目里,Fetch 可能会同时在服务端和浏览器端执行,这就牵扯出几个特有的坑。服务端执行 Fetch 时没有浏览器的环境信息,比如 Cookie 不会自动带上,需要手动把 Cookie 从请求上下文传给 Fetch 的 headers。另外服务端和客户端对代理配置的处理完全不同,你在本地配的代理只对浏览器生效,服务端发请求时根本不走这一层。
我处理过的一个案例是某个 SSR 页面首屏数据请求超时,排查许久才发现是服务端直请求了内网 API 地址,而服务器无法解析这个内网域名。解决方法是在服务端入口配置自定义 DNS 解析或用环境变量注入目标地址。这类问题比较刁钻,提前了解能省去很多排查时间。
6. 一个完整封装示例:把 Fetch 变成你顺手的那把刀
讲了这么多理论和坑,最后直接给大家一个我在生产项目里真实使用的完整封装。这个版本既保持了代码的简洁,又覆盖了日常开发几乎所有需求。
const DEFAULT_TIMEOUT = 10000; class HttpClient { constructor(baseURL = '', defaultHeaders = {}) { this.baseURL = baseURL; this.defaultHeaders = defaultHeaders; } async request(path, options = {}) { const { timeout = DEFAULT_TIMEOUT, headers = {}, ...rest } = options; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); const token = localStorage.getItem('token'); try { const res = await fetch(this.baseURL + path, { ...rest, headers: { 'Content-Type': 'application/json', Authorization: token ? `Bearer ${token}` : '', ...this.defaultHeaders, ...headers, }, signal: controller.signal, }); clearTimeout(timer); if (!res.ok) { // 统一错误处理 const error = new Error(`HTTP Error ${res.status}`); error.status = res.status; throw error; } const contentType = res.headers.get('Content-Type') || ''; if (res.status === 204) return null; if (contentType.includes('application/json')) return res.json(); return res.text(); } catch (err) { clearTimeout(timer); if (err.name === 'AbortError') { throw new Error(`请求超时(${timeout}ms)`); } throw err; } } get(path, options = {}) { return this.request(path, { ...options, method: 'GET' }); } post(path, body, options = {}) { return this.request(path, { ...options, method: 'POST', body: JSON.stringify(body), }); } put(path, body, options = {}) { return this.request(path, { ...options, method: 'PUT', body: JSON.stringify(body), }); } delete(path, options = {}) { return this.request(path, { ...options, method: 'DELETE' }); } } // 创建实例,配置基础地址 const http = new HttpClient('/api'); // 使用 const user = await http.get('/user'); const newPost = await http.post('/posts', { title: '标题', content: '内容' });这段封装把重复的 token 携带、超时取消、错误判断、JSON 解析全都处理掉了,业务代码里只需要关注数据和错误。封装的时候我特别保留了一个能力:通过 headers 参数可以覆盖默认的 Content-Type。比如文件上传这种情况,你需要传 FormData 且不希望设置 JSON 头,直接在调用时传headers: {}就能把默认的 JSON 头的设置绕过。这个灵活性很重要,不能为了统一把所有场景都锁死。
如果还想增强,可以在request方法里加一个请求缓存、或者加一个重试机制。但我不建议一上来就搞大而全的框架,先保持精简,等团队真正需要了再加也不迟。
结尾
我个人在实际项目中的体会是,Fetch 的核心不在于它比 XHR 简单多少,而在于它的设计更接近网络请求的本质:请求发出,响应到达,数据解析,错误处理。把它理解成一个流程而不是一个函数,你就能顺理成章地处理超时、取消、并发、缓存这些高级需求。
最后再分享一个小技巧:遇到 Fetch 相关的问题,先到 Network 面板里看真实响应,别只盯着代码层。百分之八十的问题在 NetWork 面板里一眼就能看出来,是状态码不对、Content-Type 不对,还是根本没发出去。把这些基础功夫练扎实,Fetch 用起来会非常顺手。