← 返回文章

JavaScript / Fetch / 浏览器 API

AbortController 实战:取消过期请求,终结前端竞态问题

搜索框连续输入“r”“re”“react”,前端会发出三次请求。网络顺序不可控,最早发出的请求可能最后返回,于是页面把“react”的结果又覆盖成“r”的结果。组件已经卸载,请求仍在下载;用户点击取消,界面变了,网络却没有停。

这些问题不能只靠一个 loading 布尔值解决。AbortController 提供了标准的取消信号,可以中止 Fetch 请求、响应体读取和其他支持 AbortSignal 的异步操作。更重要的是,它能把“这个结果已经没有意义”写进代码生命周期。

一、最小取消示例

AbortController 产生一个 signal,把它交给 fetch,需要取消时调用 abort()

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
27
const controller = new AbortController();

async function loadProducts() {
try {
const response = await fetch('/api/products', {
signal: controller.signal,
});

if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}

return await response.json();
} catch (error) {
if (error.name === 'AbortError') {
return null;
}

throw error;
}
}

loadProducts();

cancelButton.addEventListener('click', () => {
controller.abort();
});

取消通常会让 Fetch Promise 以 AbortError 拒绝。它不是网络故障,也不应该弹出“服务异常”;但也不能用宽泛 catch 把所有错误都当成取消,否则真正的 500、解析失败和代码错误都会被吞掉。

二、搜索联想只保留最新请求

每次输入时先取消上一轮请求:

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
27
28
let activeController = null;

async function search(keyword) {
activeController?.abort();
activeController = new AbortController();

const controller = activeController;

try {
const response = await fetch(
`/api/search?q=${encodeURIComponent(keyword)}`,
{ signal: controller.signal },
);

if (!response.ok) {
throw new Error(`Search failed: ${response.status}`);
}

const result = await response.json();

if (controller !== activeController) return;
renderSearchResult(result);
} catch (error) {
if (error.name !== 'AbortError') {
showSearchError(error);
}
}
}

这里同时保留身份检查。正常情况下,旧 Fetch 会被取消;但请求结束后还可能存在自定义异步转换、缓存读取等不支持 signal 的步骤。controller !== activeController 可以阻止旧流程在后续阶段写回 UI。

防抖和取消不是二选一:防抖减少请求数量,取消保证已经发出的过期请求不会继续影响结果。搜索场景通常两者都需要。

三、每个 Controller 只能代表一次生命周期

signal 一旦 aborted,就会一直保持取消状态。不要把同一个 controller 保存为全局单例反复使用:

1
2
3
4
5
const controller = new AbortController();
controller.abort();

// 这个请求一开始就会处于已取消状态
fetch('/api/data', { signal: controller.signal });

正确模型是“一次可取消操作或一组共同生命周期的操作,对应一个 controller”。重试时创建新的实例。

如果一个页面切换需要同时请求用户、权限和偏好,可以共享同一 signal:

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

const requests = Promise.all([
fetch('/api/user', { signal: controller.signal }),
fetch('/api/permissions', { signal: controller.signal }),
fetch('/api/preferences', { signal: controller.signal }),
]);

function leavePage() {
controller.abort();
}

这样离开页面时能一起取消;如果三个请求应独立重试,就应使用不同 controller。

四、在 React effect 中清理请求

依赖变化或组件卸载时取消上一轮操作:

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
27
28
29
30
31
32
import { useEffect, useState } from 'react';

function UserProfile({ userId }) {
const [state, setState] = useState({ status: 'loading' });

useEffect(() => {
const controller = new AbortController();

async function loadUser() {
setState({ status: 'loading' });

try {
const response = await fetch(`/api/users/${userId}`, {
signal: controller.signal,
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);

const user = await response.json();
setState({ status: 'success', user });
} catch (error) {
if (error.name === 'AbortError') return;
setState({ status: 'error', message: error.message });
}
}

loadUser();

return () => controller.abort();
}, [userId]);

return <ProfileState state={state} />;
}

userId 变化,React 会先运行旧 effect 的清理,再启动新请求。取消不是为了消除某条框架警告,而是释放不再需要的网络与解析工作,并避免旧结果覆盖新状态。

五、超时取消

现代环境可以使用 AbortSignal.timeout() 创建超时信号:

1
2
3
4
5
6
7
async function loadWithTimeout(url) {
const response = await fetch(url, {
signal: AbortSignal.timeout(5000),
});

return response.json();
}

如果项目浏览器基线还不支持,可以用 controller 和定时器实现,并确保清理定时器:

1
2
3
4
5
6
7
8
9
10
async function fetchWithTimeout(url, timeoutMs) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);

try {
return await fetch(url, { signal: controller.signal });
} finally {
clearTimeout(timeoutId);
}
}

超时值应来自业务期望,不是越短越好。上传、报表生成和普通查询的合理等待时间完全不同。

六、响应返回后仍然可以被取消

Fetch Promise resolve 只说明响应头已经可用,不代表响应体已经全部读取。如果在 response.json() 前取消,读取响应体仍可能以 AbortError 结束。MDN 明确说明 AbortController 能中止 Fetch 请求、响应体消费和流。

1
2
3
4
5
6
const response = await fetch(url, { signal });

await someAsyncCheck();

if (signal.aborted) return;
const data = await response.json();

对于很大的 JSON、流式响应和文件下载,这个边界尤其重要。

七、取消前端请求不等于回滚服务端

浏览器停止等待响应,不代表服务端一定停止处理。POST 已经到达服务器后,即使客户端 abort,数据库写入仍可能完成。因此:

  • 创建订单、支付等操作需要幂等键;
  • 取消按钮不能被当成服务端事务回滚;
  • 重试写操作前应查询最终状态或使用请求 ID 去重;
  • 服务端若支持协作式取消,需要单独设计协议。

AbortController 管理的是客户端异步生命周期,不是分布式事务。

八、常见错误

  1. 把 controller 全局复用,第一次取消后所有请求都立即失败。
  2. catch 中无条件忽略错误,把服务器故障误当成用户取消。
  3. 取消 Fetch 后仍让旧的异步转换更新页面。
  4. 只做防抖,不处理已经发出的慢请求。
  5. 组件卸载时只设置 isMounted,请求和响应体仍继续消耗资源。
  6. 以为取消 POST 就能撤销服务端操作。

九、行动清单

  • 哪些请求会因为路由、搜索词或组件参数变化而过期?
  • 一组请求应该共同取消,还是分别重试?
  • AbortError、超时、HTTP 错误和解析错误是否分开处理?
  • 取消后是否还有不支持 signal 的异步步骤会写回 UI?
  • 写操作是否具备服务端幂等与最终状态查询?

取消不是异常流程,而是前端并发模型的一部分。只要用户可以改变主意、路由可以切换、参数可以变化,就会产生“结果已经没有意义”的时刻。为每次操作建立明确的 signal,并在生命周期结束时取消,竞态问题才不会靠运气消失。

参考资料