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

简介: 今天这个案例,最硬核、最扎心、最真相、最干脆、最不墨迹、最不留情面、最一针见血、最开门见山、最单刀直入、最不铺垫、最不客套、最不煽情、最不废话、最不拐弯、最不磨叽、最不装、最不端着、最不啰嗦、最不拖沓、最不委婉、最不掩饰、最不藏着掖着、最直白、最彻底、最决绝、最了当。

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

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

今天我就为大家详解 AbortControllerAbortSignal 这组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 被取消时,它们通常会抛出一个名为 AbortErrorDOMException。我们需要在 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 需要同时响应两种取消信号:

  1. 全局信号:来自 DevServer 的重启,需要终止所有模块的编译
  2. 局部信号:来自单个模块的重新编译,只需要终止该模块上一次编译
  3. 超时信号:如果需要也可以加入超时信号,不过我这里不需要超时
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 展现出了极其强大的表达力:

  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,会让你的异步代码不仅能"跑起来",更能"收得住"。

目录
相关文章
|
10天前
|
人工智能 开发工具 iOS开发
Claude Code 新手完全上手指南:安装、国产模型配置与常用命令全解
Claude Code 是一款运行在终端环境中的 AI 编程助手,能够直接在命令行中完成代码生成、项目分析、文件修改、命令执行、Git 管理等开发全流程工作。它最大的特点是**任务驱动、终端原生、轻量高效、多模型兼容**,无需图形界面、不依赖 IDE 插件,能够深度融入开发者日常工作流。
3255 9
|
3天前
|
人工智能 自然语言处理 文字识别
阿里云百炼Qwen3.7-Max简介:能力、优势、支持订阅计划参考
Qwen3.7-Max是阿里云百炼面向智能体时代推出的新一代旗舰模型,对标GPT-5.5、Claude Opus 4.7等闭源旗舰。该模型支持百万级token上下文窗口,具备顶级推理能力、多模态搜索与视觉理解增强、流式输出低延迟响应等核心优势,覆盖编程、办公、长周期自主执行等复杂场景。同时支持OpenAI接口兼容,便于系统快速迁移。用户可通过Token Plan团队或节省计划等订阅方式灵活调用,适合企业级高要求场景使用。
阿里云百炼Qwen3.7-Max简介:能力、优势、支持订阅计划参考
|
13天前
|
Shell API 开发工具
Claude Code 快速上手指南(新手友好版)
AI编程工具卷疯啦!Claude Code凭借任务驱动+终端原生的特性,成了开发者的效率搭子。本文从安装、登录、切换国产模型到常用命令,手把手带新手快速上手,全程避坑,30分钟独立用起来。
3309 23
|
7天前
|
人工智能 Linux BI
国内用 Claude Code 终于不用翻墙了:一行命令搞定,自动接 DeepSeek
JeecgBoot AI专题研究 一键脚本:Claude Code + JeecgBoot Skills + DeepSeek 全平台接入 一行命令装好 Claude Code + JeecgBoot Skills + DeepSeek 接入,无需翻墙使用 Claude Code,支持 Wind
2319 4
国内用 Claude Code 终于不用翻墙了:一行命令搞定,自动接 DeepSeek
|
26天前
|
人工智能 JSON 供应链
畅用7个月无影 JVS Claw |手把手教你把JVS改造成「科研与产业地理情报可视化大师」
LucianaiB分享零成本畅用JVS Claw教程(学生认证享7个月使用权),并开源GeoMind项目——将JVS改造为科研与产业地理情报可视化AI助手,支持飞书文档解析、地理编码与腾讯地图可视化,助力产业关系图谱构建。
23597 15
畅用7个月无影 JVS Claw |手把手教你把JVS改造成「科研与产业地理情报可视化大师」
|
13天前
|
人工智能 JSON BI
DeepSeek V4-Pro 接入 Claude Code 完全实战:体验、测试与关键避坑指南
Claude Code 作为当前主流的 AI 编程辅助工具,凭借强大的代码理解、工程执行与自动化能力深受开发者喜爱,但原生模型的使用成本相对较高。为了在保持能力的同时进一步降低开销,不少开发者开始寻找兼容度高、价格更友好的替代模型。DeepSeek V4 系列的发布带来了新的选择,该系列包含 V4-Pro 与 V4-Flash 两款模型,并提供了与 Anthropic 完全兼容的 API 接口,理论上只需简单修改配置,即可让 Claude Code 无缝切换为 DeepSeek 引擎。
2799 3
|
4天前
|
人工智能 自然语言处理 安全
Claude Code 全攻略:命令大全+三种模式+记忆体系+实战工作流完整手册
Claude Code 是当前最流行的终端级 AI 编程助手,能够直接在命令行中完成代码生成、项目理解、文件修改、命令执行、错误修复等全流程开发工作。它不依赖图形界面、不占用额外资源,却能深度理解项目结构,自动生成规范代码,大幅提升研发效率。
877 2
|
11天前
|
存储 Linux iOS开发
【2026最新】MarkText中文版Markdown编辑器使用图解(附安装包)
MarkText是一款免费开源、跨平台的Markdown编辑器,主打所见即所得实时预览,支持Windows/macOS/Linux。内置数学公式、流程图、代码高亮、多主题及PDF/HTML导出,是Typora的轻量免费替代首选。(239字)

热门文章

最新文章