从原理到实战:用AbortController优雅地取消前端请求
2026/9/16 3:31:34 网站建设 项目流程

前端一天到晚都在发请求,但很少有人认真想过:这个请求到底有没有被真正取消过?我之前在一个管理后台里踩过一个坑,页面里快速切换查询条件,结果旧的请求比新的请求后返回,直接把新数据覆盖了。后来排查半天,发现就是从没主动中止过请求,所有“取消”都只是停留在口头上的“我不看结果了”,实际上浏览器该发的包一个不少,回调该执行的照样执行。

当时解决这个问题用的就是 AbortController。这货是个原生API,专治“请求发出去之后我又不想要了”这种场景。这篇文章就把它的原理、用法、和实际项目里最常见的几种配合方式都捋一遍,包括超时控制、竞态保护、组件卸载取消、axios里怎么用,以及很多人容易忽略的坑。不管你是刚接触 fetch 没多久,还是已经在项目里写了几年请求封装,应该都能从中翻到点有用的东西。

1. 请求中止这件事,到底难在哪

先说点背景。以前 XMLHttpRequest 时代想取消请求,是靠 xhr.abort() 这一个方法硬来。到了 fetch 时代,早期版本压根没给你提供取消入口,导致社区里出现了各种 polyfill 和包装库。等到浏览器慢慢把 AbortController 变成标准能力之后,事情才算有了一个统一的解法。

1.1 发出去的请求,为什么非要“主动中止”

很多人有一个错觉:我页面跳走了,或者我不需要这个数据了,浏览器是不是就会自动把请求断掉?现实不是这样。fetch 一旦发出去,网络层该走的流程照样走完,响应回来之后 promise 该 resolve 还是 resolve,你的回调函数该执行还是执行。只是这时候你可能已经不在那个页面了,或者数据已经过期了,这个“迟到的结果”反而成了脏数据。

由此会引出一连串实际问题。第一是竞态条件,两个请求先后发出,先发出去的因为网络慢反而后返回,结果把界面上本该保留的新数据覆盖掉了。第二是资源浪费,尤其移动端弱网环境下,用户明明已经不想等了,请求还在那里慢慢传,服务器和客户端带宽都被白占。第三是逻辑干扰,你 是在“比如用户连点提交按钮,第一发请求还在飞,第二发又出去了,最理想的交互应该是把上一发直接作废,而不是让后端去处理重复订单”。

所以说,主动中止请求不是“性能洁癖”,而是前端工程里一个绕不开的基础能力。很多后端接口没有幂等设计,重复请求产生的副作用就得靠前端自己挡掉一层。

1.2 AbortController 的核心组合:controller 与 signal

AbortController 本身是个很轻量的对象,只有两个关键成员:一个 controller.abort() 方法,一个 controller.signal 属性。signal 是一个 AbortSignal 对象,你可以把它理解成一根“信号线”,fetch、axios、事件监听器这些支持中止的操作,拿到这根线之后就会持续监听它的状态。

调用 controller.abort() 那一刻,signal 会被标记为 aborted,所有绑定了这个 signal 的操作会立刻收到通知。fetch 收到通知后会中止网络请求,promise 进入 rejected 状态,并抛出一个名为 AbortError 的 DOMException。事件监听器收到通知后会自动移除监听,比如 addEventListener 里传入 { signal } 的写法。

const controller = new AbortController(); const { signal } = controller; fetch('/api/data', { signal }) .then(res => res.json()) .catch(err => { if (err.name === 'AbortError') { console.log('这个请求是被我们主动中止的,不是网络错误'); } }); // 需要取消的时候 controller.abort();

从代码上看确实不复杂,但真正判断一个人有没有吃透这个东西,得看他捕获异常的方式对不对。很多人会直接写 catch 然后弹错误提示,结果用户取消请求之后反而看到一条“网络错误”,体验非常别扭。正确做法是先判断 err.name 是不是 AbortError,是的话静默处理。

2. 基础用法:从一次 abort 到整个信号机制

