文章

取消异步请求方案深度解析

取消异步请求方案深度解析

一句话概括

AbortController 是浏览器原生的”取消按钮”——把它的 signal 塞进 fetch,随时调用 abort() 就能干净利落地中断请求,告别竞态 bug 和内存泄漏。

核心知识点

1. 三行代码看懂 AbortController

1
2
3
4
5
6
7
8
9
10
const controller = new AbortController();

fetch('/api/data', { signal: controller.signal })
  .then(res => res.json())
  .catch(err => {
    if (err.name === 'AbortError') console.log('请求被取消了');
  });

// 任何时候调用,请求就被掐断
controller.abort();
  • new AbortController() 创建控制器,controller.signal 是信号对象
  • signal 传给 fetch(或 axios 的 signal 参数),API 内部就会监听它
  • controller.abort()signal.abortedtrue → fetch 抛 AbortError
  • abort() 还能传个 reasoncontroller.abort('用户离开页面'),通过 signal.reason 拿到

2. React 组件卸载时自动取消

这是面试必问。关键只有一句话:在 useEffect 清理函数里调 abort()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function UserCard({ id }) {
  useEffect(() => {
    const controller = new AbortController();

    fetch(`/api/users/${id}`, { signal: controller.signal })
      .then(r => r.json())
      .then(setUser)
      .catch(err => {
        if (err.name !== 'AbortError') console.error(err);
      });

    // 👇 组件卸载或 id 变化时,自动取消上一次请求
    return () => controller.abort();
  }, [id]);

  // ...
}

为什么能跑:useEffect 的清理函数会在依赖变化时先执行(取消旧请求),然后才跑新一轮 effect。

3. 搜索框防抖 + 取消,双保险

用户敲 “react” → 发请求A → 继续敲 “ hooks” → 立马取消A,发请求B。这是竞态处理的经典范式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function useSearch() {
  const controllerRef = useRef(null);

  const search = useCallback(async (keyword) => {
    // 第一步:取消上一次请求
    controllerRef.current?.abort();
    // 第二步:创建本次请求的 controller
    controllerRef.current = new AbortController();

    try {
      const res = await fetch(`/api/search?q=${keyword}`, {
        signal: controllerRef.current.signal
      });
      return await res.json();
    } catch (err) {
      if (err.name === 'AbortError') return; // 被取消,正常情况,不理
      throw err;
    }
  }, []);

  // 组件卸载时清理
  useEffect(() => () => controllerRef.current?.abort(), []);

  return search;
}

配合防抖使用效果最佳——用户停止输入 300ms 后才发请求,期间上一个请求早已被 abort。

4. 同时管住一堆请求

一个 AbortController 只能取消共享同一个 signal 的请求。要分组管理多个场景(页面切走全取消、单个请求单独取消),可以自己封装:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class RequestPool {
  #controllers = new Map();

  fetch(key, url, opts = {}) {
    this.#controllers.get(key)?.abort(); // 同 key 互斥
    const ctrl = new AbortController();
    this.#controllers.set(key, ctrl);
    return fetch(url, { ...opts, signal: ctrl.signal });
  }

  abort(key) {
    this.#controllers.get(key)?.abort();
    this.#controllers.delete(key);
  }

  abortAll() {
    this.#controllers.forEach(c => c.abort());
    this.#controllers.clear();
  }
}

const pool = new RequestPool();
pool.fetch('list', '/api/list');
pool.fetch('detail', '/api/detail/42');
// 页面切走
pool.abortAll();

5. AbortSignal.any() 组合多个取消源

有时候一个操作可能被多种原因取消:用户手动点取消 OR 组件卸载 OR 超时。用 AbortSignal.any() 把它们捆一起:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const userCancelCtrl = new AbortController();
const timeoutCtrl = new AbortController();
const unmountCtrl = new AbortController();

// 2 秒超时
setTimeout(() => timeoutCtrl.abort(), 2000);

