JavaScript AbortController 工程化实践:从竞态漏洞到可取消架构
一、从一次线上 Bug 说起
搜索框输入「React」,列表却展示了「Vue」的结果。排查发现:用户输入 R-e-a-c-t 触发了 5 次请求,第 3 次(参数=React)耗时 800ms 返回,第 5 次(参数=Rea)仅用 200ms 先到达——最终页面渲染了 200ms 返回的「Rea」结果,而非用户期待的「React」。
这是一个典型的 竞态条件(race condition) 问题,根因在于:后发起的请求未必后返回。
二、反模式:三种治标不治本的方案
方案A:setTimeout + 锁
let isLoading = false;
let timer = null;
function search(keyword) {
clearTimeout(timer);
timer = setTimeout(async () => {
if (isLoading) return; // ❌ 锁不住并发
isLoading = true;
const data = await fetch(`/api/search?q=${keyword}`).then(r => r.json());
render(data);
isLoading = false;
}, 300);
}
缺陷:锁只能串行化请求,无法取消已在飞行的请求;网络带宽被浪费,服务端压力未减轻。
方案B:序号校验
let seq = 0;
async function search(keyword) {
const id = ++seq;
const data = await fetch(`/api/search?q=${keyword}`).then(r => r.json());
if (id !== seq) return; // ❌ 请求仍在进行,只是丢弃结果
render(data);
}
缺陷:旧请求仍在消耗带宽和连接池,且响应处理逻辑(如 res.json())仍然执行。HTTP/1.1 的 6 连接上限可能被失效请求占满。
方案C:手动维护取消令牌
const pending = new Map();
function search(keyword) {
pending.forEach((abort, key) => { abort(); pending.delete(key); });
const controller = new AbortController();
pending.set(keyword, () => controller.abort());
return fetch(`/api/search?q=${keyword}`, { signal: controller.signal });
}
// ❌ 关键字作键可能冲突,清理逻辑散落各处
三、正解:signal 驱动的可取消架构
AbortController 自 DOM 标准起就是取消编排的原语。它的核心模型是:
AbortController ──signal──▶ 可取消操作 (fetch / addEventListener / ReadableStream)
│ │
.abort() signal.aborted === true / 'abort' 事件
3.1 请求取消:不止于 fetch
// ✅ 每次新搜索自动取消前一次
let controller: AbortController | null = null;
async function search(keyword: string): Promise<SearchResult[]> {
controller?.abort(); // 取消旧请求
controller = new AbortController();
try {
const res = await fetch(`/api/search?q=${keyword}`, {
signal: controller.signal,
});
return res.json();
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
// ✅ 静默处理——这是预期内取消
return [];
}
throw err; // 真正的网络错误仍需上抛
}
}
关键点:必须区分 AbortError 和真实错误。fetch 被 abort 时抛出的 DOMException name 为 'AbortError',与网络超时、DNS 解析失败等错误完全独立。
3.2 事件监听清理:signal 的第二个主战场
// ❌ 反例:容易遗忘 removeEventListener
useEffect(() => {
const handler = (e: MouseEvent) => { /* ... */ };
window.addEventListener('scroll', handler);
return () => window.removeEventListener('scroll', handler);
}, []);
如果多处注册同名回调、或 handler 引用丢失,清理就不可靠。signal 提供声明式方案:
// ✅ addEventListener 的 signal 选项(Chrome 88+ / 所有现代浏览器)
useEffect(() => {
const controller = new AbortController();
window.addEventListener('scroll', handleScroll, { signal: controller.signal });
window.addEventListener('resize', handleResize, { signal: controller.signal });
return () => controller.abort(); // 一行清掉所有监听器
}, []);
一个 controller.abort() 取消 N 个事件监听,无需保存回调引用。这对多事件协同场景尤其优雅。
3.3 ReadableStream 消费中断
// ✅ 大文件下载可中途取消
async function downloadWithProgress(
url: string,
signal: AbortSignal
): Promise<Blob> {
const res = await fetch(url, { signal });
if (!res.body) throw new Error('No body');
const reader = res.body.getReader();
const chunks: Uint8Array[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
// 每次迭代检查 signal,避免循环继续
if (signal.aborted) {
reader.cancel();
throw new DOMException('Aborted', 'AbortError');
}
}
return new Blob(chunks);
}
3.4 组合 signal:AbortSignal.any()
// ✅ 任一上游 signal 触发即取消
function fetchWithTimeout(url: string, ms: number): Promise<Response> {
const timeoutController = new AbortController();
const timeoutId = setTimeout(() => timeoutController.abort(), ms);
// AbortSignal.any() 组合超时+外部取消(Chrome 116+ / Firefox 124+)
const signal = AbortSignal.any([timeoutController.signal, externalSignal]);
return fetch(url, { signal }).finally(() => clearTimeout(timeoutId));
}
这替代了传统的 Promise.race 模式——后者不会真正取消底层 fetch。
四、React Hook 封装:通用 useAbortController
将上述模式收敛为一个可复用的 Hook:
import { useRef, useEffect, useCallback } from 'react';
export function useAbortController() {
const controllerRef = useRef<AbortController | null>(null);
const getSignal = useCallback((): AbortSignal => {
controllerRef.current?.abort();
controllerRef.current = new AbortController();
return controllerRef.current.signal;
}, []);
useEffect(() => {
return () => controllerRef.current?.abort();
}, []);
return { getSignal, abort: () => controllerRef.current?.abort() };
}
// 使用
function SearchBox() {
const { getSignal } = useAbortController();
const onSearch = async (kw: string) => {
const signal = getSignal(); // 自动取消上一次
try {
const res = await fetch(`/api?q=${kw}`, { signal });
// ...
} catch (e) {
if ((e as DOMException).name !== 'AbortError') throw e;
}
};
return <input onChange={e => onSearch(e.target.value)} />;
}
五、TypeScript 类型强化
原生 AbortSignal 在 addEventListener 重载中类型支持不完整。一个轻量增强:
// ✅ 确保 signal 选项被 TypeScript 识别
declare global {
interface AddEventListenerOptions {
signal?: AbortSignal;
}
}
// ✅ 包装 fetch,强制要求 signal 入参
async function safeFetch(
input: RequestInfo,
init: RequestInit & { signal: AbortSignal }
): Promise<Response> {
return fetch(input, init);
}
// 编译期即报错,不会遗漏 signal
// safeFetch('/api', {}); // ❌ TS Error: Property 'signal' is missing
更进一步,封装一个 Result 类型消解 try-catch 嵌套:
type Result<T> = { ok: true; data: T } | { ok: false; error: 'ABORT' | 'NETWORK' };
async function fetchSafe<T>(url: string, signal: AbortSignal): Promise<Result<T>> {
try {
const res = await fetch(url, { signal });
const data = await res.json();
return { ok: true, data };
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
return { ok: false, error: 'ABORT' };
}
return { ok: false, error: 'NETWORK' };
}
}
六、何时不该用 AbortController
| 场景 | 是否适用 | 原因 |
|---|---|---|
| 防抖搜索 | ✅ 最佳场景 | 高频取消+协程安全 |
| 文件上传 | ⚠️ 部分 | abort 只中断请求,已上传片段无法回滚 |
| WebSocket | ❌ 不适用 | 用 ws.close() 更直接 |
| setTimeout | ⚠️ DOM 无原生支持 | 可用 AbortSignal.timeout(ms) 或自行封装 |
| Promise 通用取消 | ⚠️ 有限 | signal 可传入 Promise 构造器,但执行体需主动检查 signal.aborted |
七、关键结论
- 序号校验≠取消:旧请求仍在消耗资源
- sig
addEventListener原生支持 signal:批量清理事件监听的最佳路径 - AbortSignal.any() 是组合取消的正确方式,不应用
Promise.race替代 - 必须区分 AbortError:它们是预期内取消,不应上报 Sentry
- Hook 封装将取消逻辑收敛到组件生命周期,消除散落的 controller 管理
AbortController 从「fetch 取消」这个窄场景,演变成了前端异步编排的通用取消原语。理解它并工程化落地,是从「能跑」跨越到「在任何时序下正确」的关键一步。
评论区
登录 后参与评论