基础用法看着简单,但把 signal 和 fetch 以外的能力打通之后,能玩出的花样远比想象中多。这里拆成几个层次来讲。

2.1 用同一个 controller 中止多个并发请求

AbortSignal 可以被多个请求同时绑定,这是它一个特别实用的特性。想象一个报表页面,页面里有十来个接口同时拉数据,用户退出这个页面时,我希望把这个页面发出的所有请求全部停掉。常规做法可能是把每个请求的 controller 单独存进一个数组,然后逐个 abort。有 AbortController 之后,只要让所有请求共享同一个 signal 就行。

const controller = new AbortController(); const requestList = [ fetch('/api/report/summary', { signal: controller.signal }), fetch('/api/report/detail', { signal: controller.signal }), fetch('/api/report/trend', { signal: controller.signal }), ]; // 任何一个环节不再需要这些数据时 controller.abort();

这里要注意一个细节:fetch 本身并不是原子操作,signal 传入发生在请求初始化阶段,如果你把同一个 signal 同时发给多个请求,abort 一次就相当于给这批请求群发了一条“全部取消”的指令。它内部实现是,每个 fetch 调用都会在 signal 上注册 abort 事件处理器,abort 触发时会挨个通知所有注册方。这个机制对并发请求管理很有用,不用为每个请求单独维护状态。

2.2 signal.aborted 与 abort 事件的监听时机

除了通过 fetch 间接使用 signal,你还可以直接监听 signal 本身。AbortSignal 上有一个 abort 事件,通过 signal.addEventListener('abort', handler) 注册回调,另外还有一个 signal.aborted 布尔属性,用来判断当前是否已被中止。

const controller = new AbortController(); controller.signal.addEventListener('abort', () => { console.log('signal 已经被中止了'); }); console.log(controller.signal.aborted); // false controller.abort(); console.log(controller.signal.aborted); // true

这里有一个很容易踩的时序坑:如果代码执行顺序是先判断 signal.aborted,再决定要不要添加监听,那你得先看它是不是已经中止了。如果已经中止,再 addEventListener 的 abort 事件是不会触发的,因为那一瞬间已经过去了。你在实际代码里经常会遇到这种场景:函数运行的时候 signal 已经通过某种渠道被 abort 了,此时正确的做法是直接检查 signal.aborted 并立即返回,而不是傻等 abort 事件。

2.3 fetch 之外:把 signal 用到事件监听器上

AbortController 不仅能中止请求,还能用来解绑事件监听。addEventListener 的 options 参数里可以传入 signal 字段,当 signal 触发 abort 时,这个监听器会被自动移除。这个能力很多同学没用过,其实特别适合用在“一次性逻辑绑定”场景里。

const controller = new AbortController(); window.addEventListener('resize', handler, { signal: controller.signal }); // 某些逻辑完成后,直接通过 abort 移除监听 controller.abort();

这个写法比手动 removeEventListener 省事的地方在于,你不用在每一个可能退出逻辑的地方都写一遍 removeEventListener,只要在合适的时机统一 abort 就行。不过说实话,这个特性的心智负担不低,项目里如果只是零星几处用到还好,大量使用的话,代码可读性可能会下降,建议只在需要“并发绑定多个监听、统一解除”的场景使用。

3. 实战场景:前端开发里最常见的几种中止需求

铺垫够多了,接下来直接上实操。我挑了几个项目里最常遇到的需求,每个都给出完整思路和代码示例。

3.1 请求超时控制:不是 setTimeout 全家桶

请求超时是最常见的需求之一。以前写 ajax 项目,超时基本靠 setTimeout 和 clearTimeout 配合,强行给请求加一个时间上限。fetch 本身没有内置 timeout 配置,但通过 AbortController 可以实现一个非常干净的版本。

function fetchWithTimeout(url, options = {}, timeout = 8000) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); return fetch(url, { ...options, signal: controller.signal, }).finally(() => clearTimeout(timer)); }

