[转] 异步任务取消机制

异步任务取消机制

作者:谢杰

该文章是并发异步操作系列文章第一篇。

为什么需要取消异步任务

在现代的 Web 和 Node.js 应用中,我们经常需要启动一些耗时较长的异步任务,比如:

  • 下载大文件
  • 进行高强度的计算
  • 持续监听一个长时间的事件流

然而,一旦任务开始运行,传统上我们只能等待它结束或因异常而中断。在很多情况下,这并不理想,因为我们可能需要主动终止任务,例如:

  • 用户已经切换到其他页面,不再关心结果
  • 请求超时
  • 用户点击了“取消”操作

在早期的 Web API 中,XHR 提供过 .abort() 方法,但基于 Promise 的 fetch() 等 API 最初并没有类似的能力。

为了填补这一空白,WHATWG 在 2017 年将 AbortControllerAbortSignal 纳入 DOM Standard,作为统一的取消信号机制。

和早期的 XHR 的实现所不同的是,早期的 XHR 里的 .abort() 方法,这是 XHR 自己实现的取消逻辑,其他 API 用不了。AbortController 则是 WHATWG 定义的统一标准,任何 API 只要愿意支持,都可以接收它的 signal 来实现取消(例如 fetchReadableStream、Node.js 的 fs.readFilesetTimeout 等等)。

虽然它最初出现在浏览器端,但这套设计与 DOM 并非深度耦合,只依赖于事件派发机制,因此 Node.js 自 v15.0.0(2020 年)起原生支持了 AbortControllerAbortSignal(低于 v15 的版本可通过 node-abort-controller 等 polyfill 实现兼容)。如今,你可以在 Node 环境下的 fetch()streamtimers 等异步 API 中,使用与浏览器一致的取消方案。

快速上手

接下来我们来看一下核心 API,有两个:

  • AbortController
  • AbortSignal

为啥会有两个呢?来看下面的图:

image-20250808161836438

上面的示意图展示了 AbortController 与 AbortSignal 的工作原理。

在没有取消机制时(上图),客户端发起异步请求后,只能一直等待服务器完成耗时处理,即便中途不再需要结果,也无能为力。

引入取消机制后(下图),AbortController 就像一只“遥控器”,可以在任务执行过程中随时发出 AbortSignal。异步任务收到信号后,会立即停止执行,从而避免无意义的等待和资源消耗。

你可以将这套设计理解为:

  • Controller:信号的发射端
  • Signal:信号的接收端

这种模式让异步任务的生命周期可控,在请求超时、用户主动取消或页面切换等场景下,都能优雅、安全地终止任务。

下面来看一下代码层面具体该怎么写:

// 创建一个 AbortController 实例,用于管理和发出取消信号
const controller = new AbortController();

// 从控制器中获取对应的 AbortSignal 对象
// 这个 signal 会被传递给需要支持取消的 API
const signal = controller.signal;

// 使用 fetch 发送请求,并在配置中传入 signal
// 一旦 signal 被触发(abort),fetch 会立即中止
fetch("https://example.com", { signal })
  .then(res => {
    // 当请求正常完成时会进入这里
    console.log("请求成功");
  })
  .catch(err => {
    // 如果是因为调用了 controller.abort() 而中断,会返回 AbortError 类型的错误
    if (err.name === "AbortError") {
      console.log("请求被中断");
    } else {
      // 其他类型的错误,例如网络错误或服务器返回非 2xx 状态码
      console.error("请求发生错误", err);
    }
  });

// 模拟延迟 2 秒后手动中止请求
// 当执行 controller.abort() 时,会触发 signal 的中止状态
// 已经关联了这个 signal 的 fetch 请求会被立即终止
setTimeout(() => controller.abort(), 2000);

在这个示例中,我们用最简单的方式演示了 AbortController 和 AbortSignal 如何配合 fetch() 实现“可中途取消”的网络请求。

首先,我们创建了一个 AbortController 实例,它就像任务的“遥控器”,专门用来发出取消的指令。接着,通过 controller.signal 获取与之绑定的 AbortSignal 对象。这个 signal 会被传递给支持取消机制的 API(例如 fetch),作为任务的监听端。

