说到前端竞态问题,相信大家并不陌生。但真正落地时,大多数开发者选择忽略边界。哪怕是前端的明星项目vite都不能幸免。
这不是某个项目的问题,而是异步任务取消本身就是个前端难题。直到 AbortController 和 AbortSignal 的出现,提供了一套标准化的异步中断协议,才让"优雅地终止上一次任务"成为可能。
今天我就为大家详解 AbortController 和 AbortSignal 这组api。并用一个最直接、最真相、最扎心、最硬核、最干脆、最不墨迹、最不留情面、最一针见血、最开门见山、最单刀直入、最不铺垫、最不客套、最不煽情、最不废话、最不拐弯、最不磨叽、最不装、最不端着、最不啰嗦、最不拖沓、最不委婉、最不掩饰、最不藏着掖着、最直白的实战案例,带你直观掌握任务终止的实现思路。
教学
AbortController:控制端与接收端的解耦
AbortController 的核心设计哲学在于“控制与信号的分离”。它包含两个关键部分:
- Controller(控制器) :负责发号施令,调用
abort()方法来触发取消操作。 - Signal(信号) :负责传递消息,它是一个只读对象,被传递给具体的异步任务。
这套机制最核心的优势就是解耦:
- 任务发起时,只需把
signal传给异步 API,让任务监听终止信号; - 任意位置、任意时机,只需调用控制器的
abort()方法,即可统一终止所有绑定该信号的任务;
任务执行者和任务终止者完全互不感知,彻底解决了传统方案需要透传实例、耦合业务逻辑的问题,实现了控制端与执行端的彻底解耦。
reason
abort()方法可以传入一个终止原因,可以通过signal.reason访问到。默认是一个"AbortError"的DOMException。你可以自行传入原因。
abortController.abort(new DOMException('My reason', 'AbortError'));
原生 AbortSignal 支持
对于已经原生支持的API,我们直接传入abortSignal即可。
fetch 请求:
const controller = new AbortController();
fetch('/api/data', {
signal: controller.signal });
setTimeout(() => controller.abort(), 100);
Node.js fs.readFile:
const fs = require("fs/promises");
const controller = new AbortController();
fs.readFile('./large-file.txt', {
signal: controller.signal });
setTimeout(() => controller.abort(), 100);
这些原生 API 会在内部监听 AbortSignal,当接收到 abort 信号后,会立即终止底层操作,并以 AbortError 拒绝 Promise。
AbortSignal 监听
对于有配套结束方式的操作,你可以手动监听 AbortSignal 后进行结束。我以一个 sleep 函数为例展示。
// 以往我们用Promise封装一个sleep函数,一般写成这样
function sleep(ms) {
return new Promise(function(resolve) {
setTimeout(resolve, ms);
});
}
// 改造后支持abortSignal
function sleep(ms, abortSignal) {
return new Promise(function(resolve, reject) {
var timer = setTimeout(resolve, ms);
if(abortSignal) {
// 监听取消事件
abortSignal.addEventListener('abort', function() {
clearTimeout(timer);
reject(abortSignal.reason);
});
}
});
}
.aborted 属性
一些异步函数可能是对外API,我们无法决定修改签名。对于无法加入异步函数,我们可以手动判断abortSignal后退出。
// 比如我们要将以下函数改造后支持abortSignal,但是内部的otherFn是已经约定好的,没法改造
async function foo() {
// ...
await myFn();
// ...
await otherFn();
// ...
}
// 改造后
async function foo(abortSignal) {
// ...
await myFn(abortSignal);
// myFn 的实现有可能在 resolve 与 await 恢复之间的间隙里 abort
if(abortSignal.aborted) throw abortSignal.reason;
// ...
await otherFn();
// otherFn 不支持 abort
if(abortSignal.aborted) throw abortSignal.reason;
// ...
}
throwIfAborted
AbortSignal 还提供了 throwIfAborted() 实例方法,如果信号已经被取消,会直接抛出 signal.reason,可以简化上面的判断逻辑:
async function foo(abortSignal) {
// ...
await myFn(abortSignal);
// myFn 的实现有可能在 resolve 与 await 恢复之间的间隙里 abort
abortSignal.throwIfAborted();
// ...
await otherFn();
// otherFn 不支持 abort
abortSignal.throwIfAborted();
// ...
}
AbortSignal.any
有时候,一个异步操作可能由多个信号中的任意一个控制取消。AbortSignal.any(signals) 允许你将多个 AbortSignal 合并成一个新的 AbortSignal。只要其中任意一个源信号被中断,新信号就会被中断。
const controller1 = new AbortController();
const controller2 = new AbortController();
// 合并信号:controller1 或 controller2 任意触发,compositeSignal 都会中断
const compositeSignal = AbortSignal.any([controller1.signal, controller2.signal]);
fetch('/api/data', {
signal: compositeSignal });
// controller1.abort(); // 这会触发 compositeSignal 的中断
AbortSignal.timeout
静态方法 AbortSignal.timeout() 返回一个指定时间后将自动中止的 AbortSignal。这在实际开发中非常有用。比如你想同时支持"用户手动取消"和"超时自动取消"。
const userController = new AbortController();
const timeoutSignal = AbortSignal.timeout(5000); // 5秒后自动取消
const combinedSignal = AbortSignal.any([
userController.signal,
timeoutSignal
]);
AbortError捕获
当异步操作因 AbortController 被取消时,它们通常会抛出一个名为 AbortError 的 DOMException。我们需要在 catch 中精准捕获它,以区分“用户主动取消”和“真实的网络/代码错误”。
try {
await fetch('/api/data', {
signal: controller.signal });
} catch (err) {
if (err.name === 'AbortError') {
console.log('用户主动取消了请求,无需报错');
} else {
console.error('发生了真实的网络错误', err);
}
}
开发实战
我们前端在开发构建步骤时,常常需要处理热更新问题。每当文件保存时,需要重新编译文件。假如文件保存时,如果上一次编译过程还没有完成,需要终止上一次编译,否则有可能出现竞态问题。
在过去,我通过命令模式把构建代码抽象成了“步骤Step”、“任务Task”、“调度器”等概念。虽然是能够解决问题的,但是代码变得十分繁杂且抽象。最近我用AbortController重构了原本的逻辑,代码瞬间就清澈了。
我们来看看关键代码:
服务器重启时,和文件修改时需要终止任务
在 DevServer 中,我们维护一个全局的 AbortController。每次重启时,先取消上一次的所有任务,再创建新的控制器:
export class DevServer {
abortController: AbortController;
abortSignal: AbortSignal;
constructor() {
this.abortController = new AbortController();
this.abortSignal = this.abortController.signal;
}
async restart() {
this.abortController.abort();
this.abortController = new AbortController();
this.abortSignal = this.abortController.signal;
}
}
每个模块节点 ModuleNode 也维护自己的 AbortController,用于细粒度控制单个模块的编译:
export class ModuleNode {
abortController: AbortController;
abortSignal: AbortSignal;
}
通过 AbortSignal.any 实现层级取消
ModuleManager 需要同时响应两种取消信号:
- 全局信号:来自
DevServer的重启,需要终止所有模块的编译 - 局部信号:来自单个模块的重新编译,只需要终止该模块上一次编译
- 超时信号:如果需要也可以加入超时信号,不过我这里不需要超时
export class ModuleManager {
abortSignal: AbortSignal;
public async start(abortSignal: AbortSignal) {
this.abortSignal = abortSignal;
}
public restart(abortSignal: AbortSignal, config: ResolvedConfig) {
return this.start(abortSignal);
}
// 每当模块创建、模块更新时,会调用这个。
public watchModule(module: ModuleNode) {
let abortController = new AbortController();
module.abortController = abortController;
// 局部信号 + 全局信号,任意一个触发都会取消当前模块编译
module.abortSignal = AbortSignal.any([
this.abortSignal,
abortController.signal
]);
}
}
监听文件变化
export class ModuleManager {
private async onChange(path: string) {
let modules: ModuleNode[];
// ... 根据路径找到相关模块节点
modules.forEach(module => {
// ...
module.abortController.abort();
this.watchModule(module);
this.rebuild(module.abortSignal, module).then(() => {
// ...
}).catch((e) => {
if (e.name !== 'AbortError') throw e; // 忽略中断错误
});
});
}
}
防抖后再编译
所谓防抖,其本质就是一个可中断的 sleep 任务。如果在这 250ms 内文件又变化了,上一次的 sleep 会被取消,从而不会触发无效的编译。
export class ModuleManager {
public async rebuild(abortSignal: AbortSignal, node: ModuleNode) {
// ...
await sleep(250, abortSignal); // 防抖等待
await this.build(abortSignal, node);
// ...
}
}
处理rollup的load钩子
为了兼容rollup插件,我们要把rollup的编译过程的代码抄下来,以保证运行效果和rollup一致。为了支持终止信号,我将对里面的函数进行改造。我这里以load钩子为例,其它也是一样的。
export class ModuleManager {
public async build(abortSignal: AbortSignal, node: ModuleNode) {
// ...
// 里面的load、transform、parseModule都是从rollup源代码中抄过来,改造成abortSignal版本以兼容rollup插件
const sourceDescription = await load(abortSignal, this.pluginDriver, id);
module.updateOptions(sourceDescription);
const moduleJSON: TransformModuleJSON = await transform(abortSignal, sourceDescription, module, this.pluginDriver, this.options.onLog);
module.setSource(moduleJSON);
await parseModule(abortSignal, this, module);
// ...
}
}
async function load(abortSignal: AbortSignal, pluginDriver: PluginDriver, id: string): Promise<SourceDescription> {
let source: LoadResult = await pluginDriver.hookFirst(abortSignal, 'load', [id]);
// ...
}
class PluginDriver {
// 这个从rollup中抄下来进行abortSignal改造,只要内部的异步函数都加入abortSignal支持即可
hookFirst<H extends AsyncPluginHooks & FirstPluginHooks>(
abortSignal: AbortSignal,
hookName: H,
parameters: Parameters<FunctionPluginHooks[H]>,
replaceContext?: ReplaceContext | null,
skipped?: ReadonlySet<Plugin> | null
): Promise<ReturnType<FunctionPluginHooks[H]> | null> {
return this.hookFirstAndGetPlugin(abortSignal, hookName, parameters, replaceContext, skipped).then(
result => result && result[0]
);
}
// 这个从rollup中抄下来进行abortSignal改造,由于load hook是事先约定好的,没法再加入abortSignal支持,所以就运行后判断.aborted。
async hookFirstAndGetPlugin<H extends AsyncPluginHooks & FirstPluginHooks>(
abortSignal: AbortSignal,
hookName: H,
parameters: Parameters<FunctionPluginHooks[H]>,
replaceContext?: ReplaceContext | null,
skipped?: ReadonlySet<Plugin> | null
): Promise<[NonNullable<ReturnType<FunctionPluginHooks[H]>>, Plugin] | null> {
for(const plugin of this.getSortedPlugins(hookName)) {
if(skipped?.has(plugin)) continue;
const result = await this.runHook(hookName, parameters, plugin, replaceContext);
if(abortSignal.aborted) {
let abortError = new DOMException('Aborted after "' + plugin.name + '" run hook ' + hookName, 'AbortError');
abortError.cause = abortSignal.reason;
throw abortError;
}
if(result != null) return [result, plugin];
}
return null;
}
}
node 原生 api 读取文件
如果插件没有自定义 load 方式,使用默认的 fs.readFile。由于 Node.js 原生支持 AbortSignal,直接传入即可:
async function load(
abortSignal: AbortSignal,
pluginDriver: PluginDriver,
id: string
): Promise<SourceDescription> {
let source: LoadResult = await pluginDriver.hookFirst(abortSignal, 'load', [id]);
if(!source) {
// 使用默认的 load 方式:fs.readFile
source = await readFile(id, {
encoding: 'utf-8',
signal: abortSignal // 原生支持,直接传入
});
// ...
}
// ...
}
实战小结
在这个构建系统重构的案例中,AbortController 展现出了极其强大的表达力:
- 取代了繁琐的命令模式:不再需要维护各种 Task 状态机,信号的传递就是最好的取消指令;
- 优雅实现了防抖与竞态处理:通过给
sleep加信号,防抖逻辑和竞态阻断浑然一体,全在try/catch中以统一的方式处理; - 完美的向下兼容:对于无法修改签名的第三方 API(Rollup 插件钩子),通过
.aborted属性实现了“事后检查”的妥协方案;对于原生支持的 API(fs.readFile),则享受了直接传入的畅通无阻; - 巧妙的信号组合:利用
AbortSignal.any轻松解决了全局重启与局部重建之间的信号耦合问题。
AbortController 和 AbortSignal 的局限性
原生的AbortController 和 AbortSignal虽然能很方便地处理异步任务,但是对于高度复杂场景还是无能为力。比如,异步任务是有全局副作用,接受到abort事件后无法同步清除副作用;再比如,需要插队,排队,等场景。这时候通过命令模式,或参考AbortController、AbortSignal原理自行封装才能解决。
总结
AbortController 和 AbortSignal 为我们提供了一套统一且优雅的异步任务取消协议。它不依赖具体 API 的私有实现,而是通过"控制器 → 信号"的模式,让不同类型的异步任务都能用相同的方式进行管理。
它的核心优势在于解耦和标准化:
- 解耦:控制端和接收端互不感知,信号随处可传
- 标准化:一套 API 通吃 fetch、fs、自定义函数、第三方库
在实际项目中,你可以把 AbortController 看作是异步任务的本地总开关:
- 它能帮你优雅地处理请求超时、用户主动取消、页面卸载等场景
- 它能统一异步任务的生命周期管理方式
- 它能减少资源浪费,避免无意义的等待和处理
当然,我们也要认识到它的边界:它是协作式取消,不是强制式中断。任务必须主动检查信号、主动配合终止。对于需要复杂调度语义或同步副作用回滚的场景,你仍然需要在它的基础上构建更高层的抽象。
熟练掌握并恰当使用 AbortController,会让你的异步代码不仅能"跑起来",更能"收得住"。