一个案例教你彻底搞明白`AbortController` 、`AbortSignal`

说到前端竞态问题,相信大家并不陌生。但真正落地时,大多数开发者选择忽略边界。哪怕是前端的明星项目vite都不能幸免。

这不是某个项目的问题,而是异步任务取消本身就是个前端难题。直到 AbortController 和 AbortSignal 的出现,提供了一套标准化的异步中断协议,才让"优雅地终止上一次任务"成为可能。

今天我就为大家详解 AbortControllerAbortSignal 这组api。并用一个最直接、最真相、最扎心、最硬核、最干脆、最不墨迹、最不留情面、最一针见血、最开门见山、最单刀直入、最不铺垫、最不客套、最不煽情、最不废话、最不拐弯、最不磨叽、最不装、最不端着、最不啰嗦、最不拖沓、最不委婉、最不掩饰、最不藏着掖着、最直白的实战案例,带你直观掌握任务终止的实现思路。

教学

AbortController:控制端与接收端的解耦

AbortController 的核心设计哲学在于"控制与信号的分离"。它包含两个关键部分:

  • Controller(控制器) :负责发号施令,调用 abort() 方法来触发取消操作。
  • Signal(信号) :负责传递消息,它是一个只读对象,被传递给具体的异步任务。

这套机制最核心的优势就是解耦

  • 任务发起时,只需把 signal 传给异步 API,让任务监听终止信号;
  • 任意位置、任意时机,只需调用控制器的 abort() 方法,即可统一终止所有绑定该信号的任务;

任务执行者任务终止者完全互不感知,彻底解决了传统方案需要透传实例、耦合业务逻辑的问题,实现了控制端与执行端的彻底解耦。

reason

abort()方法可以传入一个终止原因,可以通过signal.reason访问到。默认是一个"AbortError"的DOMException。你可以自行传入原因。

javascript 复制代码
abortController.abort(new DOMException('My reason', 'AbortError'));

原生 AbortSignal 支持

对于已经原生支持的API,我们直接传入abortSignal即可。

fetch 请求:

javascript 复制代码
const controller = new AbortController();
fetch('/api/data', { signal: controller.signal });
setTimeout(() => controller.abort(), 100);

Node.js fs.readFile:

javascript 复制代码
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 函数为例展示。

javascript 复制代码
// 以往我们用Promise封装一个sleep函数,一般写成这样
function sleep(ms) {
    return new Promise(function(resolve) {
        setTimeout(resolve, ms);
    });
}
javascript 复制代码
// 改造后支持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后退出。

javascript 复制代码
// 比如我们要将以下函数改造后支持abortSignal,但是内部的otherFn是已经约定好的,没法改造
async function foo() {
    // ...
    await myFn();
    // ...
    await otherFn();
    // ...
}
javascript 复制代码
// 改造后
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,可以简化上面的判断逻辑:

javascript 复制代码
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。只要其中任意一个源信号被中断,新信号就会被中断。

javascript 复制代码
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。这在实际开发中非常有用。比如你想同时支持"用户手动取消"和"超时自动取消"。

javascript 复制代码
const userController = new AbortController();
const timeoutSignal = AbortSignal.timeout(5000); // 5秒后自动取消

const combinedSignal = AbortSignal.any([
    userController.signal,
    timeoutSignal
]);

AbortError捕获

当异步操作因 AbortController 被取消时,它们通常会抛出一个名为 AbortErrorDOMException。我们需要在 catch 中精准捕获它,以区分"用户主动取消"和"真实的网络/代码错误"。

javascript 复制代码
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。每次重启时,先取消上一次的所有任务,再创建新的控制器:

typescript 复制代码
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,用于细粒度控制单个模块的编译:

typescript 复制代码
export class ModuleNode {
    abortController: AbortController;
    abortSignal: AbortSignal;
}

通过 AbortSignal.any 实现层级取消

