问题场景
前端处理批量异步任务时,几乎必经这几个痛点:
- 并发失控:一次性发 200 个 HTTP 请求,服务器直接 429 限流甚至被误判为攻击打爆。
- 竞态覆盖:搜索框快速输入,每次触发一个请求;"慢的旧请求"把"快的新结果"覆盖掉,显示错乱数据。
- 资源浪费 :用户取消/组件卸载后,之前的请求还在后台跑,白白消耗带宽,甚至
setState on unmounted报警。 - AI 流式:用户点了"停止",但服务端还在生成,token 哗哗地烧。
这四类问题本质是异步作业的三大控制能力缺失:限流(并发控制)、防竞态、可取消 。本文给出完整的实战方案:手写并发池、两种竞态防护、AbortController 取消,以及 React 里的落地写法。
原理深入
1. 并发控制:为什么需要"池"而不是 Promise.all
Promise.all(urls.map(fetch)) 会同时发起所有请求 ,没有并发上限。浏览器对同一域名的 TCP 连接数有限,但你的异步任务(HTTP/文件/批量处理)不受此限制------上千个并发瞬间铺开,服务器崩、内存爆。
并发池的核心思路 :维护一个"任务队列 + 进行中的计数",同时最多 N 个在跑,跑完一个补一个,像水池放水一样稳定:
text
队列: [t1, t2, t3, ... tm]
进行中: N 个
当某一个结束 → 从队列补一个进来 → 保持进行中始终 ≤ N
2. 竞态防护:为什么"请求顺序"不等于"到达顺序"
竞态(race condition) :多次异步操作,它们的完成顺序 与发起顺序 可能不一致。慢请求先发后到,快请求后发先到,导致旧结果覆盖新结果。
防护的两种主流思路:
- 序号标识:每次发起递增 ID,回调时比对"我是不是最新",不是就丢弃。
- 取消旧请求 :发起新请求前,
abort掉上一个请求。
3. 主动取消:AbortController 的威力
AbortController 是浏览器原生取消信号,核心是 signal:
js
const controller = new AbortController();
const { signal } = controller;
fetch(url, { signal }); // 传 signal
controller.abort(); // 触发取消 → fetch 抛 AbortError
它能取消:fetch、流式读取(body.getReader())、事件监听 { signal },还能配合超时。关键点 :所有异步 API 通过 signal 感知取消,取消时抛 AbortError,必须区分"主动取消"和"真错误"。
实战代码
方案 1:可复用的并发池(p-limit 思想)
js
function createLimiter(limit) {
const queue = []; // 等待队列:存 {fn, resolve, reject}
let active = 0; // 进行中数量
function next() {
// 名额满了 或 队列空,就不动
if (active >= limit || queue.length === 0) return;
const { fn, resolve, reject } = queue.shift();
active++;
// 无论成功失败,finally 里补下一个 → 不会因为某个失败而卡死队列
Promise.resolve()
.then(fn)
.then(resolve, reject)
.finally(() => {
active--;
next(); // 结束一个,拉下一个
});
}
return (fn) =>
new Promise((resolve, reject) => {
queue.push({ fn, resolve, reject });
next(); // 尝试启动
});
}
// 使用:限制同时最多 3 个
const limit = createLimiter(3);
const results = await Promise.all(
urls.map((u) => limit(() => fetch(u).then((r) => r.json())))
);
这个实现的关键细节:
queue + active + next()三要素,滚动执行。finally里补位,失败也不会停摆 (这是新手最容易漏的------用then忘了catch/finally,一个失败整个队列卡死)。- 返回 Promise,可以和
Promise.all组合。
方案 2:竞态防护------序号比对
js
let seq = 0;
async function search(keyword) {
const mySeq = ++seq; // 本次的序号
const data = await fetch(`/api/search?q=${keyword}`).then((r) => r.json());
if (mySeq !== seq) return; // 已被更新的请求顶掉 → 丢弃本次结果
render(data); // 只有最新一次会渲染
}
优点 :简单可靠,不依赖 AbortController 兼容性。缺点:旧请求实际还在跑,浪费资源。
方案 3:竞态防护------取消旧请求(推荐,省资源)
js
let currentController = null;
async function search(keyword) {
if (currentController) currentController.abort(); // 取消上一个
currentController = new AbortController();
try {
const res = await fetch(`/api/search?q=${keyword}`, { signal: currentController.signal });
render(await res.json());
} catch (e) {
if (e.name !== 'AbortError') throw e; // 非取消错误才抛出
// 主动取消:静默忽略,不弹错
}
}
优点 :旧请求真正被中断,省带宽。缺点:依赖 AbortController(现代浏览器都支持)。
方案 4:取消流式读取(AI/SSE,省 token 的关键)
js
const controller = new AbortController();
async function chat(prompt) {
const res = await fetch('/api/chat/stream', {
method: 'POST',
body: JSON.stringify({ prompt }),
signal: controller.signal, // 传入 signal
});
const reader = res.body.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
append(new TextDecoder().decode(value));
}
} catch (e) {
if (e.name === 'AbortError') console.log('用户停止生成');
}
}
// "停止"按钮 → 前端中断 + 服务端感知断开 → 停止生成 → 省 token
stopBtn.onclick = () => controller.abort();
核心价值 :abort 会真正中断 流式读取,服务端收到连接中断也会停止生成。这比"前端假装忽略、让它在后台跑完"省钱得多(AI 按 token 计费)。
方案 5:超时自动取消
js
function fetchWithTimeout(url, ms = 8000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms);
return fetch(url, { signal: controller.signal })
.finally(() => clearTimeout(timer));
}
方案 6:React 组件里的完整落地(卸载取消 + 竞态)
jsx
useEffect(() => {
const controller = new AbortController(); // 本组件一个信号
let cancelled = false; // 或配合序号
async function load() {
try {
const data = await fetch('/api/data', { signal: controller.signal }).then((r) => r.json());
if (!cancelled) setData(data);
} catch (e) {
if (e.name === 'AbortError') return; // 卸载取消,忽略
console.error(e);
}
}
load();
return () => {
controller.abort(); // 卸载时取消请求 → 根治 "setState on unmounted"
cancelled = true;
};
}, []);
要点总结
- 并发池 :
queue + active + next(),结束一个补一个,控制并发数;失败也要finally补位。 - 竞态防护:序号比对(简单)或 AbortController 取消旧请求(省资源),保证只渲染最终结果。
- 取消 :
AbortController统一管 fetch/流式/事件;catch 判AbortError,别把主动取消当错误弹给用户。 - 流式中止能真正中断服务端 → 省 AI token、省带宽。
- React 落地 :
useEffect里创建 controller + 卸载时 abort,根治 setState on unmounted。 - 生产可借力:
p-limit(并发)、useRequest/useSWR(竞态已内置)、原生AbortController(取消)。 - 组合拳:限流 + 防竞态 + 可取消三者一起用,批量异步就从"乱"变成"稳"。
一句话:异步并发控制 = 限流(并发池)+ 防竞态(序号/取消)+ 可取消(AbortController)。三件套配齐,海量请求、竞态闪屏、token 浪费、卸载报警,一次性全解决。