你大概率写过这样的代码:用户在搜索框快速打字,每敲一个字符就发一次请求,结果第二个请求比第一个先返回,页面上闪了一下"正确答案",又被"旧答案"覆盖了。
这就是竞态条件(race condition)。大多数人解决它的方式是加一个标志位:let cancelled = false,然后在清理函数里 cancelled = true。
能用,但丑。而且它只解决了"不渲染旧数据"——那个已经失去意义的 HTTP 请求,还是跑完了全程,浪费了带宽和服务器资源。
AbortController 解决的不是"取消请求",而是"终止已经失去业务意义的异步工作"。
这是一个比大多数人理解的要深得多的概念。
一、三个角色,各司其职
先把 AbortController 的结构拆清楚。它的设计只有三个核心部分:
| 角色 | 职责 | 类比 |
|---|---|---|
AbortController | 决策者——决定何时取消 | 指挥官 |
AbortSignal | 信使——把"取消"这个消息传递给具体任务 | 传令兵 |
abort() | 动作——按下取消按钮 | 发令枪 |
这里有一个非常关键的设计决策:controller 自己不执行取消,它只负责发信号。真正接收信号并执行取消动作的,是那些拿到了 signal 的异步任务。
const controller = new AbortController();
const signal = controller.signal;
// signal 传给 fetch,fetch 才知道要监听取消
fetch('/api/search', { signal });
// 如果你只创建了 controller 但没把 signal 接进去
// 调用 abort() 不会有任何效果
fetch('/api/search'); // ← 这个请求无法被取消
这就像你派了一个信使出去,但信使根本不知道该去找谁——消息发了,没人收。
只创建 controller 但没传 signal,取消不会生效。这是新手最常踩的坑。
AbortController 架构:controller 发出信号,signal 传递给多个异步任务,abort() 一键终止所有任务
二、不只是 fetch
大多数教程讲到 AbortController,都是配合 fetch 使用。但如果你以为它只能取消网络请求,那就严重低估它了。
AbortController 能取消的东西远比你想象的多:
| 可取消的异步操作 | 如何接入 signal |
|---|---|
fetch() 请求 | fetch(url, { signal }) |
响应体读取(response.text() / .json()) | fetch 被 abort 后,读取响应体同样抛出 AbortError |
addEventListener 事件监听器 | el.addEventListener('click', fn, { signal }) |
setTimeout / setInterval | 手动监听 signal 的 abort 事件来清除定时器 |
| ReadableStream | stream.pipeTo(dest, { signal }) |
| 任何你自己写的异步函数 | 接受 signal 参数,内部检查 signal.aborted |
事件监听器这个能力特别值得说。传统写法要移除监听器,你必须保存 handler 引用:
// 传统方式:必须保存引用才能移除
const handler = (e) => console.log(e);
window.addEventListener('resize', handler);
// 清理时
window.removeEventListener('resize', handler);
问题来了:如果 handler 是匿名函数呢?如果你绑了十几个监听器呢?一个个手动移除,既啰嗦又容易遗漏。用 AbortController 就优雅多了:
// AbortController 方式:一次 abort 全部移除
const controller = new AbortController();
window.addEventListener('resize', handleResize, { signal: controller.signal });
window.addEventListener('scroll', handleScroll, { signal: controller.signal });
document.addEventListener('keydown', handleKey, { signal: controller.signal });
// 一键清理全部三个监听器
controller.abort();
一个 controller 管一组相关任务。这不是巧合,是设计意图。
三、作用域对齐:controller 该跟谁同生共死?
这是整篇文章最重要的洞察。
大多数人把 AbortController 当作一个"工具"——需要取消的时候 new 一个。但更准确的理解是:一个 controller 的生命周期,应该和一个"业务失效范围"对齐。
什么叫业务失效范围?就是"当某个条件变化时,哪些正在进行的工作同时失去意义"。
举几个例子:
| 业务失效范围 | controller 的作用域 | 示例 |
|---|---|---|
| 一次搜索 | 用户输入新关键词时,旧搜索结果失效 | 搜索框防抖 |
| 一个视图 | 用户切换 Tab 时,旧 Tab 的数据请求失效 | Dashboard 多面板 |
| 一个页面生命周期 | 用户导航离开时,当前页面所有异步操作失效 | React 组件卸载 |
| 一次用户操作 | 用户点击"取消"时,正在上传的文件失效 | 文件上传 |
对应到 React,这就是 useEffect 的清理函数:
useEffect(() => {
const controller = new AbortController();
const { signal } = controller;
// 把所有副作用绑到同一个 signal
window.addEventListener('resize', handleResize, { signal });
fetch(`/api/data?id=${id}`, { signal })
.then(res => res.json())
.then(setData)
.catch(err => {
if (err.name !== 'AbortError') throw err;
});
// 组件卸载或依赖变化 → 一键终止所有异步工作
return () => controller.abort();
}, [id]);
注意这个模式的优雅之处:fetch 和 addEventListener 共享同一个 signal,组件卸载时一个 abort() 全部清理。不需要分别记住"哪些请求要取消、哪些监听器要移除"。
生物学里有个概念叫程序性细胞死亡(apoptosis) ——不是因为细胞出了问题才死亡,而是身体判断"这个细胞不再被需要了",主动发出凋亡信号。AbortController 就是异步任务的凋亡机制:不是因为任务出错,而是因为业务上下文变了,这些工作不再值得继续。
controller 的作用域 = 业务失效范围。对齐了,资源管理就自然了。
四、三个你可能不知道的新 API
AbortSignal 在过去两年新增了三个静态方法,解决了之前需要手动处理的痛点:
AbortSignal.timeout(ms) — 一行搞定超时
// 之前:手动创建定时器 + 记得清理
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch('/api/slow', { signal: controller.signal });
clearTimeout(timer);
} catch (err) {
clearTimeout(timer); // 容易忘!
}
// 现在:一行搞定
const res = await fetch('/api/slow', {
signal: AbortSignal.timeout(5000)
});
而且 timeout 抛出的是 TimeoutError,而手动 abort() 抛出的是 AbortError——你可以精确区分"超时了"还是"用户主动取消了"。
AbortSignal.any(signals) — 多条件组合
当一个操作可能因多种原因被取消时——用户点了取消按钮、页面跳转了、超时了——你需要把多个信号组合起来:
const userController = new AbortController();
cancelBtn.onclick = () => userController.abort();
const signal = AbortSignal.any([
userController.signal, // 用户取消
AbortSignal.timeout(30000), // 30 秒超时
]);
await uploadFile(file, { signal });
任意一个条件触发,操作就终止。不需要手动协调多个定时器和事件处理器。(兼容性:Node.js 20+,90% 的浏览器已支持。)
signal.throwIfAborted() — 快速失败
在自定义异步函数的入口处,先检查信号是否已被取消,避免启动注定白费的工作:
async function processLargeDataset(data, { signal }) {
signal.throwIfAborted(); // 如果已取消,直接抛出,不做无用功
for (const chunk of data) {
signal.throwIfAborted(); // 每轮迭代都检查
await processChunk(chunk);
}
}
这三个 API 的共同思想:把"取消"从一个需要手动编排的复杂操作,变成一个声明式的简洁表达。
五、AbortController vs useTransition:别搞混
如果你用 React,可能会困惑:useTransition 也是处理"异步更新"的,它和 AbortController 有什么区别?
答案是它们解决的是完全不同的问题:
| 维度 | useTransition | AbortController |
|---|---|---|
| 解决什么 | 更新优先级——哪个 UI 变化先渲染 | 工作有效性——旧工作是否还值得继续 |
| 作用层 | React 渲染调度器内部 | 浏览器/网络/IO 层 |
| 类比 | 分诊台——决定谁先看病 | 止损单——已经没意义的订单直接撤掉 |
| 取消了什么 | 低优先级的渲染工作 | 网络请求、事件监听、流式读取等 |
useTransition 和 AbortController 的职责区别:前者管"谁先渲染",后者管"旧工作是否继续"
经济学里有个概念叫沉没成本谬误——人们倾向于因为"已经投入了时间/金钱"而继续一件不值得做的事。AbortController 帮你避免这个陷阱:已经发出去的请求不会因为你继续等待就变得更有价值。及时止损,才是理性选择。
useTransition 解决"更新优先级",AbortController 解决"旧工作是否值得继续"。它们是互补的,不是替代的。
六、一个实战模式:可取消的搜索
把前面所有知识串起来,写一个生产级的可取消搜索 Hook:
function useSearch(query) {
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const controllerRef = useRef(null);
useEffect(() => {
if (!query) {
setResults([]);
return;
}
// 取消上一次搜索(业务失效范围:一次搜索)
controllerRef.current?.abort();
controllerRef.current = new AbortController();
setLoading(true);
fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: controllerRef.current.signal,
})
.then(res => res.json())
.then(data => {
setResults(data);
setLoading(false);
})
.catch(err => {
if (err.name !== 'AbortError') {
setLoading(false);
throw err;
}
// AbortError 说明这次搜索已被新搜索取代,静默处理
});
return () => controllerRef.current?.abort();
}, [query]);
return { results, loading };
}
关键点:
• 每次 query 变化,先 abort() 旧请求再发新请求——零竞态条件
• AbortError 不是错误,是正常的业务流转——旧搜索被新搜索取代
• controllerRef 用 useRef 而不是 useState,因为它不触发重渲染
catch 里静默处理 AbortError,是 AbortController 的标准模式。取消不是异常,是设计。
如果你只想带走一句话,我建议记这个:
AbortController 的本质不是"取消请求"——是让异步工作的生命周期和业务意义保持对齐。signal 传给谁,谁才能被取消;controller 跟谁同生共死,就决定了哪些工作一起失效。
下次写 useEffect 的时候,别急着写 fetch。先问自己一个问题:这个副作用的失效范围是什么? 答案清楚了,controller 放在哪里,自然就清楚了。
参考原文:
• MDN Web Docs — AbortController
• MDN Web Docs — AbortSignal
• EdgeCases — AbortController: Patterns Beyond Fetch