写前端这些年,我见过太多人一提到网络请求就默认去装 axios,问原因只回一句“大家都这么用”。但浏览器自带的那套 Fetch API,其实才是离你最近、也最值得吃透的方案:原生支持 Promise,语法干净,不引第三方库就能覆盖绝大多数接口调用场景。问题在于,Fetch 的官方文档看起来很简单,真正落到项目里却到处是坑:POST 传 JSON 忘了加 Content-Type,服务端直接甩 400;接口明明返回 500,fetch 却不抛任何异常;超时、取消、跨域、上传进度,每个点都能让新手折腾一整天。这篇文章把我这些年实际项目里踩过的 Fetch 坑和沉淀下来的套路一次性捋清楚,给刚入门的朋友一份能直接抄作业的完整攻略,也给用了一段时间、但总在边界场景卡壳的同学查漏补缺。只讲我真正用过、验证过的方案,不绕弯子。
1. 为什么值得吃透Fetch:先从定位和选型说起
1.1 Fetch到底是个什么级别的API
Fetch API 是浏览器提供的原生全局方法,挂在 window 和 Worker 环境上,最早的完整实现可以追溯到 Chrome 42 时代,到今天所有现代浏览器都原生支持,不需要任何 polyfill 或第三方库。它设计的目标很明确:用一套基于 Promise 的简洁接口,替代过去那套以回调事件为主的 XMLHttpRequest。从 Node.js 18 开始,Node 也把 Fetch 作为原生能力引入,也就是说同一个 fetch 写法,前端和后端都能跑,这对做全栈或者写脚本的人来说非常友好。
但实际项目里很多同学对 Fetch 的态度是“用过但没吃透”——会用 response.json(),却不知道 response 只能消费一次;会发 GET,却搞不定 POST 的 Content-Type。原因在于 Fetch 的 API 设计是“基础用法极简,边界情况全靠自己”。理解了它的定位,你就知道哪些坑是设计使然,哪些坑是真踩出来的。
1.2 一张表看清Fetch、XHR、axios的差别
| 对比维度 | XMLHttpRequest | Fetch API | axios |
|---|---|---|---|
| 依赖情况 | 浏览器原生 | 浏览器与 Node 18+ 原生 | 第三方库 |
| 异步模型 | 回调+事件 | Promise | Promise |
| 数据解析 | 手动 responseType 处理 | 手动调用 json/text/blob | 自动解析 |
| 超时设置 | 原生 timeout 属性 | 需配合 AbortController | 内置 timeout |
| 取消请求 | xhr.abort() | AbortController | AbortController |
| 上传进度 | progress 事件 | 无原生事件,需走流 | onUploadProgress |
| 拦截器与实例 | 无 | 无,需自行封装 | 内置 |
这张表是我在实际项目里对照着踩出来的。最直观的感受是:Fetch 在“常规请求”这个领域完全够用,甚至比 axios 更轻;但一旦涉及到上传进度、统一拦截器这类工程化需求,原生 API 确实裸奔,得自己包一层。所以我不认为 Fetch 和 axios 是二选一的敌对关系,而是看你项目处在什么阶段。小程序、轻量页面、Serverless 函数、Node 脚本,基本直接上 fetch;大型中后台项目,如果团队已经习惯了 axios 的拦截器生态,也没必要强行换。
1.3 什么场景我依然会用axios
有几种情况我不会硬上 fetch:一是产品需要统计上传进度,fetch 没有原生进度事件,用流去模拟上传进度成本很高,此时 XHR 或 axios 更省事;二是项目需要统一 request 实例、拦截器、全局 loading 这类基建能力,axios 开箱即用;三是需要兼容 IE 这类远古浏览器。反过来,如果项目只是标准 JSON 接口,你完全可以用 fetch 加一个二十行的封装解决绝大部分问题,少一个依赖就少一分供应链风险,这也是我近几年越来越倾向用 fetch 的原因。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 基础请求的完整拆解:GET、POST、headers一次讲清
2.1 第一个GET请求:URL拼接别用字符串硬拼
先看最朴素的 GET:
javascript复制const response = await fetch('https://api.example.com/users?page=1&size=20');
const data = await response.json();
console.log(data);
这段代码能跑,但要提醒一个很快会踩到的坑:当查询参数里有中文、空格或特殊字符时,直接用模板字符串拼接极易出问题。比如 keyword 是“张三”,拼进 URL 后服务端收到的是乱码或者直接 400。正确做法是用 URLSearchParams:
javascript复制const params = new URLSearchParams({
page: 1,
size: 20,
keyword: '张三'
});
const response = await fetch(`https://api.example.com/users?${params.toString()}`);
URLSearchParams 会自动做 encodeURIComponent 编码,把“张三”编码成 %E5%BC%A0%E4%B8%89,服务端拿到的就是正常值。这个点看着小,但我见过不止一次线上因为中文参数没编码导致搜索功能偶发失效的案例。
2.2 POST JSON:Content-Type是第一个分水岭
POST 是新手翻车重灾区,十有八九出在请求头:
javascript复制const response = await fetch('https://api.example.com/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: '张三',
age: 28
})
});
这里有两个必须记住的规则。第一,body 必须用 JSON.stringify 转成字符串,直接传对象的话 fetch 会调用它的 toString,最终发出去的是 "[object Object]",后端根本解析不了。第二,请求头里必须显式声明 Content-Type: application/json,否则浏览器对字符串 body 默认使用 text/plain;charset=UTF-8,很多后端框架拿到这个 Content-Type 不会走 JSON 反序列化,于是 request.body 是空的,或者直接返回 400。我调试过不少“前端明明传了参数、后端却说没收到”的问题,最后根因基本都是缺了这一行。
2.3 headers与credentials:鉴权和跨域Cookie的关键
headers 可以传普通对象,也可以传 Headers 实例,两种写法我都验证过,效果一致。实际项目中更常见的是带 Authorization:
javascript复制fetch('https://api.example.com/user', {
headers: {
'Authorization': `Bearer ${token}`,
'Accept': 'application/json'
},
credentials: 'include'
});
credentials 这个字段决定跨域请求是否携带 Cookie。默认值是 same-origin,同源请求会带,跨域不带。如果你做的是单点登录、需要跨域携带会话 Cookie 的页面,必须显式设成 include。这里有个连带条件:后端响应的 Access-Control-Allow-Origin 不能是 *,且必须返回 Access-Control-Allow-Credentials: true,否则浏览器照样拦。这个坑我后面第七节会再详细展开。
3. Response对象:别把一切响应都当JSON处理
3.1 先看状态码,再决定怎么读数据
fetch 返回的 Response 对象包含 status、statusText、ok、headers、url 等字段。我建议养成一个习惯:任何请求进来,先判断 ok:
javascript复制const response = await fetch('/api/data');
if (!response.ok) {
throw new Error(`请求失败:HTTP ${response.status}`);
}
const data = await response.json();
这里的 ok 等价于 status 在 200 到 299 之间。很多教程为了省事直接 response.json(),这在接口落到 404 页面时才会暴露问题。更隐蔽的坑是:Response 的 body 只能被消费一次。如果你先调了 response.text(),再调 response.json(),第二次会直接抛异常。所以需要同时拿原文和解析结果时,先 text 再手动 JSON.parse:
javascript复制const text = await response.text();
try {
const data = JSON.parse(text);
console.log('接口JSON:', data);
} catch (e) {
console.error('接口返回的不是合法JSON,原文是:', text);
}
3.2 json、text、blob、arrayBuffer怎么选
| 方法 | 适用场景 | 注意事项 |
|---|---|---|
| response.json() | JSON 接口 | 非 JSON 内容会抛错 |
| response.text() | HTML、纯文本、JSON 原文 | 适合二次处理 |
| response.blob() | 图片、文件下载 | 配合 createObjectURL |
| response.arrayBuffer() | 二进制流、非 UTF-8 编码 | 需要手动解码 |
文件下载是 blob 的典型场景。我封装过一个导出 Excel 的公共方法:
javascript复制const response = await fetch('/api/export', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) {
throw new Error(`导出失败:HTTP ${response.status}`);
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'export.xlsx';
a.click();
URL.revokeObjectURL(url);
URL.createObjectURL 用完要 revokeObjectURL 释放,不然大文件多次导出会内存上涨。这个细节很多人会忽略,但长时间跑的前台页面上影响很明显。
3.3 中文乱码:一个TextDecoder解决历史遗留问题
正常接口都是 UTF-8,response.text() 没问题。但国内有些老系统返回的是 GBK 编码的 HTML
