前两天参与一个前端小组的代码评审,看到一套新写的管理后台,请求模块自己包了一层 fetch,但同事在注释里写了一句“这里为什么不用封装好的请求库”。我没直接回答,先让他把 fetch 的返回体拿到控制台看,结果卡了半天。
其实“AJAX vs Fetch API”这个选择题,背后真正要解决的是异步 JavaScript 的执行模型:回调什么时候触发、Promise 怎么链式传递、错误在哪里被吞掉。搞懂了这些,用哪个 HTTP API 都是顺手的事。这篇文章不聊面试八股,我把代码演进和踩坑记录都放出来,前端新人可以直接对照自己的代码改,老手就当查漏补缺。
1. AJAX 与 Fetch API:两个时代的异步请求模式
1.1 你写的“AJAX”可能根本不是 AJAX
先说这个最搞笑也最常见的误区。AJAX 的准确定义是 Asynchronous JavaScript And XML,即“异步 JavaScript 和 XML”。它不是一个具体的 API,而是一种使用 XMLHttpRequest 在后台与服务器交换数据的编程模式。但现在大家口中说的“用 AJAX 请求”,很多时候只是泛指“不刷新页面就向后台要数据”,跟 XML 已经没有半毛钱关系,返回的数据也早换成了 JSON。
早期代码里你大概率见过这种东西:
const xhr = new XMLHttpRequest(); xhr.open('GET', '/api/user/list', true); xhr.onreadystatechange = function () { if (xhr.readyState === 4) { if (xhr.status >= 200 && xhr.status < 300) { console.log(xhr.responseText); } else { console.error('请求失败', xhr.status); } } }; xhr.send();这段代码本身没问题,但问题出在“事件模型”上:你要记住 readyState 的四个阶段,要手动判断状态码,还要在一个回调里处理两种结果。一个请求还好,两个三个串行请求下来,代码就开始往右长,也就是所有人吐槽的回调地狱。
所以后来才有了各种封装库,试图把 XHR 的细节藏起来。但封装库只能改写法,改不了底层的异步模型。直到 fetch API 出现,浏览器终于原生提供了一套基于 Promise 的请求接口,异步逻辑才开始变得人模人样。
1.2 Fetch 的底层是 Promise,但这不是全部
fetch 是全局自带的新一代请求 API,它接收一个 URL 和一个可选配置对象,返回一个 Promise。Promise 是它和 XHR 最大的分水岭:XHR 靠事件,fetch 靠状态链,这让“在请求完成后做点什么”这个描述在代码层面变得极其简单。
fetch('/api/user/list') .then((response) => { if (!response.ok) { throw new Error(`HTTP ${response.status}`); } return response.json(); }) .then((data) => { console.log(data); }) .catch((err) => { console.error('请求失败', err); });这段代码做了三件事:第一步把原始 response 拿回来,第二步把响应体解析成 JSON,第三步处理异常。每一步都是显式的,中间不会再有“readyState 是 4 了吗”这种噪音。
但需要强调,fetch 并不是把 Promise 当成简单装饰,它的整个生命周期都构建在 Promise 语义上。response.json() 返回的还是一个 Promise,因为响应体可能是流式到达的。这意味着你最少要经历两次 then 才能拿到业务数据。
有人问这是不是更啰嗦了。表面的确比 XHR 少不了一些样板,但换来的好处是你可以用同一套 Promise 工具去组合、并发、取消,这比 XHR 时代的 callback 结构要强太多。
2. Promise 与异步 JavaScript:不弄懂这些,代码怎么改都卡
2.1 回调地狱是怎么来的:一个真实场景
假设你有一个登录页面:先调用登录接口拿 token,再用 token 拉用户信息,接着按用户 id 拉订单列表。用老式回调写是这样的:
login(function (token) { getUser(token, function (user) { getOrders(user.id, function (orders) { render(orders); }); }); });三层嵌套还能忍,五层的时候漏个括号或者变量引错,调试过程能让人崩溃。回调地狱的本质不是“嵌套太多”,而是“控制流被打散了”。每一步都要手动把下一步塞进上一步的回调里,错误处理还得单独写一套。
后来在项目里我用 Promise 重写了同样的逻辑:
login() .then((token) => getUser(token)) .then((user) => getOrders(user.id)) .then((orders) => render(orders)) .catch((err) => toast(err.message));这个版本的每行都对应一个异步步骤,数据流从上一个返回值流向下一个参数,错误统一由 catch 接收。这就是我想强调的第一点:Promise 不是让你少写代码,而是让异步代码的控制流重新变得线性可读。
2.2 Promise 的三种状态与 then 链的细节
Promise 对象只有三种状态:
- pending:异步操作还没结束,等待中
- fulfilled:操作成功,有了最终值
- rejected:操作失败,有了失败原因
一旦状态从 pending 转为 fulfilled 或 rejected,就再也不会变。这个“不可逆”特性特别重要,意味着多次调用 then 的后续处理不会因为一个重复 resolve 而反复触发。
then 链最需要记住的规则是:then 方法永远返回一个新的 Promise。所以你才能一直往下点。常见的坑是回调函数里忘记 return:
// 错误写法 getUser(token) .then((user) => { getOrders(user.id); }) .then((orders) => { // 这里拿不到 orders,因为上一个 then 没有 return render(orders); });这种问题在代码评审里碰到过很多次。它不报错,只是下一层拿到的值是 undefined,直接让页面数据消失。排查半天结果发现是少了一个 return,非常冤。
catch 的兜底位置也存在学问。如果你把 catch 放在中间,它只能捕获前面链路里的错误,同一级别后面的 then 不会再被它保护。实际项目里我习惯把所有 catch 都收敛到链尾,然后用一个统一的错误弹窗函数去处理,既有兜底又避免每个环节各吃一遍异常。
2.3 async/await 与错误捕获:让异步代码长得像同步
Promise 链已经比回调好读很多,但遇到条件判断、循环、分支时就还是有点扭。ES2017 带来的 async/await 把这一步彻底打通了。它只是一个语法糖,本质还是操作 Promise,但读起来像同步代码:
async function loadOrders() { try { const token = await login(); const user = await getUser(token); const orders = await getOrders(user.id); render(orders); } catch (err) { toast(err.message); } finally { hideLoading(); } }注意 await 只能出现在 async 函数里。如果你在普通函数里写 await,浏览器会直接给你一个语法错误。
另一个常见误区是以为 async 函数里的代码是同步执行的。不是。async 函数一旦调用,整个函数体依然会被 Promise 包裹,函数内第一个 await 之前的部分会先同步执行,碰到 await 之后才交出控制权。理解这一点,很多诡异的执行顺序问题就能想通了。
如果一个函数本身返回的是 Promise,你可以在它前面加 async,让返回值再被包一层 Promise。这种“无意义包装”代码里少写为妙。async/await 真正该配合的,是 Promise.all 这一类并发工具,而不是把所有请求全部改成串行。
3. 实操:用 Fetch 重写日常请求场景
3.1 基础 GET 请求:从 XHR 到 fetch 的完整对照
先放一个最直白的对照。同样是请求用户列表,XHR 版本:
const xhr = new XMLHttpRequest(); xhr.open('GET', '/api/user/list', true); xhr.onload = function () { if (xhr.status >= 200 && xhr.status < 300) { console.log(JSON.parse(xhr.responseText)); } else { console.error('请求失败', xhr.status); } }; xhr.onerror = function () { console.error('网络错误'); }; xhr.send();fetch 版本:
fetch('/api/user/list') .then((response) => { if (!response.ok) { throw new Error(`HTTP ${response.status}`); } return response.json(); }) .then((data) => { console.log(data); }) .catch((err) => { console.error('请求失败', err); });看起来 fetch 没有省太多行数,但结构和关注点完全不一样。
fetch 的 response 对象里,status 是 HTTP 状态码,ok 是一个布尔值,表示状态码是否在 200 到 299 区间。这里必须养成手工检查 response.ok 的习惯,原因后面会专门讲。
拿到 response 之后,调用 response.json() 才能把响应体解析成对象。解析本身是异步的,所以还得 return 出去让下一个 then 接收。
我自己的经验是:不要在 then 里直接写业务逻辑,而是先做一个简单的请求工具函数,统一处理状态码和 JSON 解析,后续页面只关心业务成功或失败。
3.2 POST 提交与请求头处理
POST 请求比 GET 多了一块配置对象。传 JSON 数据时的标准写法:
const formData = { name: '某开发者', role: 'admin' }; fetch('/api/user/create', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(formData), }) .then((res) => res.json()) .then((data) => console.log(data));这里最容易翻车的点有两个。
第一,body 不能直接传对象,必须用 JSON.stringify 序列化成字符串。很多人第一次写 fetch 都栽在这,传对象进去接口解析不到数据。
第二,header 里的 Content-Type 要写完整。浏览器虽然默认会带一些头,但不会默认帮你声明 JSON。你不声明,后端按表单格式解析,JSON 字符串就会变成一堆畸形字段。
如果是表单上传,要注意一个反直觉的规则:
const fd = new FormData(); fd.append('file', fileInput.files[0]); fetch('/api/upload', { method: 'POST', body: fd, });这里不要手动设置 Content-Type。因为浏览器会自动帮你补上 multipart/form-data 以及对应的 boundary 分隔符。一旦你手写 Content-Type,反而可能漏掉 boundary,后端收到一个残缺的 multipart 请求,直接解析失败。我见过有人在这个坑里查了一个下午。
3.3 超时与取消:AbortController 的正确用法
fetch 本身没有超时参数。你写上 timeout 配置它是不会认的。官方设计思路是:取消异步请求这件事,应该由 AbortController 来做。
const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 5000); fetch('/api/slow-report', { signal: controller.signal }) .then((res) => res.json()) .then((data) => console.log(data)) .catch((err) => { if (err.name === 'AbortError') { console.log('请求已取消,可能超时'); } else { console.error('其他错误', err); } }) .finally(() => clearTimeout(timer));AbortController 的核心是 signal。把这个 signal 传给 fetch 之后,controller.abort() 一被调用, fetch 就会进入 rejected 状态,错误对象的 name 是 AbortError。
注意三个细节。
第一,clearTimeout 要放在 finally 里,否则请求提前成功时计时器还在跑,五秒后依然会触发 abort,产生一个根本没有意义的取消错误。虽然多数时候不会影响逻辑,但控制台会多报一条错误,看着就烦。
第二,在 catch 里一定要判断 err.name === 'AbortError',把“取消”和“真实错误”区分开。如果不分,超时和接口报错的 toast 会一样,用户分不清是自己取消的还是网络问题。
第三,一个 controller 可以同时取消多个请求。把同一个 signal 传给两个 fetch,一次 abort 就能同时停掉它们,适合“页面卸载时清理所有未完成的网络请求”这种场景。
还有一种老派做法是用 Promise.race,拿一个定时器跟 fetch 比赛谁先完成。它能实现超时,但超时后底层请求并没有真正停止,数据回来时还会继续执行后续逻辑。AbortController 才是正经解法。
4. 日常踩坑与排查思路
4.1 fetch 不会对 HTTP 错误状态自动抛异常
这是 fetch 跟 XHR 最大的行为差异,也是初学者的第一课。
如果用 XHR,status 400、500 时你在 onload 里总会拿到完整信息,可以手动判断。但 fetch 只在网络层出错时 reject,比如断网、DNS 解析失败、CORS 问题。HTTP 状态码哪怕返回 500,fetch 依然认为请求成功了,response.ok 为 false,但你如果不用 response.ok 判断,代码会继续往下走,把错误页面当成成功数据处理。
所以每写一个 fetch,基本都要补一段类似下面的逻辑:
async function request(url, options = {}) { const controller = new AbortController(); const timeout = options.timeout || 10000; const timer = setTimeout(() => controller.abort(), timeout); try { const response = await fetch(url, { ...options, signal: controller.signal, }); if (!response.ok) { throw new Error(`请求失败:${response.status} ${response.statusText}`); } return await response.json(); } finally { clearTimeout(timer); } }这里把超时、状态码判断、JSON 解析全部收敛到一个函数里。页面代码只需要 await request('/api/xxx'),错误全部由统一的 catch 处理。这也是很多团队最终选择自研轻量请求封装的原因之一:fetch 原生能力足够,但缺一个默认的“业务错误处理层”。
4.2 响应体只能读一次:clone 与重新消费
Response 对象里的 body 是一个可读流。流的特性是只能消费一次。一旦你调用了 response.text() 或者 response.json(),再调用另一个,就会得到类似“Body has already been consumed”的报错。
我碰到过一个需求:响应内容要同时展示原始文本和解析后的 JSON,于是写了:
const response = await fetch('/api/user/list'); const text = await response.text(); const data = await response.json(); // 这里会炸解决办法有两种。第一种是干脆只调 text() 然后 JSON.parse:
const response = await fetch('/api/user/list'); const text = await response.text(); const data = JSON.parse(text);第二种是用 clone 复制响应流:
const response = await fetch('/api/user/list'); const copy = response.clone(); const text = await response.text(); const json = await copy.json();clone 的限制是它必须在 body 还没被读取时执行。如果原 response 已经被消费,clone 也会报错。大多数业务场景根本不需要读两次响应体,建议直接把一次读取的逻辑做进封装里,别给上层留下消费流的窗口。
4.3 并发请求与竞态:Promise.all 与请求序号
如果页面同时要拉用户列表和订单列表,串行写会白白浪费等待时间。正确做法是并发:
const [users, orders] = await Promise.all([ fetch('/api/users').then((res) => res.json()), fetch('/api/orders').then((res) => res.json()), ]); render(users, orders);Promise.all 的特点是“一个 reject,全部 reject”。这适合请求之间强依赖、必须同时成功的场景。但如果只是几个独立模块各自加载,互不影响,一个失败不应该拖垮另一个,这时要用 Promise.allSettled:
const results = await Promise.allSettled([ fetch('/api/users').then((res) => res.json()), fetch('/api/orders').then((res) => res.json()), ]); results.forEach((result) => { if (result.status === 'fulfilled') { console.log(result.value); } else { console.error(result.reason); } });除了并发,还有一个高频问题叫“竞态”:用户在搜索框里快速切换关键词,前一个请求响应慢,后一个请求响应快,后返回的数据先把结果渲染了,随后前一个慢请求才回来,又把页面覆盖掉。
简单的处理方式是加请求序号:
let requestId = 0; async function search(keyword) { const current = ++requestId; const results = await fetch(`/api/search?q=${keyword}`).then((res) => res.json()); if (current !== requestId) { return; // 已经过期,直接丢弃 } render(results); }更彻底的方式是配合 AbortController,把前一个请求真正取消掉。搜索框这种场景,其实不需要保留旧请求的结果,取消节省带宽和 CPU 更加实际。
我自己在前端团队里日常更偏爱异步按钮场景下的方案:点击发送验证码后,进入 60 秒倒计时,期间不允许再次点击。用一个 controller 保存当前请求,组件卸载时主动 abort,防止 setState 引发内存泄漏警告。这种“手动控制请求生命周期”的能力,正是 Promise 与 fetch 组合起来最迷人的地方。
我个人在实际编码中的体会是:真正好用的一套异步请求方案,从来不是抄一个 fetch 模板就够了,而是把超时、取消、错误分类、并发控制全部都理顺。你先把原生 fetch 和 Promise 的特性吃透,再去看那些封装好的请求库,你会发现它们做的事情也没有想象中那么神秘,无非是把你手写容易漏的部分默认补全。对多数中后台项目来说,一个几十行的封装函数加上 async/await,已经能覆盖九成以上请求场景。
最后分享一个小技巧:在调试 fetch 响应数据时,直接用我们上边封装好的 request 函数,然后通过浏览器控制台的 Network 面板对比请求瀑布图,每个请求耗时、排队时间、取消状态一眼就能看出来。异步代码出错时,不要下意识改代码,先看浏览器给出的堆栈和网络请求状态,往往能省下大半个晚上的排查时间。