前端并发控制的实战技巧与常见陷阱解析
谁更灵活?谁更省事?
最近给一个后台数据导出功能加并发控制,本来以为就几行 throttle/debounce 事情,结果翻车了三次——第一次用 setTimeout 手搓队列,导出中途点了五次按钮,后台收到七个请求;第二次上了 lodash.throttle,发现它压根不支持「等上一个执行完再决定是否执行下一个」;第三次换 async-pool,倒是能控数量,但错误处理一塌糊涂,失败了连哪个任务挂的都看不到。
折腾完我干脆把项目里用过的、查文档看过的、甚至同事甩过来的几个方案全拉出来,真刀真枪跑了一遍。不是为了搞学术对比,纯粹是不想下次再花两小时 debug 并发逻辑。
我最常写的三个方案
下面这几个,我都在线上跑过,不是玩具代码。排名不分先后,按我实际踩坑顺序来。
1. 手写 Promise 队列(我目前主力)
我比较喜欢用这个,核心就一个原则:所有任务必须排队,串行执行,失败不中断,可取消。不是为了性能,是为了可控。
代码不复杂,但得自己管状态:
class SerialQueue {
constructor() {
this.queue = [];
this.isRunning = false;
}
push(fn) {
return new Promise((resolve, reject) => {
this.queue.push({ fn, resolve, reject });
this.next();
});
}
async next() {
if (this.isRunning || this.queue.length === 0) return;
this.isRunning = true;
const { fn, resolve, reject } = this.queue.shift();
try {
const result = await fn();
resolve(result);
} catch (err) {
reject(err);
} finally {
this.isRunning = false;
this.next(); // 继续下一个
}
}
clear() {
this.queue = [];
}
}
// 用法:
const queue = new SerialQueue();
document.getElementById('export-btn').addEventListener('click', () => {
queue.push(() => fetch('https://jztheme.com/api/export', {
method: 'POST',
body: JSON.stringify({ type: 'user' })
}).then(r => r.json()));
});
优点:逻辑完全透明,加 loading、重试、取消、日志埋点都随心所欲。我上周刚给它加了个 timeout(10000) 包装,超时自动 reject,没动队列本身一行。
缺点:要自己维护状态,clear() 得想清楚什么时候调(比如用户切页了就得清)。还有个坑:如果 fn 本身没返回 Promise,会直接 resolve(undefined),得加 guard,我已在生产环境补了 Promise.resolve(fn())。
2. async-pool(适合固定并发数场景)
这个库我只在「批量上传图片」这种明确要限制同时请求数(比如 max=3)时用。API 简洁得过分:
import pool from 'tiny-async-pool';
const urls = ['/img/1.jpg', '/img/2.jpg', '/img/3.jpg', '/img/4.jpg'];
const results = await pool(3, urls, async url => {
const res = await fetch(https://jztheme.com${url});
return res.arrayBuffer();
});
优点:开箱即用,max 参数直接拍死并发数,错误不会滚雪球(单个失败不影响其他)。
缺点:**不能动态调整并发数**,也不能插队、暂停、取消单个任务。我们有个需求是「用户点暂停后,等当前正在跑的 2 个传完就停」,async-pool 没法做,最后还是切回手写队列。
另外注意:它默认是「fire and forget」风格,失败了你得靠 try/catch 在每个 fn 里包,不然整个 pool() 就 reject 掉——这点文档写得不清,我第一次用被坑过,报错堆栈还指向 pool 内部,debug 半小时才发现是某张图 404 了没 catch。
3. useRequest + 轮子方案(React 场景下妥协之选)
如果你项目里已经用了 ahooks 或 swr,别硬套上面两个。我试过在 antd 表格里配 useRequest 的 throttleInterval,结果发现它只防抖,不控并发——用户狂点导出,照样发一堆请求,只是时间上错开了……
后来改用 loading + 状态锁:
const { run, loading } = useRequest(
() => fetch('https://jztheme.com/api/export').then(r => r.json()),
{ manual: true }
);
// 按钮加锁
<button onClick={() => !loading && run()} disabled={loading}>
{loading ? '导出中...' : '导出'}
</button>
这招简单粗暴,适合 MVP 阶段或内部工具。但它解决的是「不让用户重复点」,不是「控制并发」——万一接口本身被其他地方调了呢?所以我在 useEffect 里又加了一层全局锁变量,算是混合打法。
结论:够用,但不够干净。如果你的业务对「请求幂等性」「任务可见性」有要求,别用这个糊弄。
性能对比:差距比我想象的大
实测 100 个任务(每个模拟 200ms 延迟),三种方案耗时如下:
- 手写队列(串行):≈20s(100×200ms)
- async-pool(max=5):≈4.2s(20 个批次 × 200ms)
- useRequest 锁按钮:≈20s(因为还是串行触发,只是 UI 锁了)
但!真实世界里,慢一点真不重要。我宁可多等 15 秒,也要知道第 47 个任务在哪挂了、能不能重试、有没有进度条。async-pool 的 4.2s 看着漂亮,但上次线上出问题,排查日志时根本分不清「是第 3 批里的第 2 个失败了,还是第 4 批第一个」——因为 pool 把它们全 flatten 成数组了。
我的选型逻辑
看场景,我一般选这三个:
- 后台管理类操作(导出、审核、同步) → 手写队列。理由:需要精确控制、可观察、可干预。哪怕多写 20 行,也比半夜被告警 call 起来强。
- 批量上传/下载(数量大、失败可接受) → async-pool。理由:省事,且天然支持「失败跳过」,加个
filter(Boolean)就能拿到成功列表。 - 轻量级表单提交、搜索建议 → useRequest + loading 锁。理由:90% 场景够用,团队新人也能看懂,上线快。
至于 throttle/debounce,我基本不用在并发控制上。它们是「节流输入」,不是「控请求流」。很多人混淆这点,导致点了十次按钮,最后发了十次请求,只是时间错开了……
踩坑提醒:这三点一定注意
1. 不要信任 Promise.all 的错误处理:它一失败就全崩,不适合并发控制场景。要用 Promise.allSettled 或自己包装 reject。
2. 取消机制一定要暴露出去:我见过太多「cancel() 方法写在注释里」的轮子。手写队列我一定加 abortController 支持,哪怕暂时不用。
3. 错误堆栈别被 Promise 吃掉:async-pool 里 catch 到的 error,error.stack 是空的。解决方案:在每个 fn 里手动 console.error(e),或者用 try/catch 包一层再 throw 新 error。
以上是我踩坑后的总结,希望对你有帮助。这个技巧的拓展用法还有很多(比如加优先级、带权重的调度),后续会继续分享这类博客。有更优的实现方式欢迎评论区交流。

暂无评论