fetch 启动后,它会一直监听这个 signal 的状态。如果我们在任务执行中调用了 controller.abort()signal 会立即变为“中止”状态,fetch 也会立刻终止请求,并抛出一个 AbortError

在这段代码里,我们用 setTimeout 模拟了一个“2 秒后取消请求”的场景——这在实际开发中可能对应着请求超时、用户点击取消按钮、或页面切换等情况。

这种模式的好处是,你可以用一套统一的方式去管理异步任务的生命周期,不需要依赖特定 API 提供的私有取消方法,从而让取消逻辑更优雅、更易维护。

相关细节

在了解了 AbortController 的核心理念后,接下来我们来看一下该 API 一些细枝末节的知识。

前面我们聊了 AbortController 的用途和基本用法,但如果你真的要在项目里用得顺手,还是得知道它有哪些“开关”和“按钮”。这一节,我们就来扒一扒它的细节——属性、方法、甚至是那些看似不起眼的静态方法。

1. controller.signal

这是 AbortController 唯一的属性,也是它存在的核心意义。
你可以把它理解为 “传话的麦克风”——只要控制器这边说“停”,所有拿着这个 signal 的任务都会听到。

  • 类型AbortSignal
  • 常用场景:传给 fetch()ReadableStreamsetTimeout 等支持取消的 API。
const controller = new AbortController();
const { signal } = controller;

fetch("/api/data", { signal })
  .then(r => r.json())
  .then(console.log)
  .catch(err => {
    if (err.name === "AbortError") console.log("被取消了");
    else console.error(err);
  });

// 3 秒后取消
setTimeout(() => controller.abort("timeout"), 3000);

2. controller.abort([reason])

这是 AbortController 最常用的方法,也是你按下“终止任务”按钮的地方。

  • 作用:触发 signalabort 事件,让关联的异步任务立刻中止。

  • 可选参数 reason:可以传一个自定义原因,比如 "Timeout" 或一个 Error 对象,这样在捕获时就能知道为什么被中止。

    注意点:调用 controller.abort() 一次就够了,多次调用没有额外效果(signal.aborted 会保持 true)。

const c = new AbortController();
const s = c.signal;

fetch("/slow", { signal: s }).catch(err => {
  // 在现代浏览器/Node 里,err.name === "AbortError"
  // 同时 err.message / s.reason 里能拿到“取消原因”
  if (s.aborted) {
    console.log("已取消,原因:", s.reason); // e.g. "user-cancel" or Error(...)
  }
});

document.getElementById("cancel").addEventListener("click", () => {
  c.abort("user-cancel");
});

3. 静态方法 AbortSignal.abort(reason)

听到 AbortSignal.abort() 这个名字,你可能会疑惑:它和实例方法 controller.abort() 到底有什么不同?

区别在于:AbortSignal.abort() 是一个快捷工厂——直接生成一个已经处于中止状态的 AbortSignal,免去了你先 new AbortController() 再手动调用 abort() 的步骤。

它非常适合那种一开始就确定要中止的场景,比如你在写一个工具函数时,发现条件不满足,就立即返回一个“无效”的信号,让后续逻辑立刻停下。

来看个例子:假设我们有一个支持取消的文件下载工具函数:

async function downloadFile(url, signal) {
  // 检查这个 AbortSignal 是否已经处于中止状态
  // 如果已中止,立即抛出 AbortError,避免发起无意义的请求
  // 这是 AbortSignal 提供的同步检查方法(现代浏览器/Node.js 均支持)
  signal.throwIfAborted();
  const res = await fetch(url, { signal });
  return res.blob();
}

现在有个需求:

  • 用户必须登录才能下载文件
  • 如果未登录,不仅要阻止下载,还要避免发起任何网络请求

AbortSignal.abort() 就能优雅地实现:

function getDownloadSignal(isLoggedIn) {
  if (!isLoggedIn) {
    // 创建一个“已中止”的 signal,原因是未登录
    return AbortSignal.abort(new Error("User not logged in"));
  }
  const controller = new AbortController();
  return controller.signal;
}

// 模拟未登录
const signal = getDownloadSignal(false);

// 这里会立刻抛出 AbortError
downloadFile("/big-file.zip", signal)
  .then(() => console.log("下载完成"))
  .catch(err => {
    if (err.name === "AbortError") {
      console.log("下载被取消:", signal.reason);
    } else {
      console.error(err);
    }
  });

