前端开发··2 阅读·预计 17 分钟

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 类型强化

原生 AbortSignaladdEventListener 重载中类型支持不完整。一个轻量增强:

// ✅ 确保 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

七、关键结论

  1. 序号校验≠取消:旧请求仍在消耗资源
  2. sigaddEventListener 原生支持 signal:批量清理事件监听的最佳路径
  3. AbortSignal.any() 是组合取消的正确方式,不应用 Promise.race 替代
  4. 必须区分 AbortError:它们是预期内取消,不应上报 Sentry
  5. Hook 封装将取消逻辑收敛到组件生命周期,消除散落的 controller 管理

AbortController 从「fetch 取消」这个窄场景,演变成了前端异步编排的通用取消原语。理解它并工程化落地,是从「能跑」跨越到「在任何时序下正确」的关键一步。

0 评论

评论区

登录 后参与评论