[转] 异步任务取消机制

异步任务取消机制

作者:谢杰

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

为什么需要取消异步任务

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

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

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

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

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

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

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

虽然它最初出现在浏览器端,但这套设计与 DOM 并非深度耦合,只依赖于事件派发机制,因此 Node.js 自 v15.0.0(2020 年)起原生支持了 AbortController 与 AbortSignal(低于 v15 的版本可通过 node-abort-controller 等 polyfill 实现兼容)。如今,你可以在 Node 环境下的 fetch()、stream、timers 等异步 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()、ReadableStream、setTimeout 等支持取消的 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 最常用的方法,也是你按下“终止任务”按钮的地方。

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

  • 可选参数 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 中的文件流、定时器任务,能够在合适的时机中止它们,意味着更好的资源管理和更流畅的用户体验。

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

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

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

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

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


-EOF-

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