概述
本文是笔者的系列博文 《Bun技术评估》 中的第二十六篇。
在本文的内容中,笔者主要想要来探讨一下Bun中的AbortController和相关的问题。
在本文成文过程和提及Abort这个话题的时候,笔者脑海中老是出现战争和动作电影中,主人公遇到不利情况下,大呼"Abort Misson!!"的场景,很有既视感。

关于AbortController(ABC)
我们知道,使用JS语言的Bun、Nodejs和浏览器都使用异步操作的工作方式。这就带来一个问题,如何有效和精确的控制这些操作的过程?特别是希望它可以在超时或者某些需要的情况下进行中断,这就引入了所谓的AbortController(中止控制器,ABC)的机制。
ABC是JavaScript中用于中止异步操作(例如 fetch() 请求、定时任务等)的控制器对象。它已经作为一个标准模型在浏览器原生支持,当然现在也能在Bun和Nodejs里直接使用。所以,ABC本质上,并不是Bun的特性,而是JS的特性。
所以,其实在Bun的技术文档中,并没有单独的AbortController的板块,而是作为WebAPI的一部分,直接使用MDN中相关的章节:
developer.mozilla.org/en-US/docs/...
通过引入ABC,而非简单的条件错误处理,可以得到下列优势:
- 可控性
ABC提供了一种一致性的异步操作的控制模式。可以从外部按需控制异步操作是否执行或者随时终止。
- 资源管理
使用ABC,可以更有效的管理资源的使用。例如在页面切换时,即时终止未完成的网络请求,从而节省内存和流量的使用。
- 安全性
当多个操作同时存在时,后发操作可能比前一个更早的返回操作结果。如果需要,可以控制这些操作即时取消,避免数据冲突。
- 一致性和可组合性
ABC可以应用在任何支持signal模式的操作方法(API)。这些API包括但不限于: Fetch、ReadableStream、WritableStream、WebSocket、FileReader、WebUSB、WebRTC等等。而且开发者还能在自定义异步函数中,通过监听signal来实现自己的中断逻辑。
为了直观理解ABC的工作模式和在实际环境中的应用,笔者觉得通过一些简单的示例程序,是一种比较好的方式。
应用示例
基本方式
先来看一段使用ABC控制fetch超时操作的典型代码,这很好的展现了ABC的基本应用方式:
js
const controller = new AbortController();
const signal = controller.signal;
fetch('https://example.com', { signal })
.then(response => response.text())
.then(data => console.log(data))
.catch(err => {
if (err.name === 'AbortError') {
console.log('请求被中止了');
} else {
console.error('其他错误:', err);
}
});
// 2秒后取消请求
setTimeout(() => controller.abort(), 2000);
简单总结一下:
- 这里需要控制的是fetch这个异步方法,其他的异步方法,也可以使用类似方式(只需要支持signal机制)
- 先创建一个控制器实例
- 实例中有signal属性
- 将signal属性作为可选参数(名字也是signal)调用执行fetch
- 按需调用controller实例的abort的方法(此处是一个超时),可以通过signal在异步方法中,触发AbortError错误
- AbortError会终止fetch操作,并抛出错误
- 捕获该错误,并失败AbortError,就可以确认是Abort引发的终止操作
所以,实际上ABC包括控制器(Controller)和信号(Signal)两个部分。控制器可以执行abort方法,并且通过关联的信号,注入到需要控制的异步方法当中实际执行中止操作,并体现在异步方法的错误捕获模型当中。
中止监听
除了在异步函数的错误捕获中处理中止操作之外,还可以基于signal状态,来处理中止操作,这样程序结构和逻辑好像更加清晰一点:
js
// 增加事件监听器
signal.addEventListener('abort', () => {
// Logs true:
console.log(signal.aborted);
});
// 或者 onabort方法,更简洁方便
signal.onabort = () => {
console.log("Request aborted");
};
单信号多操作
如果异步操作比较复杂,比如涉及到多个关联的异步操作,可以使用单一信号来进行控制。下面是一个简单的示例:
js
async function fetchStory({ signal } = {}) {
const storyResponse = await fetch('/story.json', { signal });
const data = await storyResponse.json();
const chapterFetches = data.chapterUrls.map(async url => {
const response = await fetch(url, { signal });
return response.text();
});
return Promise.all(chapterFetches);
}
const controller = new AbortController();
const signal = controller.signal;
fetchStory({ signal }).then(chapters => {
console.log(chapters);
});
此处的示例也是一个常见的应用场景。先请求一个列表,然后再分别请求列表项目的内容, 可以看成是一种复合性的异步操作,这时就可以使用单一的信号来控制整个操作过程,简单而清晰。
AbortSignal 中止信号
AbortSignal(ABS)是一个ABC相关类,实际上就是signal的原始类。从类定义中,我们可以看到,ABS其实是继承自EventTarget这个类。
ABS有几个静态的类方法,在某些情况下,可以简化代码编写:
js
...
try {
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
const result = await res.blob();
// ...
} catch (err) {
if (err.name === "TimeoutError") {
console.error("Timeout: It took more than 5 seconds to get the result!");
}
...
如果只考虑超时中断,我们可以看到在代码中,可以省略了定义控制器和信号实例的部分,直接使用AbortSignal.timeout()方法,它会返回一个超时自动中止的signal实例,并且在异步操作中抛出超时错误。
组合操作
下面是一个组合了用户主动中止和超时中止的控制方式,它利用了AbortSignal.any方法:
js
// 取消控制器和按钮
const userCancelController = new AbortController();
document.getElementById("cancelDownloadButton").addEventListener("click", () => {
userCancelController.abort();
});
// Timeout after 5 minutes
const timeoutSignal = AbortSignal.timeout(1_000 * 60 * 5);
// This signal will abort when either the user clicks the cancel button or 5 minutes is up
// whichever is sooner
const combinedSignal = AbortSignal.any([
userCancelController.signal,
timeoutSignal,
]);
try {
const res = await fetch(someUrlToDownload, {
// Stop the fetch when any of the signals aborts
signal: combinedSignal,
});
const body = await res.blob();
// Do something with downloaded content:
// ...
} catch (e) {
if (e.name === "AbortError") {
// Cancelled by the user
} else if (e.name === "TimeoutError") {
// Show user that download timed out
} else {
// Other error, e.g. network error
}
}
也就是说,在异步方法注入的signal参数可以是一个数组,并且组合各种signal实例。任何一个实例的中止操作,都会在异步方法中抛出中止错误。这个特性,极大的方便了复杂情况下中止操作控制逻辑的编写实现。
覆盖操作
这也是一个可能的使用场景,比如一个搜索操作,在结果还未返回前,用户可能希望修改搜索参数,就需要中断现有的操作并且创建一个新的操作。下面是相关示例代码:
js
let controller;
async function search(query) {
if (controller) controller.abort(); // 取消上一个请求
controller = new AbortController();
const signal = controller.signal;
try {
const res = await fetch(`/search?q=${query}`, { signal });
const data = await res.json();
console.log('最新结果', data);
} catch (err) {
if (err.name === 'AbortError') console.log('旧请求已取消');
}
controller = null;
}
代码中先判断当前是否有正在执行的搜索,如果有就取消,并且重建搜索操作方法和中止控制器。
立即中止
ABS有一个静态方法AbortSignal.abort(),它会返回一个"立即终止"的信号实例:
js
// 基本形式
const signal = AbortSignal.abort();
// 相当于:
const controller = new AbortController();
controller.abort();
return controller.signal;
这很奇怪,在正常情况下,为什么要声明和处理一个立即终止的操作呢?通过请教chatGPT,它提出了一些理由如下:
- 接口设计的占位信号
某些函数、库、组件要求必须传入一个 signal 参数(比如内部要监听取消事件),但你当前场景根本不希望它真的执行。就可以传入一个立刻中止的信号实例,而不用破坏接口实现定义或者引入判断分支逻辑。
- 延迟初始化 / 空对象模式
当系统还没准备好时(例如组件还没挂载、网络不可用、服务未启动),可以用"已中止的 signal"当作一个安全的空操作。
- 状态表达:显式表示"任务不可执行"
在复杂异步系统中,你可能有多个"任务通道"(task slot),某些 slot 被逻辑判定为不可用。此时,用一个已中止 signal 就能表达这种"永久不可执行"的状态。
- 测试,特别是取消逻辑
单元测试或集成测试中,很多函数需要验证"如果任务被中止,是否正确响应"。这时不可能等真实异步执行完,就可以直接传入"已中止 signal"。
- 兼容或容错
有时我们希望在安全模式下防止某些模块实际执行异步行为:例如离线状态、维护模式、或安全沙盒。这时可以简洁的通过条件返回一个合适的signal:
js
function getSafeSignal(allow) {
if (!allow) {
const c = new AbortController();
c.abort();
return c.signal;
}
return new AbortController().signal;
}
await fetch('/api', { signal: getSafeSignal(isOnline) });
最后,让笔者感到比较困惑的是,为什么ABS没有提供一个abort实例方法,来简化程序呢:
js
// 笔者理想中的代码
const signal = AbortSignal.abort(false);
// 注入异步方法
const res = await fetch(`/search?q=${query}`, { signal });
....
条件中止
signal.abort("SomeReson");
如果实现上述的模式,其实是可以简化掉AbortController的。
小结
在本文中,笔者研究和探讨了JS异步操作标准的中断模式:AbortController。包括基本概念,适合使用场景和基本模式,并进一步探讨了更复杂的高级和组合模式,并讨论了相关的AbortSignal和使用方法。