带新人写前端,我几乎每次都会遇到同一个问题:项目里要用原生 HTTP 请求,绝大多数人脑子里第一个蹦出来的还是 XMLHttpRequest,甚至有人还会顺手 Google 一下用法,然后写出一堆 callback。不是说不该用 XHR,只是从 2015 年 Fetch 进入 Web 标准以来,以 Promise 为基础的请求方式已经足够成熟,学习成本也没想象中那么高。这篇文章就用 7 天的节奏,带你把 Fetch 从入门到实战过一遍,搞懂它和 XHR 的差异、响应处理、错误机制、上传文件和请求进度,读完之后你会发现日常请求基本不用再碰 XHR 了。
先说这个学习计划适合谁。如果你是刚接触前端不久的新人,或者从 jQuery 时代走过来、习惯用 $.ajax 的老开发,再或者你只是被“回调地狱”折磨过、想找一个更干净的请求写法,这套内容都很对口。它不要求你有很深的基础,但是如果你用过 Promise 或者 async/await,哪怕只是知道一点皮毛,整个上手过程都会顺畅很多。
1. 为什么要告别 XMLHttpRequest
1.1 XHR 的痛点在哪里
XMLHttpRequest 是 2000 年前后诞生的老接口,它解决了“页面不刷新就能和服务器通信”的问题,但它的设计带着浓重的早期浏览器时代色彩。用 XHR 发一个请求,你要先 new 一个实例,再 open、onreadystatechange、send,每一步都在一个全局状态机上打转。
javascript复制const xhr = new XMLHttpRequest();
xhr.open('GET', '/api/users');
xhr.onreadystatechange = function () {
if (xhr.readyState === 4 && xhr.status === 200) {
console.log(JSON.parse(xhr.responseText));
}
};
xhr.send();
这段代码的问题很明显:第一,可读性差,readyState === 4 这种魔数对新手极不友好;第二,当请求变多、需要串行/并行组合时,套着套着就变成嵌套回调,代码纵向疯长,后期根本没法维护;第三,处理错误需要额外挂 onerror、ontimeout,而且它的事件系统是绑在实例上的,没法像 Promise 那样方便地链式传递。
我在某些老项目里见过一个封装的 XHR 工具,为了处理下载进度、超时、取消请求、JSON 解析这些需求,代码堆到几百行,看起来像一座小堡垒。不是说 XHR 做不到这些,而是它把这些能力全部摊在了“事件 + 状态”上,使用者的心智负担很重。
1.2 Fetch 的设计思路:一切皆 Promise
Fetch API 是后来被纳入 HTML 标准的一套新接口,核心思想非常简单:fetch() 是一个函数,你给它一个 URL,它返回一个 Promise。Promise 能 then、能 catch、能配合 async/await,这意味着你终于可以用线性写法去表达“请求成功后做什么”。
这种设计带来的直接好处是组合能力强。两个请求要并行发出、再一起处理,用 Promise.all 就好;要串行请求,用 await 一行接一行,读代码的人看到的是顺序执行,不用去脑补状态机。此外,Fetch 把“请求”和“响应”拆成了独立对象,比如 Request、Response、Headers,职责更清晰,也符合现代 Web 的模块化趋势。
有朋友问过我一个很实际的问题:能不能用 Fetch 替代所有 XHR 场景?我觉得大多数场景可以,但有一个例外就是对上传/下载进度的监听,这一点后面会专门讲。认清边界,你才能做出更合理的选型。
1.3 一张表看清 Fetch 和 XHR 的差别
| 对比项 | XMLHttpRequest | Fetch |
|---|---|---|
| 返回值 | 实例 + 事件回调 | Promise |
| 链式处理 | 不支持,靠嵌套 | 支持 then/catch/async-await |
| 读取响应 | responseText / responseXML | 通过 text() / json() / blob() 等方法 |
| 错误处理 | onerror、onabort、status 判断等 | 网络错误 reject,HTTP 错误需手动判断 |
| 超时控制 | xhr.timeout 属性 | 依赖 AbortController |
| 上传进度 | upload.onprogress 可直接监听 | 原生不支持,需要借助其他方案 |
| 默认携带 Cookie | 同源请求默认带 | 需要显式配置 credentials |
提示:并不是说“新的就是绝对好的”。如果你手头的项目已经有一层稳定的 XHR 封装,没有必要为了换而换。但如果你想降低新代码的复杂度、减少心智负担,Fetch 是更值得主动拥抱的选项。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 第 1 天:fetch 的基础结构与第一个 GET 请求
2.1 fetch(url) 到底返回了什么
很多人第一次用 fetch 时会踩同一个坑:以为 fetch(url) 返回的是数据,结果打印出来是一个“怪怪的对象”。其实 fetch() 返回的是一个 Promise,而这个 Promise 在成功时会 resolve 成一个 Response 对象。
javascript复制fetch('/api/users');
上面这行代码不会直接给你数组或者对象,它只是“发起了一个请求”,然后给你一个承诺:我将来告诉你服务器有没有回应。你可以这样拿到结果:
javascript复制fetch('/api/users')
.then(function (response) {
console.log(response); // Response 对象
});
在浏览器的 Network 面板里,你会看到请求确实发出去了,这就对了。Response 并不是最终数据,它更像是服务器返回的“信封”:里面有状态码、响应头、正文数据,你需要自己“拆信封”。
2.2 Response 对象的关键属性
Response 对象有几个东西你必须记牢:status 是 HTTP 状态码,200 表示成功,404 表示资源不存在,500 表示服务器出错;ok 是一个布尔值,相当于 status >= 200 && status < 300;headers 是响应头对象,可以读取服务器返回的自定义头。
javascript复制fetch('/api/users')
.then(function (response) {
console.log(response.status);
console.log(response.ok);
});
需要注意的是,Response 的正文是一个“可读流”(ReadableStream),你不能直接把它当字符串或者对象用。为了拿到正文内容,Fetch 提供了一系列方法:text() 拿字符串,json() 直接解析成 JSON,blob() 拿二进制数据。这些方法也都返回 Promise,所以记得继续 then 或者 await。
有同学会好奇,为什么 Fetch 要把正文设计成流?因为这样能在数据没有完全到达时就开始处理,比如做流式渲染,也方便大文件处理。虽然平时我们用得不多,但这个设计对性能提升是有实际作用的。
2.3 第一个完整的 GET 请求
现在写一个最简单的 GET 请求,假设后端接口是 https://api.example.com/users,返回一个用户数组。
javascript复制async function loadUsers() {
const response = await fetch('https://api.example.com/users');
const users = await response.json();
console.log(users);
}
loadUsers();
用 async/await 之后,整个流程就像在写同步代码:fetch 发请求,response.json() 解析,然后打印。这里有一个新手极易忽略的点:我只判断了“请求有没有成功返回”,但没判断“HTTP 状态是不是 OK”。如果接口返回 404,response.json() 可能解析出来的是一段错误提示文字,而不是你要的数据。正确写法应该是加一个拦截:
javascript复制async function loadUsers() {
const response = await fetch('https://api.example.com/users');
if (!response.ok) {
throw new Error('请求失败:' + response.status);
}
const users = await response.json();
console.log(users);
}
后面讲错误处理的时候,这个 if (!response.ok) 会频繁出现,你可以把它当作一个固定模板。
3. 第 2~3 天:数据格式、JSON 解析和 query 参数拼接
3.1 text、json、blob 怎么选
Response 对象的三个读取方法,对应三种使用场景:
text():拿到纯字符串。适合接口返回 HTML 片段、纯文本、JSON 字符串等,也可以先text()再自己解析。json():帮你在内部完成JSON.parse,返回对象/数组。接口是 JSON 格式时最常用。blob():拿到二进制数据,适合图片、音视频、文件下载。
举个实在的例子。图片接口返回的是二进制图片,你需要把它变成可预览的 URL:
javascript复制const response = await fetch('https://api.example.com/avatar.png');
const blob = await response.blob();
const url = URL.createObjectURL(blob);
document.querySelector('#avatar').src = url;
这里要注意的是,这三个方法只能调用一次,因为流一旦被消费就没有了。我见过有人先 await response.json() 发现格式不对,想再 await response.text() 看原始内容,结果报错——流已经被读走,第二次读取拿不到东西。遇到这种情况,你只能在第一次读取时做好决定,或者用 response.clone() 复制一份再读。
3.2 用 URLSearchParams 拼 query 参数,别再用字符串拼接
GET 请求最常见的需求就是带参数,比如分页、搜索关键字。我以前见过很多人这样写:
javascript复制const page = 2;
const keyword = '张三';
fetch(`https://api.example.com/users?page=${page}&keyword=${keyword}`)
这样写有两个问题:第一,如果 keyword 包含特殊字符,比如 & 或中文,URL 会变得不安全;第二,参数一多,字符串模板连到眼花。使用 URLSearchParams 是更稳妥的方案:
javascript复制const params = new URLSearchParams({
page: 2,
keyword: '张三'
});
const url = `/api/users?${params.toString()}`;
params.toString() 会自动做 URL 编码,张三 会变成 %E5%BC%A0%E4%B8%89,服务器收到的才是正确值。这个方法写起来干净,也避免了中文乱码和特殊字符串冲突的问题。
3.3 拿到响应数据之后的常见处理套路
在实际项目里,后端接口通常有统一返回格式,比如:
json复制{
"code": 0,
"message": "success",
"data": [...]
}
前端拿到 response 后,不能直接认为 json() 解析出来的就是数据本身,你得先看业务码。这里有个容易混淆的地方:HTTP 状态码是 200,不代表业务一定成功,业务可能返回 code: 1 表示参数错误。所以我会习惯性地在封装里统一处理:
javascript复制async function request(url, options) {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error('HTTP ' + response.status);
}
const result = await response.json();
if (result.code !== 0) {
throw new Error(result.message || '业务异常');
}
return result.data;
}
这样调用方拿到的就是真正的 data,而不是外面那层壳。这种“先判断 HTTP 状态,再判断业务状态”的层次,是项目里最容易出问题的地方,值得在第二天的时候就建立思维模型。
4. 第 4~5 天:POST 请求、请求头和 CORS
4.1 fetch 的第二个参数 init 到底能配什么
fetch(url, options) 的第二个参数 options(通常叫 init)是控制请求行为的核心。常用的配置项有 method、headers、body、mode、credentials、cache、signal。
| 配置项 | 说明 | 常见值 |
|---|---|---|
| method | 请求方法 | GET、POST、PUT、DELETE、PATCH |
| headers | 自定义请求头 | 对象或 Headers 实例 |
| body | 请求体 | 字符串、FormData、Blob 等 |
| mode | 请求模式 | cors、same-origin、no-cors |
| credentials | 是否携带 Cookie | same-origin、include、omit |
| signal | 与 AbortController 配合 | AbortSignal 实例 |
一个完整的 POST 请求可以这样写:
javascript复制const response = await fetch('https://api.example.com/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: '小明',
age: 18
})
});
这里我要特别讲一下 Content-Type。如果你发送的是 JSON 字符串,Content-Type 要设置成 application/json;如果你用的是 FormData 对象,那千万别手动设置 Content-Type,浏览器会自动生成带 boundary 的 multipart/form-data。手动设置了反而会让后端解析失败。
4.2 发送 JSON 和发送表单数据是两件事
很多后端同学在联调时抱怨“前端传的数据我收不到”,最常见的原因就是把 JSON 和 FormData 混着传。JSON 是一种文本格式,后端需要按 JSON 解析器来读;FormData 是表单编码,后端按字段接收。
JSON 的 POST 写法前面已经给过,FormData 的写法是另一个套路:
javascript复制const formData = new FormData();
formData.append('name', '小明');
formData.append('age', 18);
const response = await fetch('https://api.example.com/users', {
method: 'POST',
body: formData
});
如果前后端都在同一个项目里,建议做个约定:接口用 JSON 就用 JSON,统一写 Content-Type: application/json;接口要收文件或者传统表单,就交 FormData。不要今天用 JSON、明天用 application/x-www-form-urlencoded,后端同学真的会崩溃。
4.3 CORS 和同源策略,你得知道它为什么存在
刚学 Fetch 的时候,很多人会遇到一个跨域报错:浏览器控制台提示 CORS policy,然后直接懵了。这里明确一点:跨域问题是浏览器出于安全考虑设的限制,不是 Fetch 独有的,XHR 同样会遇到。
所谓同源,指的是协议、域名、端口三者一致。只要有一个不一致,浏览器就会认为这是一个跨域请求,默认情况下不允许前端读取响应。要解决跨域,后端需要返回 Access-Control-Allow-Origin 等响应头。前端能在 fetch 里配的,主要是 mode: 'cors' 和 credentials。
如果你只是在本地开发,前端和后端不在同一个端口,最简单的调试办法是在开发环境配一个代理,让浏览器看到的请求是同源的。这一步很多脚手架都已经内置,比如常见构建工具里的 devServer 代理配置,解决好之后就不用纠结跨域了。
注意:跨域不是 Fetch 的 bug,而是浏览器的安全边界。不要试图在前端绕过它,正确做法是让后端接口开放合理的 CORS 策略,或者在开发环境使用代理。
5. 第 6 天:错误处理与请求超时的正确姿势
5.1 网络错误 ≠ HTTP 错误,这个坑坑了很多人
用 XHR 的时候,状态码判断是刻在骨子里的习惯,到了 Fetch 反而容易忽略 HTTP 错误。这里需要强调一个核心差异:fetch 只有在网络层面失败时才会 reject,比如断网、DNS 解析失败、请求被取消;但服务器返回 404、500 这类 HTTP 错误时,fetch 仍然会正常 resolve。
这意味着你不能只写一个 try/catch 就以为万事大吉。看下面这段经典错误代码:
javascript复制try {
const response = await fetch('/api/nonexistent');
console.log(response); // 依然能打印,不会进 catch
} catch (err) {
console.log('请求失败', err); // 断网才会到这里
}
正确的处理必须包含 response.ok 判断,这也印证了前面反复强调的模板。日常封装中,我会把 HTTP 错误当作异常向上抛,让调用方统一用 try/catch 捕获。
5.2 用 AbortController 实现超时和取消
原生 fetch 并没有 timeout 配置项,但你可以通过 AbortController 手动控制。思路很简单:创建一个 AbortController,把它对应的 signal 传给 fetch,然后在指定时间后调用 abort()。
javascript复制const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 3000);
try {
const response = await fetch('https://api.example.com/slow', {
signal: controller.signal
});
const data = await response.json();
} catch (err) {
if (err.name === 'AbortError') {
console.log('请求超时');
} else {
console.log('请求失败', err);
}
} finally {
clearTimeout(timeoutId);
}
这里有个容易被忽略的小点:用了 abort() 后,catch 里拿到的错误对象 name 是 AbortError,你要单独判断一下才能区分是“超时”还是“网络错误”。如果项目中经常用到,建议封装成一个带 timeout 参数的请求工具函数。
实际开发中,我突然想提醒一个场景:给上传/下载请求设置超时要小心,大文件传输本身就需要较长时间,盲目加一个 3 秒超时可能会误杀正常任务。超时方案更多适合普通接口调用。
5.3 封装一个适合日常使用的 request 函数
如果你要把 Fetch 用进项目,最好在它外面包一层自己团队的 request。基本能力包括:URL 前缀、超时、统一错误处理、业务码判断、JSON 自动序列化。
javascript复制async function request(url, options = {}) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), options.timeout || 10000);
const config = {
method: options.method || 'GET',
headers: options.headers || {},
signal: controller.signal
};
if (options.data) {
if (options.form) {
config.body = options.data;
} else {
config.headers['Content-Type'] = 'application/json';
config.body = JSON.stringify(options.data);
}
}
try {
const response = await fetch(url, config);
if (!response.ok) {
throw new Error('HTTP ' + response.status);
}
const result = await response.json();
if (result.code !== 0) {
throw new Error(result.message || '业务错误');
}
return result.data;
} catch (err) {
if (err.name === 'AbortError') {
throw new Error('请求超时');
}
throw err;
} finally {
clearTimeout(timeoutId);
}
}
后续所有页面调用,就可以简化为:
javascript复制const users = await request('/api/users', {
method: 'GET'
});
封装的意义不是炫技,而是把容易写错、容易重复的逻辑收敛到一个地方。项目大了以后,你改一处超时时间,全站生效,这种收益会越来越大。
6. 第 7 天:文件上传与进度显示
6.1 用 fetch 上传文件其实很简单
文件上传在 FormData 场景下并不复杂,关键是理解 FormData 可以直接拿 <input type="file"> 里的 File 对象。
javascript复制const fileInput = document.querySelector('#fileInput');
async function uploadFile() {
const file = fileInput.files[0];
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/upload', {
method: 'POST',
body: formData
});
if (response.ok) {
console.log('上传成功');
} else {
console.log('上传失败');
}
}
FormData 是浏览器提供的表单数据载体,它不仅能传文件,也能附带其它字段。比如给后端附带一个说明字段:
javascript复制formData.append('description', '这是一张测试图片');
后端只要按普通表单字段处理即可拿到这些额外的键值对。
6.2 进度条:Fetch 做不到,但可以绕
这是标题里“彻底告别 XMLHttpRequest”需要打折扣的地方。XMLHttpRequest 有一个 upload.onprogress 事件,可以实时拿到上传进度;而 fetch 的原生 API 目前没有对应的上传进度回调。如果你非要进度条,有几个方向:
- 上传进度需求不强:暂时不做,只显示 loading 文案。
- 必须做进度条:这部分逻辑仍然用
XMLHttpRequest,或者用已经封装好的三方库。 - 不显示真实百分比,只显示“上传中”状态,等接口返回成功后再变化。
我看到很多项目的做法是“主力请求用 fetch,上传进度这种特殊场景才用 XHR”。这样并不是“背叛” Fetch,而是合理选型。技术选型永远看场景,不用为了“新”而给自己添堵。
6.3 一个带 loading 状态的上传示例
为了让你直接用得上,我提供一个稍完整的组件式写法:
javascript复制const uploadBtn = document.querySelector('#uploadBtn');
const statusText = document.querySelector('#statusText');
uploadBtn.addEventListener('click', async () => {
const fileInput = document.querySelector('#fileInput');
if (!fileInput.files.length) {
statusText.textContent = '请先选择文件';
return;
}
statusText.textContent = '上传中...';
const formData = new FormData();
formData.append('file', fileInput.files[0]);
try {
const response = await fetch('/api/upload', {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error('上传失败');
}
statusText.textContent = '上传成功';
} catch (err) {
statusText.textContent = '上传失败:' + err.message;
}
});
这个示例在“上传按钮点击 → 校验文件 → 显示上传中 → 发送 → 反馈结果”这条链路上是完整的,新人可以直接拿去做修改。后续想加更多交互,可以自行扩展。
7. 踩坑记录与实战避雷清单
7.1 fetch 默认不携带 Cookie,这个容易翻车
很多做登录状态校验的项目,在迁移到 Fetch 后发现“虽然请求发了,但后端认为我没登录”。原因很可能就是 credentials 配置没设置。Fetch 默认行为是 credentials: 'same-origin',但如果你请求的是跨域接口,或者后端要求每次请求都携带认证信息,你需要显式设置:
javascript复制fetch('https://api.example.com/users', {
credentials: 'include'
});
同源请求一般用默认即可,但跨域 + 需要 Cookie 的场景,include 会成为关键开关。前端一个小配置的失误,会让后端同学查半天日志都找不到问题。
7.2 重定向和缓存行为需要了解
fetch 默认会跟随重定向,也就是说服务器返回 302 时,浏览器会自动跳转并拿到最终的响应。你可以用配置项去控制,redirect: 'follow' 是默认值,redirect: 'manual' 表示不自动跟随。手动模式场景比较少,但如果做文件下载或需要读取重定向头的时候,你可能会用到。
缓存方面,fetch 默认缓存策略和浏览器普通请求类似,但如果你想强制刷新缓存:
javascript复制fetch('/api/users', {
cache: 'no-store'
});
这个配置在调试阶段尤其好用,可以避免因为缓存导致的“代码改了但页面还是老数据”。
7.3 兼容性:现代浏览器都行,老环境要留退路
Fetch 的兼容性在现代浏览器里已经没有太大问题,但如果你的用户群还在使用特别老的系统浏览器,或者需要兼容到多年前的移动端浏览器,建议先查一下目标环境。一个务实的做法是:确认协议期内的浏览器支持情况,如果确实不支持,可以在项目里补一个补丁包,或者该场景继续用 XHR。
我发现很多团队在兼容层面踩过同一个坑:一上来就全量替换,结果用户反馈页面打不开,最后只能回退。正确姿势是渐进式替换:新代码用 fetch,老模块不动,等确认没问题再去重构。
7.4 和 axios 这类库的关系
聊到 Fetch,免不了有人问“都有 axios 了,为什么还要学 Fetch”。我的看法是:axios 内部在不同环境会使用不同的实现,浏览器里通常还是基于 XMLHttpRequest 封装。它的优势在于简洁的 API、拦截器、取消请求、全局配置等。但无论用哪个库,理解原生 Fetch 能帮你更快看懂库的源码和文档、排查底层问题,尤其是遇到响应拦截器不生效或者请求头怪异篡改的情况。
依赖越少,越需要你对底层足够熟。我不是让你丢掉 axios,而是建议你把 Fetch 当成基本功之一,这对你读框架源码和解决复杂问题会有实打实的帮助。
7.5 跨域、代理和本地联调的小建议
在本地开发环境,最常见的情况是前端跑在 localhost:3000,后端跑在 localhost:8080,两者端口不同,属于跨域。生产环境可以用 Nginx 反向代理解决,但本地开发建议直接用构建工具的代理功能。配置好之后,前端代码里的地址就写相对路径,比如 /api/users,浏览器会把请求发到同源地址,再由代理转发给后端。
javascript复制// 开发环境 fetch 地址
fetch('/api/users')
这样代码里不需要硬编码完整域名,后续部署到不同环境也更灵活。很多刚从后端“增删改查”思维转向全栈的同学会忽略这点,导致上线前要改一堆 URL,费时且容易出错。
8. 多写封装,多留接口,多读浏览器报错
最后分享一些我自己的体会。7 天学完 Fetch 之后,你要做的不是立刻把所有老代码重构一遍,而是先在项目里写一个薄薄的工具层。比如加一个 request.js,把超时、错误处理、JSON 解析、取消请求都收进去,之后所有业务页面只依赖这个工具。这样你改底层能力的时候,不会影响上层业务,是收益比很高的一件事。
另外,刚上手你会频繁遇到各种控制台报错,特别是 CORS 相关。不要一看到英文报错就慌,先看报错里有没有提示 Access-Control-Allow-Origin,有的话大部分是后端没配跨域头,前端再怎么折腾都没用。还有一类报错是 Unexpected token '<',通常意味着你请求的 URL 返回的是 HTML 而不是 JSON,最可能的原因是你的路径写错了,或者开发服务器拦截了请求并返回了页面。这种时候用 Network 面板看一下响应内容,十秒就能定位。
我对 Fetch 的整体评价是:它让前端请求代码的写法从“事件驱动”转向“数据驱动”,思维上更现代,组合能力更强。虽然它在上传进度这类场景里不如 XHR 顺手,但这不妨碍它成为日常请求的首选。你只要把基础语法、错误处理、封装思路这几点搞明白,7 天绰绰有余。
如果你在学习过程中被某个具体问题卡住,比如跨域配置、上传进度、请求拦截,建议直接把你看到的报错信息复制到搜索引擎里,再结合本篇的封装思路去排查。代码层面的东西,看再多不如动手跑一遍。写一个 fetch 请求、故意制造一个 404、再故意断网看 catch 的表现,这些体验比背 100 条理论都有用。
