1. 从一个“消失的请求”说起:为什么状态码会变成 canceled
前端开发里有个场景几乎人人都遇到过:页面上某个操作触发了接口请求,Network 面板里那条记录红彤彤地挂着,Status 一栏写着(canceled),既不是 200 也不是 500,控制台里可能还跟着一句net::ERR_ABORTED。第一次见的人往往会懵——服务器到底收没收到?是我的代码写错了,还是后端挂了?
先把结论摆在前面:canceled不是 HTTP 状态码,它是浏览器 DevTools 对“请求被主动中止”这一现象的状态标注。真正的 HTTP 状态码是服务器返回的,而canceled意味着这个请求压根没走到“拿到响应”那一步,它在传输途中被某一方掐断了。掐断的发起方可能是浏览器本身、可能是页面里的 JS 代码、也可能是网络层。
这个区分非常关键,因为排查方向完全不同。如果你把它当成 500 去查后端日志,大概率什么都查不到;如果你把它当成网络故障去 ping 服务器,也是白费功夫。它的根因几乎永远在前端这一侧。
那什么情况下浏览器会主动掐断一个请求?我把它归成几大类,后面每一类都会展开讲:
- 页面卸载或跳转,浏览器清理未完成的请求
- 同一个请求被重复发起,前一个被浏览器或代码取消
XMLHttpRequest或fetch显式调用了 abort- 请求超时设置触发
- 表单提交、链接点击等默认行为打断了进行中的请求
- 浏览器自身的连接复用、预检失败等底层原因
理解了这个前提,我们才能往下聊 AJAX 里那些真正让人头疼的坑。这篇文章我会按“现象 → 原理 → 复现 → 排查 → 规避”的顺序,把canceled背后牵扯出的 AJAX 问题一次性讲透,包括请求竞态、重复提交、组件卸载后的状态更新、超时与重试的边界等等。适合已经写过一些接口调用、但被这些“玄学问题”折磨过的前端同学,也适合想系统梳理请求生命周期的朋友。
2. AJAX 请求的完整生命周期与取消机制
2.1 一个请求从发出到结束到底经历了什么
很多人对 AJAX 的理解停留在“发出去、等回来”这个层面,但真正要搞懂canceled,得把中间过程拆开看。一个请求的完整生命周期大致是这样的:
- 代码调用
fetch或xhr.send(),请求进入队列 - 浏览器进行 DNS 解析、建立 TCP 连接(可能复用已有连接)
- 如果是跨域且非简单请求,先发 OPTIONS 预检
- 真正发送请求,等待服务器响应
- 收到响应头、响应体,触发回调
- 请求结束,连接可能被复用或关闭
canceled可能出现在第 2 到第 5 步之间的任何一个环节。比如连接还没建立就被取消,或者响应体正在下载时被中断。DevTools 统一显示为canceled,但底层原因千差万别。
这里有个容易被忽略的点:请求被取消时,服务器可能已经收到并处理了请求。也就是说,一个“取消”的 POST 请求,后端可能已经往数据库写了一条记录。这就是为什么“取消请求”不能等同于“回滚操作”,这是很多人在做防重复提交时踩的大坑。
2.2 主动取消与被动取消的区别
我把取消分成两类,这个分类贯穿全文:
主动取消:代码里明确调用了AbortController.abort()或xhr.abort()。这种取消是可控的,你能精确知道哪个请求被取消了,也能在取消后做清理。
被动取消:浏览器或运行时环境替你做的决定。比如页面跳转、组件卸载、请求被后来的同名请求顶掉、超时机制触发。这种取消往往悄无声息,是canceled问题的主要来源。
区分这两者的意义在于:主动取消通常是我们设计的一部分,是好事;被动取消往往是 bug 的表征,需要排查。但很多人看到canceled就慌,其实先要问一句——这个取消是我自己写的吗?
2.3 AbortController:现代取消请求的标准姿势
AbortController是目前最推荐的取消方案,fetch原生支持它。基本用法是这样:
const controller = new AbortController(); const { signal } = controller; fetch('/api/data', { signal }) .then(res => res.json()) .then(data => console.log(data)) .catch(err => { if (err.name === 'AbortError') { console.log('请求被取消了'); } else { console.error('其他错误', err); } }); // 需要取消时 controller.abort();这里有个细节值得说:abort()之后,fetch的 Promise 会 reject 一个AbortError。如果你不 catch 它,控制台会报未捕获的异常。所以取消请求一定要配套处理 AbortError,否则你会看到一堆红色报错,误以为是别的问题。
AbortController还有个进阶用法——一个 controller 可以控制多个请求,只要它们共用同一个 signal。这在“页面切换时取消该页面所有请求”的场景里特别好用。
2.4 XHR 时代的取消方式与遗留问题
老项目里还在用XMLHttpRequest的话,取消方式是xhr.abort()。它和AbortController的区别在于:
xhr.abort()会触发abort事件,readyState变成 0- 不会 reject Promise(因为 XHR 本身不是 Promise)
- 如果用了 axios 这类基于 XHR 的库,取消方式又不一样
axios 早期用CancelToken,现在推荐用AbortController。如果你维护的是老代码,看到CancelToken.source()这种写法不要惊讶,它是历史产物,能用但不推荐在新代码里继续用。
提示:无论用哪种取消方式,取消后的清理逻辑(比如把 loading 状态置回 false)都要自己写。框架不会帮你做这件事,这也是很多“转圈圈停不下来”问题的根源。
3. 请求竞态:canceled 最常见的幕后黑手
3.1 什么是请求竞态,为什么它会导致 canceled
请求竞态(race condition)是前端最经典的坑之一。场景很简单:用户在搜索框里快速输入,每输入一个字符就发一次请求。假设先发了请求 A(关键词“a”),紧接着发了请求 B(关键词“ab”)。如果 A 的响应比 B 晚回来,页面上最终显示的就是 A 的结果,也就是“a”的搜索结果,但输入框里明明是“ab”。这就是竞态。
那这和canceled有什么关系?很多团队为了解决竞态,会在发新请求前取消上一个请求。于是 Network 面板里就出现了一堆canceled的记录。这时候的 canceled 是好事,是防竞态机制在正常工作。
但问题在于,如果取消逻辑写得不对,就会出现“该取消的没取消,不该取消的取消了”,或者取消后状态没清理干净,导致 UI 错乱。
3.2 用 AbortController 解决搜索框竞态
一个可靠的搜索防竞态方案长这样:
let currentController = null; async function search(keyword) { // 取消上一个请求 if (currentController) { currentController.abort(); } currentController = new AbortController(); try { const res = await fetch(`/api/search?q=${keyword}`, { signal: currentController.signal }); const data = await res.json(); renderResults(data); } catch (err) { if (err.name === 'AbortError') { // 被取消,什么都不做 return; } showError(err); } }这段代码的关键点有三个:一是每次请求前先 abort 上一个;二是用 try/catch 吞掉 AbortError,避免误报;三是只有非取消的错误才走错误处理。
3.3 只靠防抖够不够
很多人会说,我用防抖(debounce)不就行了,何必搞取消?防抖确实能减少请求数量,但它解决不了竞态。因为防抖只是延迟发送,一旦请求发出去了,多个请求仍然可能同时在飞。如果网络抖动导致先发的后回,竞态照样发生。
所以防抖和取消是互补的,不是二选一。防抖控制“发多少请求”,取消控制“旧请求别捣乱”。生产环境里我一般两个都用:输入时防抖 300ms,发请求时取消上一个。
3.4 竞态在非搜索场景的变体
竞态不只出现在搜索框。任何“同一份数据被多次请求”的场景都可能有:
- Tab 切换时快速点击,多个 tab 的数据请求互相覆盖
- 列表分页快速翻页,第 2 页的数据覆盖了第 3 页
- 详情页快速切换不同 id,旧 id 的数据渲染到了新页面
这些场景的解法思路一致:给每次请求打上标识,或者取消旧请求。打标识的做法是记录一个自增的 requestId,响应回来时比对是不是最新的;取消的做法就是上面那套 AbortController。前者不产生 canceled 记录,后者会产生。选哪个看团队习惯,我个人偏向取消,因为能真正省掉无用的网络传输。
4. 组件卸载与页面跳转引发的取消
4.1 单页应用里组件卸载后的请求去哪了
在单页应用里,用户点了一个按钮发起请求,还没等响应回来就跳到了另一个路由。这时候原组件已经卸载了,但请求还在飞。等响应回来,回调里如果调用了setState或者操作了已经销毁的 DOM,轻则报 warning,重则内存泄漏。
React 里经典的报错就是Can't perform a React state update on an unmounted component。虽然新版 React 对这个 warning 做了调整,但问题本质还在:卸载后的异步回调不应该再执行。
解决办法就是在组件卸载时取消所有进行中的请求。以 React 为例:
useEffect(() => { const controller = new AbortController(); fetch('/api/data', { signal: controller.signal }) .then(res => res.json()) .then(data => setData(data)) .catch(err => { if (err.name !== 'AbortError') console.error(err); }); return () => { controller.abort(); }; }, []);useEffect的清理函数里 abort,这样组件卸载时请求自动取消,Network 里就会看到canceled。这是预期内的取消,不用管它。
4.2 页面跳转(非单页)时的取消行为
如果是传统的多页应用,用户点击链接跳转,浏览器会卸载当前页面。这时候所有未完成的请求都会被浏览器自动取消,Network 面板里显示canceled。这是浏览器的默认行为,你无法阻止,也不需要阻止。
但有个坑:如果这个请求是个写操作(比如埋点上报、日志上报),页面跳转可能导致请求没发出去就被取消了。解决办法是用navigator.sendBeacon,它专门用于在页面卸载时发送数据,浏览器会保证尽力发送。
window.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') { navigator.sendBeacon('/api/log', JSON.stringify(logData)); } });sendBeacon不返回响应,也不受页面卸载影响,是埋点场景的标准方案。
4.3 卸载取消的边界:哪些请求不该取消
不是所有请求都适合在卸载时取消。我总结了一条原则:读操作可以取消,写操作要谨慎。
读操作(GET 查询)取消了无非是少拿一次数据,重新进页面再拿就行。但写操作(POST 下单、支付、提交表单)如果被取消,可能出现“前端以为失败了,后端其实成功了”的状态不一致。这种场景下,取消请求反而制造了麻烦。
对于写操作,更稳妥的做法是:不取消,但用一个全局的标记记录组件是否还活着,回调里判断标记再决定要不要更新 UI。请求本身让它跑完,保证后端状态一致。
5. 重复提交与请求去重的实战方案
5.1 用户狂点按钮引发的连环 canceled
“提交”按钮被连点,是canceled的高发场景。用户手快点了五下,代码发了五个请求,如果做了取消逻辑,前四个就被取消了,Network 里四条canceled加一条成功。看起来好像没问题,但如果没做取消,五个请求全打到后端,可能就创建了五条订单。
这里要区分两个层面的问题:前端防重复和后端幂等。前端防重复是体验优化,后端幂等是数据安全的底线。两者都要做,不能只靠一个。
前端防重复的常见手段:
- 点击后立即禁用按钮,请求结束再恢复
- 用一个
isSubmitting标志位拦截重复调用 - 用请求去重机制,相同请求在飞行中直接复用
5.2 请求去重的实现思路
请求去重指的是:如果同一个请求(相同 URL、相同参数)正在飞行中,就不要重复发,直接复用它的 Promise。实现思路是用一个 Map 缓存进行中的请求:
const pendingMap = new Map(); function requestKey(config) { return `${config.method}:${config.url}:${JSON.stringify(config.params || {})}`; } async function dedupRequest(config) { const key = requestKey(config); if (pendingMap.has(key)) { return pendingMap.get(key); } const promise = fetch(config.url, config) .finally(() => { pendingMap.delete(key); }); pendingMap.set(key, promise); return promise; }这个方案在 axios 里可以通过拦截器实现,社区也有现成的库。核心就是“飞行中的请求登记在册,重复的直接返回同一个 Promise”。
注意:去重只对“幂等的读请求”安全。写请求去重要非常小心,因为用户可能确实想提交两次不同的内容,只是参数恰好一样。
5.3 取消 vs 去重:怎么选
这两个方案经常被混为一谈,其实适用场景不同:
| 方案 | 适用场景 | 是否产生 canceled | 副作用 |
|---|---|---|---|
| 取消旧请求 | 搜索、Tab 切换、快速翻页 | 是 | 旧请求结果被丢弃 |
| 请求去重 | 重复点击、相同查询 | 否 | 复用同一结果 |
| 防抖节流 | 输入、滚动触发 | 否 | 延迟发送 |
| 按钮禁用 | 表单提交 | 否 | 需手动恢复状态 |
我的经验是:竞态类问题用取消,重复类问题用去重,输入类问题用防抖,提交类问题用禁用。搞清楚问题属于哪一类,方案自然就出来了。
6. 超时、重试与取消的边界处理
6.1 请求超时到底算不算取消
超时和取消是两个概念,但在 DevTools 里有时都表现为请求中断。超时是“等太久了,我不等了”,取消是“我主动不要了”。fetch本身没有超时参数,需要自己用AbortController配合setTimeout实现:
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)); }这里有个细节:超时触发的 abort 和手动 abort 抛出的都是AbortError,你无法从错误类型上区分。如果需要区分,得自己加标记:
let isTimeout = false; const timer = setTimeout(() => { isTimeout = true; controller.abort(); }, timeout); // catch 里判断 if (err.name === 'AbortError') { if (isTimeout) console.log('超时了'); else console.log('被取消了'); }6.2 重试机制与取消的冲突
自动重试是个好东西,但和取消机制放一起就容易打架。比如请求失败后自动重试三次,结果组件卸载了,取消信号发出,但重试逻辑还在跑,又发了新请求。这种“幽灵请求”很难排查。
正确的做法是:重试逻辑要感知取消信号。每次重试前检查 signal 是否已经 aborted,如果已取消就停止重试。
async function retryFetch(url, options, retries = 3) { for (let i = 0; i < retries; i++) { if (options.signal?.aborted) return; try { return await fetch(url, options); } catch (err) { if (err.name === 'AbortError') throw err; if (i === retries - 1) throw err; await sleep(1000 * (i + 1)); } } }6.3 重试的退避策略与幂等性
重试还有个隐患:如果请求不是幂等的,重试可能造成重复操作。比如一个“扣款”请求超时了,你重试一次,可能扣了两次。所以只有幂等请求才适合自动重试,非幂等请求要么不重试,要么后端提供幂等键。
退避策略上,指数退避(1s、2s、4s、8s)比固定间隔更合理,能避免在服务端压力大时雪上加霜。但退避时间也不能太长,否则用户等不及。我一般设置最大重试 2-3 次,总时长控制在 10 秒内。
7. 常见问题速查与排查技巧
7.1 canceled 问题排查速查表
| 现象 | 可能原因 | 排查方向 | 解决思路 |
|---|---|---|---|
| 搜索时大量 canceled | 请求竞态 | 看是否每次输入都发请求 | 加防抖 + AbortController |
| 跳转后 canceled | 页面卸载 | 确认是否预期 | 预期内忽略,写操作用 sendBeacon |
| 提交按钮连点 canceled | 重复提交 | 看按钮是否禁用 | 禁用按钮 + 请求去重 |
| 随机 canceled 无规律 | 超时或网络 | 看请求耗时 | 调整超时时间,加重试 |
| 组件卸载后报错 | 异步回调未清理 | 看控制台 warning | useEffect 清理函数里 abort |
| 跨域请求 canceled | 预检失败 | 看 OPTIONS 请求 | 检查 CORS 配置 |
7.2 用 DevTools 定位取消源头
排查canceled时,DevTools 的 Network 面板有几个实用技巧:
- 打开 Preserve log,跳转后请求记录不会丢
- 看 Timing 标签,能看出请求卡在哪个阶段
- 看 Initiator 列,能知道是哪个 JS 文件发起的请求
- 右键请求可以 Copy as fetch,方便复现
如果 Initiator 指向的是某个第三方库,那取消可能是库内部做的,需要去看库的文档或源码。
7.3 我踩过的几个坑
坑一:abort 后没处理 AbortError,控制台一片红。这个前面说过,catch 里一定要判断err.name === 'AbortError'并静默处理。
坑二:以为取消请求就等于后端没收到。实际上请求可能已经到达服务器,只是响应被丢弃了。写操作千万别依赖取消来“撤销”。
坑三:在 axios 拦截器里统一取消,结果把不该取消的也取消了。取消逻辑要精确到具体场景,不要全局一刀切。
坑四:AbortController 复用导致误取消。一个 controller 只能 abort 一次,abort 后 signal 永久处于 aborted 状态。如果复用同一个 controller 发多个请求,一次 abort 会取消所有。每次请求要新建 controller。
坑五:忘记清理 setTimeout,导致内存泄漏。超时用的 timer 一定要在请求结束后 clearTimeout,否则定时器会一直挂着。
8. 一套可复用的请求封装思路
聊了这么多问题,最后给一套我实际项目里在用的封装思路,把取消、超时、去重、重试都整合进去。核心是一个request函数,接收配置对象,内部处理所有边界情况。
class RequestManager { constructor() { this.pending = new Map(); } async request(config) { const { url, method = 'GET', params, timeout = 8000, retries = 0, dedup = false } = config; const key = `${method}:${url}:${JSON.stringify(params || {})}`; // 去重 if (dedup && this.pending.has(key)) { return this.pending.get(key); } const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); const promise = this._doRequest(config, controller.signal, retries) .finally(() => { clearTimeout(timer); this.pending.delete(key); }); if (dedup) this.pending.set(key, promise); return promise; } async _doRequest(config, signal, retries) { try { const res = await fetch(config.url, { ...config, signal }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return await res.json(); } catch (err) { if (err.name === 'AbortError') throw err; if (retries > 0) { await new Promise(r => setTimeout(r, 1000)); return this._doRequest(config, signal, retries - 1); } throw err; } } cancelAll() { // 需要维护 controller 列表才能全部取消 } }这套封装的关键设计点:去重和取消分开控制,超时用 AbortController 实现,重试感知取消信号,请求结束后清理定时器和缓存。实际用的时候,读请求开 dedup,写请求关 dedup 但开按钮禁用,搜索场景额外加防抖。
我个人在实际项目里的体会是,请求层的问题从来不是单一技术点,而是“生命周期管理”这件事。什么时候发、什么时候取消、取消后怎么清理、失败了怎么重试,这些问题想清楚了,canceled就不再是玄学,而是一个你能完全掌控的状态。最后再分享一个小技巧:给每个请求加一个业务标识(比如X-Request-Id),排查线上问题时能快速把前端记录和后端日志对上,比单纯看时间戳靠谱得多。