// 任意一个信号触发,请求就被取消
const signal = AbortSignal.any([
  userCancelCtrl.signal,
  timeoutCtrl.signal,
  unmountCtrl.signal,
]);

fetch('/api/heavy', { signal }).catch(err => {
  if (err.name === 'AbortError') console.log('请求被中断:', signal.reason);
});

注意:AbortSignal.any() 是较新的 API(Chrome 116+),面试提到属于加分项。

其实你每天都在用

场景一:搜索框输入联想 你敲 “天气”,页面发了请求;继续敲 “天气预报”,上一个请求立刻被 abort。不是你网络快,是 AbortController 把旧请求掐了。没有它,你会先看到 “天气” 的结果闪现再被覆盖。

场景二:SPA 页面切换 从列表页点进详情页的瞬间,列表页还没返回的数据请求被取消。否则你到了详情页,列表请求才回来,setState 到已卸载的组件上——React 报黄牌警告 Can't perform a React state update on an unmounted component

场景三:上传大文件中途后悔 你选了 2GB 的视频上传,进度条走到 30%,手滑选错了文件,点了取消。前端立马 controller.abort(),浏览器关掉 TCP 连接,不再浪费带宽——虽然服务器那 30% 已经写入磁盘了(这就是为什么非 GET 请求取消要在后端做幂等)。

场景四:表单提交防重复 用户狂点”提交”按钮,传统做法是 loading 状态 + disabled,但如果你用 AbortController,每次点击前先把上一次 abort 掉,确保同一时刻只有一个请求在路上。

常见误解

❌ 误区一:调了 abort(),服务器就不会收到请求了

HTTP 请求一旦从网卡发出,服务器大概率已经收到并开始处理。abort() 只是客户端不再等待响应,浏览器会关闭 TCP 连接。对于 GET 请求,反正数据不会变,重发就行。对于 POST/PUT,服务器可能已经写入数据了——所以非幂等接口建议带上幂等键(idempotency key)。

❌ 误区二:Promise 可以被取消

JavaScript 里原生 Promise 一旦创建就无法取消。pending → fulfilled/rejected 是不可逆的。网上看到那些”可取消 Promise”的方案,本质上都是额外包一层状态标记(let cancelled = false),在 .then 里判断——并不是真正取消了异步任务本身。真正的取消还是得靠 AbortController 把底层操作(fetch、定时器等)中断掉。

❌ 误区三:cancel 了就不用 catch 了

很多人以为 abort 之后万事大吉,结果控制台飘红。fetch 被取消时会抛 AbortError,它是一个 DOMException。如果你的代码里有 await fetch(...) 却没 try-catch 或者 catch 里不判断 error.name === 'AbortError',未捕获的 rejection 就出去了。

1
2
3
4
5
6
7
8
9
10
// ❌ 错误示范
await fetch(url, { signal }); // abort 时直接抛异常,没人接

// ✅ 正确写法
try {
  await fetch(url, { signal });
} catch (err) {
  if (err.name === 'AbortError') return; // 取消是预期行为,忽略
  throw err; // 其他错误往上抛
}

❌ 误区四:axios 的 CancelToken 还能继续用

axios 从 v0.22 开始原生支持 signal 参数,CancelToken 已在 v1.0 被彻底移除。面试说 “axios 取消请求用 CancelToken” 等于告诉面试官你用的是三年前的文档。

1
2
3
4
5
6
7
8
9
// ❌ 过时的写法
const source = axios.CancelToken.source();
axios.get('/api', { cancelToken: source.token });
source.cancel();

// ✅ 现在的写法
const controller = new AbortController();
axios.get('/api', { signal: controller.signal });
controller.abort();

一句话总结

每个异步请求都配一个 AbortController,组件卸载就 abort——这就是现代前端处理请求取消的全部秘密,简单但面试官就想听你说出来。

本文由作者按照 CC BY 4.0 进行授权