这套写法的关键在于 finally 里的 clearTimeout。很多人会忽略这一步,导致请求本来就几十毫秒就返回了,但那个 8 秒的定时器还挂在那边,等它真正触发时 controller.abort() 又执行了一次,虽然整体没大碍,但如果一个页面里发起几十次请求,就会积压一堆无用的定时器。

不过说实话,这种“定时器 + abort”方案遇到一种边缘情况会有点棘手:如果 setTimeout 还没来得及触发,请求就已经完成了,这时候 finally 清掉定时器没问题;但如果请求已经完成,而定时器和 abort 并发执行,可能 abort 时请求已经写入了响应头,此时 fetch 已经从 pending 变成 fulfilled 了,后面的 catch 也不会被触发。实际开发里这个问题不大,大多数时候 abort 一个已完成的 fetch 只会让 signal 变成 aborted 状态,不会产生多余副作用。

3.2 搜索框竞态保护:只响应用户最后一次输入

搜索联想这块,竞态问题可以说是绕不过去的。用户快速输入多个关键字,每个关键字都发一个请求,网络环境不稳定的情况下,先发出去的请求反而可能最后返回,导致下拉框里显示的和输入框里的内容对不上。

用 AbortController 做竞态保护非常顺手。每次输入变化时,先中止掉上一个请求,再发新请求。

