JavaScript 前端并发请求控制方案总结
在前端开发中(如批量文件上传、大批量接口请求),为了防止浏览器卡顿或服务器压力过大,通常需要限制同一时刻的最大并发数(例如限制最多 10 个请求同时进行)。
本文总结了两种主流的实现方案:递归队列法 和 Promise.race 竞速法。
方案一:递归队列法 (Recursive Worker)
核心思维 :"收银台模式"。 假设有 10 个收银台(Worker),顾客(Tasks)排成一长队。收银台不关闭,处理完一个顾客后,立刻叫号处理下一位,直到队伍排空。
1. 实现原理
- 初始化 :根据最大并发数
max,一次性启动max个异步函数(Worker)。 - 状态维护 :维护一个全局索引
index,指向任务列表中下一个待处理的任务。 - 自动流转:Worker 完成当前任务后,通过递归调用自己,去领取并执行下一个任务。
- 结束条件 :当
index超出任务总数时,Worker 停止递归。
2. 代码示例
javascript
/**
* @param {Function[]} tasks 任务数组 (返回 Promise 的函数)
* @param {number} max 最大并发数
*/
function concurrentRun(tasks, max = 10) {
const results = [];
let index = 0; // 全局指针
// 递归执行器
async function worker() {
// 递归出口:任务取完了
if (index >= tasks.length) return;
// 1. 占位:先保存当前索引,然后指针后移
const i = index;
index++;
try {
// 2. 执行:运行任务并保存结果
// console.log(`开始任务 ${i}`);
const res = await tasks[i]();
results[i] = res; // 按索引保存,保证结果顺序
} catch (err) {
results[i] = err; // 捕获错误,防止中断
} finally {
// 3. 接力:无论成功失败,立马递归领取下一个
await worker();
}
}
// 4. 启动:同时开启 max 个并发线程
const workers = [];
const runCount = Math.min(tasks.length, max); // 防止任务数少于并发数
for (let i = 0; i < runCount; i++) {
workers.push(worker());
}
// 5. 等待:所有 worker 都收工了,整体才算完成
return Promise.all(workers).then(() => results);
}
3. 优缺点
- 优点:逻辑清晰,稳定性高,天然保证结果顺序(Result 数组按索引存储)。
- 缺点:需要定义辅助函数,代码量稍多。
- 推荐指数:⭐⭐⭐⭐⭐ (工程落地与面试首选)
方案二:Promise.race 竞速法 (Dynamic Pool)
核心思维:"停车场模式"。
停车场只有 10 个车位。车一辆接一辆来,只要有空位就进。如果满了,门口的栏杆就放下,直到有一辆车出来(Promise.race),才放下一辆车进去。
1. 实现原理
- 遍历:使用循环遍历所有任务。
- 入列 :将任务包装后推入一个"正在执行数组" (
executing)。 - 包装 :每个任务完成后,必须执行
splice操作将自己从executing中移除。 - 阻塞 :判断
executing.length >= max。如果满了,使用await Promise.race(executing)阻塞主线程,等待最快的一个任务完成腾出坑位。
2. 代码示例
JavaScript
javascript
/**
* @param {Function[]} tasks 任务数组
* @param {number} max 最大并发数
*/
async function limitRequest(tasks, max = 10) {
const results = [];
const executing = []; // 正在执行的任务队列
// 使用 entries() 拿到索引,为了保证结果顺序
for (const [index, task] of tasks.entries()) {
// 1. 创建任务:执行并存储结果
const p = task().then(res => results[index] = res);
// 2. 包装任务:任务完成后,从 executing 队列中移除自己
// 关键:e 必须引用 p.then 的返回值,确保 race 等待的是"删除操作"完成
const e = p.then(() => executing.splice(executing.indexOf(e), 1));
// 3. 入列
executing.push(e);
// 4. 竞速:如果队列满了,等待最快的一个执行完
if (executing.length >= max) {
await Promise.race(executing);
}
}
// 5. 收尾:等待剩余的任务完成
await Promise.all(executing);
return results;
}
3. 优缺点
- 优点 :利用
async/await线性逻辑,代码看起来较精简。 - 缺点:涉及微任务时序问题(必须正确包装 Promise),逻辑稍显绕弯,容易写出 Bug。
- 推荐指数:⭐⭐⭐⭐
总结对比
| 维度 | 递归队列法 (方案一) | Promise.race 竞速法 (方案二) |
|---|---|---|
| 并发维持机制 | 总量守恒:走一个,递归补一个 | 阻塞等待 :满了就 await race 暂停循环 |
| 代码结构 | 闭包 + 递归函数 | For 循环 + 动态数组 |
| 执行视角 | 开启 N 个永久的"线程" | 动态维护一个"线程池" |
| 稳定性 | 高,容错率好 | 中,需注意 splice 的时序 |
| 适用场景 | 通用业务、面试手写 | 个人项目、脚本工具 |
附:测试用例代码
可以将上述任意一种方案配合以下代码进行测试:
JavaScript
javascript
// 模拟请求:返回一个 Promise,耗时 100~1000ms
const mockRequest = (id) => {
return () => new Promise((resolve) => {
const time = Math.random() * 1000 + 100;
console.log(`🚀 任务 ${id} 开始`);
setTimeout(() => {
console.log(`✅ 任务 ${id} 完成 (耗时 ${Math.floor(time)}ms)`);
resolve(`结果 ${id}`);
}, time);
});
};
// 生成 20 个任务
const tasks = Array.from({ length: 20 }, (_, i) => mockRequest(i));
// 执行测试 (并发数限制为 3)
console.time('总耗时');
concurrentRun(tasks, 3).then(res => {
console.log('--- 所有任务结束 ---');
console.log(res);
console.timeEnd('总耗时');
});