AbortController 不是取消请求

0 阅读4分钟

你大概率写过这样的代码:用户在搜索框快速打字,每敲一个字符就发一次请求,结果第二个请求比第一个先返回,页面上闪了一下"正确答案",又被"旧答案"覆盖了。

这就是竞态条件(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() 一键终止所有任务

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 事件来清除定时器
ReadableStreamstream.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 有什么区别?

答案是它们解决的是完全不同的问题:

维度useTransitionAbortController
解决什么更新优先级——哪个 UI 变化先渲染工作有效性——旧工作是否还值得继续
作用层React 渲染调度器内部浏览器/网络/IO 层
类比分诊台——决定谁先看病止损单——已经没意义的订单直接撤掉
取消了什么低优先级的渲染工作网络请求、事件监听、流式读取等

useTransition 和 AbortController 的职责区别:前者管

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

qrcode_for_gh_6a9e7f3719d6_344.jpg