let controller = null; input.addEventListener('input', (e) => { const keyword = e.target.value; if (controller) { controller.abort(); } controller = new AbortController(); fetch(`/api/search?keyword=${encodeURIComponent(keyword)}`, { signal: controller.signal, }) .then(res => res.json()) .then(data => { renderSuggestions(data); }) .catch(err => { if (err.name === 'AbortError') return; // 其他错误正常处理 }); });

这套代码的核心价值在于,当用户输入速度非常快时,前面的多次请求会立刻被 abort,只有最后一个请求能顺利进入 then 渲染阶段。实际测试下来,弱网环境下这种处理能给用户带来很明显的“跟手”感,而不是眼睁睁看着联想结果一会儿跳出来一个旧的。

3.3 组件卸载时自动取消请求

前端框架里最常见的诉求:组件销毁以后,请求返回的数据不应该再更新状态。以前很多人用 mounted 里发请求、beforeUnmount 里设一个 this._isDestroyed 标志位来兜底。有 AbortController 之后,可以直接把 signal 存起来,卸载时统一 abort。

拿 React 举例,比较简洁的写法是配合 useEffect 的清理函数:

useEffect(() => { const controller = new AbortController(); fetch('/api/user/info', { signal: controller.signal }) .then(res => res.json()) .then(data => setUser(data)) .catch(err => { if (err.name === 'AbortError') return; console.error(err); }); return () => controller.abort(); }, []);

这里 return 出去的清理函数会在组件卸载时执行,abort 一调用,fetch 就立刻 rejected,自然就不会再走进 setUser 去更新已卸载组件上的状态。很多同学以前会用一个 mounted 标志位来挡,类似这样:

let isMounted = true; fetch(...).then(data => { if (isMounted) setUser(data); });

不能说这种写法完全没效果,但它只能阻止“数据更新组件状态”,无法阻止底层请求把流量跑完。AbortController 是从根源上断掉连接,更彻底,同时对监控埋点也更友好,能真实反映“用户已经离开页面”的场景。

3.4 批量任务管理:上传、下载、轮询

批量上传文件、批量下载素材、定时轮询这类场景,AbortController 也能派上大用场。比如用户选了 10 个文件上传,中间点了一下“取消全部”,如果每个文件都用同一个 signal,一次 abort 就能终止所有上传。

轮询场景更严重一些。如果页面里用 setInterval 不停地发请求,用户切走页面之后,轮询若没有清理,请求会一直打到后端。很多人会用 clearInterval 去清理轮询,但轮询请求本身可能还有一两个正在 pending 中。正确的姿势是 interval 和请求共用一套中止机制:

const controller = new AbortController(); async function poll() { if (controller.signal.aborted) return; try { const res = await fetch('/api/task/status', { signal: controller.signal, }); const data = await res.json(); updateStatus(data); } catch (err) { if (err.name === 'AbortError') return; // 网络异常处理 } } const timer = setInterval(poll, 3000); // 离开页面或者取消任务时 controller.signal.addEventListener('abort', () => { clearInterval(timer); });

这里有一个细节值得注意:我在 poll 函数开头检查了 signal.aborted,原因是 setInterval 的回调可能已经进入排队状态,即使 clearInterval 了,当前这一轮的 poll 还是可能会执行。加一个 aborted 判断,能保证轮询任务中止后,当前在途的那一次请求也不会被发出去。

4. 和其他工具的配合:axios、stream、事件总线

AbortController 不是只在 fetch 里能用。现在很多主流库都已经向它靠拢,下面这几种配合方式在真实项目中的出场率很高。

4.1 axios:从 CancelToken 切换到 AbortController

axios 早期版本提供的是 CancelToken 方案,创始人自己也公开说这个 API 在设计上不是很好,所以后续版本把 AbortController 也纳入了支持范围。

const controller = new AbortController(); axios.get('/api/data', { signal: controller.signal, }).catch(error => { if (axios.isCancel(error)) { console.log('请求已被取消'); } else if (error.name === 'AbortError') { console.log('通过 AbortController 中止'); } });

如果项目用的是老一点的 axios 版本,需要确认版本号是否支持 signal 参数。简单说,新版 axios 里两者都能用,但我更推荐优先用 signal,因为 CancelToken 那套 API 确实显得老旧,而且未来大概率会被逐步淘汰。另外,axios 的 signal 支持同样遵循底层规则:中止后 promise 会进入 rejected 状态,错误对象依然会经过拦截器,所以你在拦截器里对错误做统一提示时,一定要把 AbortError 排除掉,别让用户看到“网络异常”之类的弹窗。

4.2 与 ReadableStream 结合:中止后避免继续读流

fetch 的响应体可以按流的方式读取,比如 large file 下载、SSE 数据流。这种场景下,AbortController 中止后通常会直接终止整个流。但有些特殊场景下,你可能只想中止“读取流的动作”,而不是把整个请求杀掉。

这里有一个实操小技巧:如果你是在消费 response.body.getReader() 之后才调用 abort,某些浏览器里 reader.read() 会抛出一个 AbortError,你需要在这个错误下主动释放 reader。

const controller = new AbortController(); fetch('/api/stream', { signal: controller.signal }) .then(response => { const reader = response.body.getReader(); const decoder = new TextDecoder(); function read() { reader.read().then(({ done, value }) => { if (done) return; console.log(decoder.decode(value)); read(); }).catch(err => { if (err.name === 'AbortError') { console.log('流读取已中止'); reader.releaseLock(); } }); } read(); });

这段代码看起来简单,但它在处理“中止后继续读流”这个细节时非常关键。如果 abort 后不调用 releaseLock,流的锁可能一直占着,后续再想用这个流做点什么都可能遇到 Locked 错误。很多做实时日志、流式聊天的人会在这一块栽跟头,建议记下来。

4.3 AbortSignal 组合:timeout 静态方法

现代浏览器还提供了一个很方便的静态方法 AbortSignal.timeout(ms),用它可以少写一点定时器逻辑:

const res = await fetch('/api/data', { signal: AbortSignal.timeout(5000), });

这个写法会在 5 秒后自动 abort 请求,错误本质上还是 AbortError。它比 setTimeout + clearTimeout 的写法更简洁,但需要确认目标浏览器的兼容性。老版本浏览器不支持时,还是得退回手动定时器方案。

另外,AbortSignal.any() 这个 API 在新版本浏览器里也开始有了,它可以把多个 signal 组合成一个,只要任何一个 signal 被中止,整体就会中止。这个特性在复杂业务里非常有用,比如“页面销毁时要中止”和“超时时要中止”两个条件同时存在,用 AbortSignal.any([signal1, signal2]) 就能干净地合并逻辑。

5. 常见问题与排查技巧实录

AbortController 相关的坑,很多不是它本身有多难,而是它对“中止后发生了什么”的细节不够敏感。这里整理几个最常见的,都是我实际开发中踩过或者帮别人排查过的。

5.1 abort 之后 catch 会触发,别当成网络错误提示

这个前面反复强调过,但还是得单独拎出来说。很多人写完 fetch 之后,所有错误统一走一个 message 提示,结果用户点了取消,马上弹一条“请求失败,请重试”,给人一种取消操作反而出错误的感觉。

正确的错误处理姿势如下:

fetch('/api/data', { signal }) .then(handleResponse) .catch(err => { if (err.name === 'AbortError') { // 静默处理,不弹任何提示 return; } showErrorToast('请求失败,请重试'); });

具体判断条件可以根据库的不同略有差异,但核心思路是一致的:主动中止产生的错误,不是真正的“异常”,是业务逻辑的一部分,不该走异常提示通道。

5.2 服务端到底有没有收到“取消”指令

这是很多人问过的问题。AbortController 只是客户端把连接断开,服务端能不能立刻感知到,取决于底层连接还在不在以及服务端有没有针对连接断开做处理。

HTTP/1.1 和 HTTP/2 下,断开连接的行为不完全一样。大多数情况下,fetch 中止后,浏览器会断开网络连接,服务端如果正在处理这个请求,它的 socket 会被关闭,理论上能够感知到连接异常。但如果服务端已经把响应头发出去了,此时 abort 对服务端的“取消”作用就非常有限,因为响应已经开始传输了。

所以不要把 AbortController 当成取消服务端任务的银弹。如果需要“客户端中止时,服务端也真的停止计算”,通常得靠额外手段,比如发送一个取消指令、用 WebSocket 通知、或者在请求里带一个取消标识。前端可以做的只是“我不再接收这个响应了”,后端怎么配合是另一套工程。

5.3 定时器一定要清理,不然会堆积

如果不用 AbortSignal.timeout,而是自己写 setTimeout,那么请求正常结束后必须清理定时器。很多同学写代码时能记得 abort,但忘了 clearTimeout,结果定时器到点后执行 controller.abort(),虽然不会造成太大危害,但日积月累会在页面上留一堆无效定时器。

const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 10000); fetch('/api/data', { signal: controller.signal }) .then(res => res.json()) .finally(() => clearTimeout(timer));

这里推荐用 finally 而不是 then 里清、catch 里也各写一遍,代码更简短,也能保证无论成功失败都会清理。

5.4 中止请求后,内存里的引用回收了吗

AbortController 作为一个对象,如果长期保存在某个模块级变量里,请求完成后还没有置 null,它引用的 signal 以及 signal 上注册的回调,可能会一直占着内存。

虽然现在浏览器引擎的垃圾回收已经很智能,但我们在框架里使用时还是尽量遵循局部变量的原则:能写在函数里的就别挂到全局,用完后及时释放引用。在 React 场景下,useEffect 返回的清理函数顺手把 controller 置为 null 也是一种好习惯。

5.5 常见问题速查表

为了方便查,我把最常见的几个问题整理成表格。

问题现象可能原因解决思路
请求中止后 catch 弹了错误提示没有判断 err.name === 'AbortError'增加 AbortError 分支,静默处理
中止后服务端任务还在跑abort 只是客户端断连用连接状态感知或额外发送取消指令
所有请求被误杀复用了同一个 signal,且调用了 abort按需创建独立 controller,不要共用
定时器一直在触发没有 clearTimeout在 finally 中清理定时器
abort 后流还在读reader 没有释放调用 reader.cancel() 或 releaseLock()
旧版本浏览器报错浏览器不支持 AbortController引入 polyfill 或改用 XHR.abort() 方案

6. 一些值得收藏的封装思路

最后分享一下我实际项目里比较常用的封装思路。方向不复杂,核心是把“网络错误”“业务错误”“主动中止”三种类型拆开处理,让上层代码不用每次关心细节。

6.1 抽一个统一的 request 方法

常见做法是封装一个 request 函数,内部支持传入外部 signal,同时也能合并超时逻辑。这样业务代码里只需要管业务,不用每次写 AbortError 判断。

async function request(url, { timeout = 8000, signal } = {}) { const controller = new AbortController(); if (signal) { if (signal.aborted) { controller.abort(); } else { signal.addEventListener('abort', () => controller.abort(), { once: true }); } } const timer = setTimeout(() => controller.abort(), timeout); try { const res = await fetch(url, { signal: controller.signal }); return await res.json(); } finally { clearTimeout(timer); } }

这个封装里值得留意的就是 signal 合并逻辑。如果传入的信号已经中止,直接把内部 controller 也 abort;如果没有中止,就监听它,一旦它 abort 就同步 abort 内部 controller。这样外部调用方只要维护自己的 controller,内部超时也不会被覆盖。

6.2 竞态场景的通用 hook

在 React 项目里,我会把竞态保护抽成一个简单 hook。比如 useRequest 里面自动管理 controller,请求发起时先 abort 上一次,组件卸载时自动 abort。业务组件只管调函数,不用管取消逻辑。

function useRequest(requestFn) { const controllerRef = useRef(null); const run = useCallback((...args) => { if (controllerRef.current) { controllerRef.current.abort(); } const controller = new AbortController(); controllerRef.current = controller; return requestFn(...args, controller.signal); }, [requestFn]); useEffect(() => { return () => controllerRef.current?.abort(); }, []); return run; }

这里有一个必须注意的点:useEffect 的清理函数只能看到首次渲染时的 controllerRef,所以你必须在每次 run 时把最新的 controller 存进 ref 里去,而不是把它放到 state 里。用 ref 才能保证清理函数读到的是当前的 controller,否则很可能在卸载时 abort 了上一次的,而当前的还挂在那里。

6.3 分阶段取消:什么时候做轻量封装,什么时候做重型方案

我的建议是,如果项目里只有一两个接口需要取消,没必要上复杂的请求层改造,直接在 fetch 里传入 signal 就行。如果全项目有几十个接口、几百个调用点,那一定要在请求封装层统一处理超时和 signal 合并,否则每个页面都自己写 AbortError 判断,代码很快会发散成一片混乱。

如果是中后台系统,建议把“页面级统一取消”做成一个公共能力。比如路由切换时统一 abort 旧页面所有请求,前提是所有请求都注册到了同一个容器里。这个能力虽然实现起来要花点心思,但对体验的提升非常明显,尤其表单页和数据大屏这种请求密集场景,能明显减少旧请求对用户后续操作的干扰。

7. 最后再讲一个组合小技巧

除了常规的 fetch 中止外,AbortController 还能用来做跨模块的“取消信号”。比如你在 A 模块里发起一个耗时任务,B 模块可以拿到同一个 signal,B 模块在某个时机调 abort(),A 模块所有绑定了这个 signal 的操作都会同时停掉。这个模式很适合做复杂的交互联动。

我在一个数据大屏项目里用过这个思路:屏幕上多个图表分别请求不同接口,右上角有个全局刷新按钮,点击之后先 abort 掉当前所有图表请求,再重新拉数据。实现方式就是全屏共享一个 controller,刷新时 controller.abort(),然后重新 new 一个 controller 挂到全局。对比之前“用状态标记忽略旧响应”的写法,代码量少了很多,也更容易理解。

另外一个小细节:如果你在做 Electron 或桌面容器内嵌 H5 页面,AbortController 同样适用于容器内的网络请求,而且相比页面卸载后的请求挂起,它能更快地释放系统资源,对低配机器来说尤其友好。

我个人在实际项目里的体会是,AbortController 本身只是一个很小的 API,但它解决的是前端异步编程里一个非常底层也非常普遍的痛点:如何去表达“我不再关心这个结果了”。以前这个意图要靠各种标志位、闭包技巧去模拟,现在有了原生能力,写法干净多了。如果你还没在项目里用过,建议从搜索联想、组件卸载取消这两个场景开始试点,改动范围小,收益又明显,踩坑概率也低。用顺手之后再去改请求层统一封装,基本上就不会再有“明明已经中止了,为什么还会报错”这种困惑了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询