如果你最近在写前端,大概率已经绕不开 Fetch API 了。无论是初始化页面数据、上传文件、拉取用户信息,还是跟后端做接口联调,现在的主流做法基本都统一到了 fetch 上。相比早年满地都是的 XMLHttpRequest(XHR),Fetch 用更简洁的语法、更贴近 Promise 的模型,把网络请求这件事从“回调地狱”里彻底拽了出来。
但真正用起来之后,你会发现 Fetch 并不是“照着文档写两行”就完事的东西。接口超时怎么办?并发请求怎么控制?请求取消了但后端还是收到了?在 Chrome 里明明页面加载了,Network 面板却看不到请求,问题出在哪?模拟器里发出去的请求,电脑上的抓包工具抓不到,又该怎么处理?这些才是实际开发里每天都会碰到的问题。
这篇文章我就从 Fetch API 的核心设计讲起,再结合我自己的真实踩坑经验,把请求调试、抓包定位、异常排查这些实操内容一次性讲透。无论你是刚接触前端的小白,还是已经被接口问题折磨过几次的开发者,应该都能从这里找到能直接用的东西。
1. 整体设计思路:Fetch API 为什么成了现代网络请求的默认方案
1.1 从 XHR 到 Fetch:它到底解决了什么问题
在很多老项目里,你依然能看到类似 xhr.onreadystatechange 的写法。XHR 本身并不是不能用,只是它的 API 设计停留在“事件回调”时代,代码一旦复杂起来,状态管理就非常痛苦。你得手动判断 readyState 是不是等于 4,再判断 status 是不是 2xx,再手动把响应文本 JSON.parse 一下。多个请求之间有依赖关系的时候,嵌套层级直接起飞。
Fetch 的核心设计思路,是把“发起请求”和“处理响应”拆成两个清晰的阶段,用 Promise 串联起来。fetch() 负责发请求,返回一个 Promise;拿到响应之后,你再决定怎么读 body。配合 async/await,代码读起来就像同步逻辑一样顺:
javascript复制async function getUser(id) {
const res = await fetch(`/api/user/${id}`);
if (!res.ok) {
throw new Error(`请求失败:${res.status}`);
}
return res.json();
}
这就解决了 XHR 时代最痛的两个问题:嵌套回调和错误处理不统一。Fetch 的错误处理逻辑也更符合直觉,网络层面失败(比如 DNS 解析失败、连接被拒绝)会直接 reject,而 HTTP 状态码非 2xx 并不会 reject,只会在 res.ok 上体现。这个设计让开发者必须主动去关心状态码,反而比 XHR 里“不管怎样都进回调”的机制更容易写出健壮代码。
1.2 一次请求的完整生命周期
理解 Fetch,不能只停留在“发请求、拿响应”这一步。一次完整的请求,从你在代码里调用 fetch() 开始,到响应体被读取完毕,中间会经历几个关键阶段:
- 请求构造阶段:
fetch(url, options)里的url和options会合并成一个Request对象。这里包括 method、headers、body、credentials、signal 等全部配置。 - 网络请求阶段:浏览器帮你处理 DNS 查询、TCP 连接、TLS 握手(HTTPS 下)、发送请求头、接收响应头。这些过程在 DevTools 的 Network 面板里都能看到对应的 Timing 信息。
- 响应读取阶段:
fetch()返回的 Promise 在收到响应头时就会 resolve,但响应体 body 这时候还没完全到达。你需要调用res.text()、res.json()、res.blob()等方法去读取 body。这一步非常关键,它意味着你可以在 body 还没下载完的时候就拿到状态码和响应头。 - 流式处理阶段(可选):通过
res.body可以拿到一个ReadableStream,实现真正的流式读取。这对大文件下载、SSE 场景下的数据处理特别有用。
很多新手容易踩的坑就在这里:以为 fetch() 返回了,响应数据就有了。实际上 res.json() 本身也是一个异步操作,如果漏掉 await,后面拿到的就是一个 Promise 而不是数据。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析:从基础用法到容易踩坑的请求配置
2.1 最基础的 GET 和 POST 到底应该怎么写
GET 请求最简单,不需要额外配置:
javascript复制const res = await fetch('https://api.example.com/posts');
const posts = await res.json();
但要注意,fetch 的默认 method 是 GET,如果你不传 options,它就只是发送一个不带 body 的 GET 请求。想在 GET 上带参数,直接拼 URL 或者用 URLSearchParams:
javascript复制const params = new URLSearchParams({ page: '1', size: '20' });
const res = await fetch(`/api/posts?${params.toString()}`);
POST 请求需要显式指定 method,并且设置 Content-Type。很多新手在这一步容易出问题:后端明明要求 JSON 格式,但前端只写了 method: 'POST',没设置请求头,结果后端解析不到 body,甚至直接报 415 错误。
javascript复制const res = await fetch('/api/posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: '测试文章',
content: '正文内容'
})
});
这里要特别强调:body 不能直接传对象。你传 { title: 'xxx' },它会先被转成字符串 [object Object],后端拿到之后根本没法解析。必须用 JSON.stringify() 序列化,这是 Fetch 和 axios 最大的差别之一,axios 内部帮你处理了,Fetch 需要你自己动手。你觉得 Fetch“难用”,其实很多时候就是这些细节没注意到。
2.2 Header、CORS 与凭证策略
请求头配置看起来简单,但实际开发中暗坑很多。先说大小写问题:Fetch 里的 Headers 是大小写不敏感的,Content-Type 和 content-type 效果一样,所以你不需要纠结这个。真正要紧的是跨域场景下,某些请求头会被 CORS 限制,浏览器会自动拦下,你在代码里设置也只会被忽略。
比如 Authorization 头,如果你从 http://localhost:3000 去请求 http://api.example.com,并且后端没在 CORS 响应头里允许 Authorization,那这个请求头根本不会发出去。就算发了,响应回来浏览器也会因为 CORS 报错拦截,你还是在控制台看到一个红色报错,但 Network 面板里其实能看到这次请求,只是响应没有暴露给 JS。
另一个高频点是 CORS 预检。当你的请求满足某些条件(比如自定义 Headers、非简单 method、带 body 的 JSON),浏览器会先发一个 OPTIONS 请求,用来询问服务器“我能不能这么干”。后端如果没正确处理这个 OPTIONS,你的 POST 请求就会莫名其妙失败。排查这类问题,第一反应必然是打开 Network 面板看有没有 OPTIONS 请求,以及它的状态码是多少。
关于凭证策略,Fetch 默认不会携带同源 cookie,更不会带跨域 cookie。如果你需要携带 cookie(比如登录态),必须在请求里设置:
javascript复制fetch('/api/user', {
credentials: 'same-origin' // 同源才带
});
fetch('https://api.example.com/user', {
credentials: 'include' // 跨域也带,需要后端配合 Access-Control-Allow-Credentials
});
这里有三种配置,实际测试中很多人分不清什么时候用哪个:
| 配置值 | 行为 | 典型场景 |
|---|---|---|
omit |
不携带任何凭证 | 公开接口,避免泄漏 token |
same-origin |
同源请求携带,跨域不携带 | 前端和后端同域名,但不希望跨域子域带 cookie |
include |
同源和跨域都携带 | 需要跨域共享登录态的 SSO 场景 |
需要注意,include 模式下,后端必须返回 Access-Control-Allow-Credentials: true,并且 Access-Control-Allow-Origin 不能是 *,必须指定具体域名。否则浏览器会直接拒绝把响应交给你的代码。我都记不清自己因为这个配置踩过多少次坑了。
2.3 请求体格式与序列化
很多后端接口要求的数据格式不一样,Fetch 的 body 可以接受多种类型,每种类型对应的 Content-Type 也不同。写代码之前,最好先和后端确认接口要什么格式,别自顾自地传 JSON。
| 数据类型 | 手动设置 Content-Type | 使用场景 |
|---|---|---|
JSON.stringify(data) |
application/json |
最常用,适合结构化数据 |
new URLSearchParams(data) |
application/x-www-form-urlencoded |
表单提交,键值对数据 |
FormData |
不手动设置,浏览器自动带 boundary | 文件上传、混合表单 |
Blob / 文件流 |
根据文件类型设置 | 二进制数据、文件上传、下载 |
普通字符串 |
text/plain |
纯文本数据 |
用 URLSearchParams 的时候有个小技巧:它可以直接把对象转成表单格式,也可以直接传入一个 FormData 实例,然后设置 Content-Type: application/x-www-form-urlencoded。不过现在很多后端框架都能自动解析 JSON 和表单,如果你非要传表单,建议先确认框架的解析规则,否则很容易出现“请求发出去了,但后端拿不到参数”的尴尬。
关于 FormData,我补充一点:在浏览器环境里,你把 FormData 传给 fetch,不要手动设置 Content-Type。因为一旦手写了 Content-Type: multipart/form-data,浏览器会自动追加一个 boundary 参数,你手写的那个会覆盖掉正确的 boundary,后端解析时反而失败。这个坑我在上传头像功能里真实遇到过,后来发现把 Content-Type 删掉就一切正常了。
3. 高级请求场景:并发、取消、超时与上传下载的完整处理
3.1 超时控制和请求取消
Fetch 原生没有超时机制,这是它和 axios 相比最容易被吐槽的一点。axios 里你直接配 timeout: 5000 就行,Fetch 需要自己用 AbortController 来实现。原理不复杂:AbortController 暴露了一个 signal 属性和一个 abort() 方法,你把 signal 传入 fetch,然后通过 abort() 就能中断请求。
javascript复制function fetchWithTimeout(url, options = {}, timeout = 5000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
return fetch(url, {
...options,
signal: controller.signal
}).finally(() => clearTimeout(timer));
}
这样封装之后,超时会抛出一个 AbortError,你在调用处用 try/catch 捕获,再根据错误类型决定怎么提示用户。这里有个细节:AbortController.abort() 之后,即使后端已经处理完请求,你这边拿到的是 AbortError,不会再有后续的响应处理。这个机制在“用户离开页面时取消请求”“取消重复提交”等场景特别有用。
我自己的习惯是做一个全局的请求取消管理:
javascript复制const pendingControllers = new Map();
function createCancelableFetch(key, url, options) {
if (pendingControllers.has(key)) {
pendingControllers.get(key).abort();
}
const controller = new AbortController();
pendingControllers.set(key, controller);
return fetch(url, { ...options, signal: controller.signal })
.finally(() => pendingControllers.delete(key));
}
这样切换页面、筛选条件变化时,可以统一把之前的请求全部取消掉,避免旧请求的响应覆盖新请求的数据。前端并发场景下,这个技巧能让状态管理干净很多。
3.2 并发请求处理策略
多个请求同时发出,并且都完成后才处理结果,这种场景太常见了。Promise.all 是首选方案:
javascript复制const [userRes, postsRes, commentsRes] = await Promise.all([
fetch('/api/user'),
fetch('/api/posts'),
fetch('/api/comments')
]);
const [user, posts, comments] = await Promise.all([
userRes.json(),
postsRes.json(),
commentsRes.json()
]);
但 Promise.all 有个问题:一个请求挂了,整个 Promise 直接进入 reject。如果你想“部分成功也能拿到结果”,可以用 Promise.allSettled:
javascript复制const results = await Promise.allSettled([
fetch('/api/user'),
fetch('/api/posts')
]);
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
console.log(`第 ${index} 个请求成功`, result.value);
} else {
console.log(`第 ${index} 个请求失败`, result.reason);
}
});
并发请求还需要考虑“不该真并发”的情况。比如你有一个接口同时被多个组件调用,每个组件都发一次请求,后端压力大,前端也容易拿到不一致的数据。这种情况下,可以做简单请求合并:
javascript复制let pendingPromise = null;
function getUser() {
if (!pendingPromise) {
pendingPromise = fetch('/api/user')
.then(res => res.json())
.finally(() => { pendingPromise = null; });
}
return pendingPromise;
}
这种“单例请求”模式在微前端、多 Tab 页签场景下特别实用,能明显降低重复请求数量。
3.3 文件上传、下载与进度感知
文件上传在 Fetch 里非常直接,把文件丢进 FormData 就行:
javascript复制const formData = new FormData();
formData.append('file', fileInput.files[0]);
const res = await fetch('/api/upload', {
method: 'POST',
body: formData
});
注意别手动设置 Content-Type,前面已经说过了。如果是多文件上传,循环 append 就行,后端用同一字段名可以收到数组。
下载文件稍微有点绕。最常见的需求是把后端返回的二进制流下载成文件。你首先要拿到 ArrayBuffer 或者 Blob,然后创建一个临时 URL:
javascript复制const res = await fetch('/api/file/download');
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = '文件名.pdf';
document.body.appendChild(link);
link.click();
URL.revokeObjectURL(url);
至于上传进度,Fetch 本身没有暴露上传进度事件。这意味着你要么改用 XHR(它提供了 xhr.upload.onprogress),要么借助第三方库,比如 axios 自带上传进度支持。如果项目已经重度使用 Fetch,又非要进度条,可以考虑用 XMLHttpRequest 单独封装上传模块,这没什么丢人的,工具就该选对的用。
3.4 请求调试的辅助手段
调试网络请求,我个人的建议是先在 DevTools 的 Network 面板里过一遍,再用抓包工具深入分析。Network 面板能帮你快速判断几个核心问题:请求有没有发出去、状态码是多少、耗时在哪一段、响应内容是什么。
具体到 Fetch 请求,你可以在 Console 里执行一段代码来验证:
javascript复制fetch('/api/test')
.then(res => res.json())
.then(data => console.log(data))
.catch(err => console.error('请求出错', err));
这会帮你在应用代码之外快速确认接口本身是否可用。另外,DevTools 里还能通过右键请求选择 “Copy as fetch”,快速生成一段 fetch 代码,方便你在 Console 里复现或者做二次调试。这个插件我基本每天都会用到。
4. 请求调试与常见问题排查:从浏览器到模拟器再到真机抓包
4.1 浏览器里为什么看不到网络请求
很多开发者遇到过这个问题:页面明明加载了,数据也显示出来了,但打开 Chrome 的 DevTools Network 面板,就是找不到对应的请求。排查这个问题的思路,我建议从下面几个方向入手。
第一,请求可能被浏览器缓存了。你可以勾选 Network 面板顶部菜单里的 “Disable cache” 选项,再刷新页面,看请求是否出现。如果出现了,说明是缓存策略的问题。第二,请求可能被 Service Worker 拦截了。现在很多前端项目引入了 Workbox 或者自定义 SW,如果在 SW 代码里做了缓存策略,请求根本不会真正发到网络,DevTools 的 Network 面板里可以看到 from ServiceWorker 的标识。第三,扩展插件可能会影响。Chrome 的某些广告拦截插件、隐私插件会屏蔽掉特定请求,这种情况建议你在无痕模式里测试。
如果请求确实发出去了,但数据没渲染出来,那问题往往不在“有没有请求”,而在“响应处理”。检查一下代码里的 res.json() 是否执行成功,检查返回值结构是对还是出错。有时候后端返回的是 204 No Content,你还在那 res.json(),那直接就会抛异常。
4.2 用抓包工具抓取模拟器网络请求
场景是这样:你手机上运行着 App 或者 WebView 页面,想看看它到底发了哪些请求、带了哪些参数。这时 Chrome DevTools 帮不上忙,得靠 Charles 这类抓包工具。
下面是我在实际项目中反复用到的 Charles + 安卓模拟器抓包流程:
- 获取电脑在局域网中的 IP 地址。Mac 上在“系统设置”->“网络”里能直接看到,Windows 用
ipconfig命令也能查到。 - 打开 Charles,确认 HTTP 代理功能开启。Charles 默认代理端口是 8888,你可以在 Proxy Settings 里看到。
- 打开安卓模拟器(比如 Android Studio 自带的 AVD),进入 WLAN 设置,长按当前连接的 WiFi,选择修改网络,把代理设置为手动,主机名填电脑 IP,端口填 8888。
- 回到 Charles,会弹窗提示有新的连接请求,点击 Allow。
- 在模拟器里访问任意 http 页面,Charles 里就能看到请求了。
但如果目标站点是 HTTPS,上面的步骤还不够。你需要安装并信任 Charles 的 CA 证书:在模拟器的浏览器里访问 http://charlesproxy.com/getssl,下载证书并安装。注意 Android 7.0 以上对用户证书默认不信任,如果 App 用的是 WebView 且开启了 usesCleartextTraffic 之外的严格模式,证书可能仍然无法生效。这时候可能要调整 App 的网络安全配置,允许调试证书。
我踩过最大的坑是:模拟器的网络模式和 Charles 的代理设置不匹配。有的模拟器默认使用 NAT 网络,从模拟器里访问电脑的 IP 可能走不通。这时候把模拟器的网络模式改成桥接(Bridge)模式,或者用模拟器自带的 “Android SDK Emulator” 的网络工具,才能正常连上抓包工具。
4.3 电脑上如何抓取同一网络下手机的网络请求
这个需求在 App 开发联调阶段特别常见。手机和电脑连同一个局域网,想用电脑上的 Charles 或 Fiddler 查看手机请求,思路其实和模拟器类似:设置手机 WiFi 代理指向电脑。
具体做法:
- 确保手机和电脑连的是同一个路由器,且网络互通。
- 电脑上打开 Charles,确认代理端口。
- 在 iPhone 或安卓手机上,进入 Wi-Fi 设置,找到当前网络,配置 HTTP 代理为手动,服务器填电脑的局域网 IP,端口填 8888。
- 手机浏览器访问一个 HTTP 页面,Charles 里应该能看到请求,此时表示抓包链路已通。
- 抓 HTTPS 需要安装证书。iPhone 下载证书后,还要在“设置”->“通用”->“关于本机”->“证书信任设置”里启用完全信任;安卓手机则需要在 Wi-Fi 高级设置里安装 CA 证书。
这里有一个很容易踩的坑:手机设置代理之后,如果 Charles 没开启,或者防火墙拦了端口,手机会直接上不了网。所以调试完记得把手机的代理设置恢复成“无”或者“自动”,不要一直开着代理跑业务测试,否则会出现“手机突然断网”的诡异问题。
另外一个非常实用的注意点:如果你用 Charles 抓包,手机的蓝牙连接和 WiFi 冲突可能出现弱网情况,建议调试时关闭蓝牙。另外,如果电脑装了多个抓包工具(比如 Charles 和 Fiddler 同时开),端口冲突会导致抓包失败,只保留一个工具最省心。
4.4 状态码速查与高频错误清单
实际联调时,看到状态码如果不能快速反应,会浪费很多时间。整理一份我常用的状态码排查速查表:
| 状态码 | 含义 | 常见排查方向 |
|---|---|---|
| 200 OK | 请求成功 | 数据是否正确解析 |
| 201 Created | 资源创建成功 | POST 请求常见,是否需要 Location 头 |
| 204 No Content | 请求成功但无响应体 | 不要调用 res.json() |
| 301/302 | 重定向 | 检查 Location 头是否合规 |
| 304 Not Modified | 命中缓存 | 缓存策略调整 |
| 400 Bad Request | 请求参数错误 | 检查 body 格式、字段名、类型 |
| 401 Unauthorized | 未认证 | token 是否过期、请求头是否带全 |
| 403 Forbidden | 无权限 | 后端权限校验逻辑 |
| 404 Not Found | 资源不存在 | URL 路径是否正确、路由是否匹配 |
| 405 Method Not Allowed | 请求方法不符合接口定义 | GET/POST 用反了 |
| 429 Too Many Requests | 请求频率超限 | 限流策略,防止暴力请求 |
| 500 Internal Server Error | 后端异常 | 重点查看后端日志 |
| 502 Bad Gateway | 网关错误 | Nginx 代理配置、后端服务是否存活 |
| 503 Service Unavailable | 服务不可用 | 后端负载过高或正在启动 |
| 504 Gateway Timeout | 网关超时 | 后端处理时间过长,考虑超时设置 |
其实在实际项目里,最让人头疼的不是这些明确的状态码,而是“请求成功了但数据不对”。比如接口返回 200,但 JSON 结构发生了变化,前端解析不到字段,页面直接白屏。这种问题用 Network 面板看响应内容一般就能定位。
4.5 几个真实的抓包问题排查记录
这里分享几个我真实遇到过的案例。
第一个是“Google Chrome 抓不到网络请求”。有一次朋友的项目在 Chrome 里调试,Network 面板几乎什么都看不到。后来发现是项目代码里注册了全局 beforeunload 事件,页面一加载就弹窗确认,导致 DevTools 的自动刷新被拦截,Network 记录没来得及刷新。关闭这个干扰后,一切恢复正常。所以说,遇到“抓不到请求”第一反应不一定是工具问题,也可能是页面行为问题。
第二个是“Charles 在 Android 模拟器里抓不到包”。那次因为模拟器的网络模式默认是 NAT,电脑 IP 在模拟器里根本 ping 不通。解决办法是把 AVD 的启动参数加上 -dns-server 8.8.8.8 -http-proxy http://电脑IP:8888,让模拟器直接带上代理启动,效果稳定很多。
第三个是“HTTPS 证书安装后仍然无法解密”。这是因为部分 Android 模拟器默认对用户证书信任有限制。我后来改用了一个更加干脆的办法:只在调试包里通过代码给 WebView 注入系统 CA 证书,或者把 Charles 证书安装到系统证书目录。打包的时候再换个正式配置,普通证书直接留在用户目录,不影响正式包。
这些排查过程都需要耐心,我个人的体会是:抓包工具不会骗人,Network 面板也不会骗人,大多数时候不是工具坏了,而是配置哪里没有对齐。
5. 尾声:关于 Fetch 和请求调试,我的几点实际操作心得
前面把 Fetch API 的写法和网络请求调试的流程都过了一遍,最后再说几个我平时特别注意的点。
第一,项目里建议封装统一的请求函数,不要到处裸写 fetch。我自己的封装会包含基础 URL、超时时间、错误码统一处理、请求取消逻辑,甚至加一个简单的请求日志开关。这样一旦接口出问题,我只需要看封装的日志,而不是翻遍每一处调用。
第二,抓包工具的证书安装永远是第一步,但也是很多人忽略的一步。很多人以为设置了代理就能看到 HTTPS 明文,结果抓下来全是乱码,最终浪费时间在乱码排查上。先在浏览器里访问一次 http 页面,确认链路通了,再进入 HTTPS 阶段。
第三,调试完一定要记得恢复手机或模拟器的网络设置。不止一次看到同事把手机代理忘关了,第二天跑到其他网络环境里发现连不上网,还以为是 Wi-Fi 出了问题,其实是指向电脑的代理端口已经失效了。
Fetch 本身不难,难的是把请求生命周期理解透,把调试工具用熟。这篇文章涉及的代码和流程,我基本都在项目里实测过。如果这里面的某个问题正好是你踩过的坑,那这篇内容就值了。