ModuleManager 需要同时响应两种取消信号:

  1. 全局信号 :来自 DevServer 的重启,需要终止所有模块的编译
  2. 局部信号:来自单个模块的重新编译,只需要终止该模块上一次编译
  3. 超时信号:如果需要也可以加入超时信号,不过我这里不需要超时
typescript 复制代码
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
        ]);
    }
}

监听文件变化

typescript 复制代码
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 会被取消,从而不会触发无效的编译。

typescript 复制代码
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钩子为例,其它也是一样的。

typescript 复制代码
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);
        // ...
    }
}
typescript 复制代码
async function load(abortSignal: AbortSignal, pluginDriver: PluginDriver, id: string): Promise<SourceDescription> {
    let source: LoadResult = await pluginDriver.hookFirst(abortSignal, 'load', [id]);
    // ...
}
typescript 复制代码
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,直接传入即可:

typescript 复制代码
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 展现出了极其强大的表达力:

  1. 取代了繁琐的命令模式:不再需要维护各种 Task 状态机,信号的传递就是最好的取消指令;
  2. 优雅实现了防抖与竞态处理 :通过给 sleep 加信号,防抖逻辑和竞态阻断浑然一体,全在 try/catch 中以统一的方式处理;
  3. 完美的向下兼容 :对于无法修改签名的第三方 API(Rollup 插件钩子),通过 .aborted 属性实现了"事后检查"的妥协方案;对于原生支持的 API(fs.readFile),则享受了直接传入的畅通无阻;
  4. 巧妙的信号组合 :利用 AbortSignal.any 轻松解决了全局重启与局部重建之间的信号耦合问题。

AbortController 和 AbortSignal 的局限性

原生的AbortControllerAbortSignal虽然能很方便地处理异步任务,但是对于高度复杂场景还是无能为力。比如,异步任务是有全局副作用,接受到abort事件后无法同步清除副作用;再比如,需要插队,排队,等场景。这时候通过命令模式,或参考AbortControllerAbortSignal原理自行封装才能解决。

总结

AbortControllerAbortSignal 为我们提供了一套统一且优雅的异步任务取消协议。它不依赖具体 API 的私有实现,而是通过"控制器 → 信号"的模式,让不同类型的异步任务都能用相同的方式进行管理。

它的核心优势在于解耦标准化

  • 解耦:控制端和接收端互不感知,信号随处可传
  • 标准化:一套 API 通吃 fetch、fs、自定义函数、第三方库

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

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

当然,我们也要认识到它的边界:它是协作式取消 ,不是强制式中断。任务必须主动检查信号、主动配合终止。对于需要复杂调度语义或同步副作用回滚的场景,你仍然需要在它的基础上构建更高层的抽象。

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

相关推荐
ZengLiangYi9 小时前
Tailwind CSS v4 + Vite:现代前端样式方案
前端·css·vite
NIIBLE10 小时前
全栈日记之工程化设计(webpack)
前端·webpack·前端工程化
发现一只大呆瓜1 天前
超全 Vite 性能优化指南:网络、资源、预渲染三维落地方案
前端·面试·vite
发现一只大呆瓜1 天前
Vite 兼容降级全解:语法降级、Polyfill 原理与 legacy 插件底层机制
前端·面试·vite
canonical_entropy2 天前
超越Harness Engineering: AGE 应用开发模板介绍
aigc·ai编程·前端工程化
周淳APP2 天前
【前端工程化原理通识:从源头到运行时的理论阐述】
前端·编译·打包·前端工程化
发现一只大呆瓜2 天前
Vite 开发预构建机制详解,搞懂 esbuild 与 Rollup 分工差异
前端·面试·vite
__zRainy__3 天前
uni-app 全局容器实战系列(一):全局容器的实现
uni-app·vite
发现一只大呆瓜4 天前
Vite凭什么这么快?3分钟带你彻底搞懂 Vite 热更新的幕后黑手
前端·面试·vite