这样做的好处:

  1. 零浪费:不会发送无意义的请求。
  2. 调用方逻辑一致:依旧通过捕获 AbortError 处理取消,无需特殊分支。
  3. 写法简洁:少了创建控制器再手动 abort() 的步骤。

4. 静态方法 AbortController.timeout(ms)

这是 Node.js 从 v15.4.0 开始加的“便捷版”(浏览器端目前大部分不支持,需自行 polyfill)。

  • 作用:创建一个 AbortSignal,并在指定的毫秒数后自动进入中止状态。
  • 优势:不用自己写 setTimeout 再调用 abort(),更简洁。
  • 典型场景:请求超时控制,比如 fetch(url, { signal: AbortController.timeout(5000) })
// 5 秒超时:到点自动进入 aborted 状态
const signal = AbortSignal.timeout(5000);

fetch("/maybe-slow", { signal })
  .then(r => r.text())
  .then(console.log)
  .catch(err => {
    if (err.name === "AbortError") {
      console.log("超时了");
    } else {
      console.error(err);
    }
  });

总的来讲,AbortController 的设计非常简洁。

唯一的属性是 signal,唯一的实例方法是 abort()

但配合 AbortSignal,它能在异步任务中提供一致、优雅的取消能力。再加上静态方法的加持,你既能自己掌控中止时机,也能用“一键定时取消”快速处理超时场景。

进阶思考

设想这样一个场景:你在网页上点击了“删除数据库记录”,请求很快发了出去,服务器那边的 SQL 语句已经开始准备执行。突然,你反悔了,立刻调用 controller.abort()。此时,这个删除动作真的还能被阻止吗?

现实可能会让你有些失望——大多数情况下,答案是否定的

AbortController 的取消逻辑,其实只作用在客户端,它能做到的包括:

  • 让浏览器立即停止发送或接收数据
  • 让绑定了该信号的异步任务(如 fetch())立刻结束,并抛出 AbortError
  • 丢弃本地的任务结果,不再进行后续处理

但是,一旦请求已经抵达服务器并开始执行,客户端的中止信号并不能“远程撤销”后端的操作。换句话说,删除数据库的 SQL 语句可能已经在运行,你的 abort() 并不能让它立刻停下来。

这就像你在餐馆点了一份大餐,订单已经送到厨房,厨师正忙着下锅。这时你接到一个急事电话,必须马上离开——虽然你对服务员说“不用了”,但厨房那边其实已经在做,这道菜并不会因为你离开而自动停下。

如果真想实现全链路取消,就必须有后端的配合——在任务执行过程中检查取消信号,并在检测到时主动中止操作。

因此,AbortController 在前端任务管理中非常实用,但要想让它变成“全局任务终止按钮”,前后端需要协同设计取消机制。

写在最后

在现代前后端开发中,异步任务的可控性越来越重要。无论是浏览器端的 fetch() 请求、WebSocket 长连接,还是 Node.js 中的文件流、定时器任务,能够在合适的时机中止它们,意味着更好的资源管理和更流畅的用户体验。

AbortControllerAbortSignal 为我们提供了一套统一且优雅的取消机制——它不依赖具体 API 的私有实现,而是通过“控制器 → 信号”的模式,让不同类型的异步任务都能用相同的方式进行管理。这不仅降低了心智负担,也让取消逻辑更容易抽象成可复用的工具。

当然,我们也要认识到它的边界:取消信号只在客户端生效,并不能直接干预已经在服务器上执行的逻辑。如果需要真正的“全链路取消”,就必须在后端配合检查取消状态,并在合适的时机主动中止处理。

因此,在实际项目中,你可以把 AbortController 看作是异步任务的本地总开关

  • 它能帮你优雅地处理请求超时、用户主动取消、页面卸载等场景;
  • 它能统一异步任务的生命周期管理方式;
  • 它能减少资源浪费,避免无意义的等待和处理。

熟练掌握并恰当使用 AbortController,会让你的异步代码不仅能“跑起来”,更能“收得住”。


-EOF-

posted @ 2025-10-29 14:32  Zhentiw  阅读(8)  评论(0)    收藏  举报