前端并发请求控制:5 种实现方案完整梳理
在前端开发中,当需要批量发起大量网络请求时,如果不做并发控制,短时间同时抛出几十上百个请求,一方面会超出浏览器同源请求上限(Chrome 同源最大并发为 6),出现请求排队阻塞;另一方面也会给后端服务器带来巨大压力,甚至触发接口限流。
因此对请求做并发限流,是业务中很常见的需求。本文整理前端 5 种主流的并发控制实现方案,包含原理说明、优缺点与可直接运行的示例代码。
前置说明:
fetch只有网络异常才会进入catch,404、500 这类 HTTP 错误状态不会抛出 reject,业务使用时需要手动判断response.ok做异常处理。
方式 1:分批批量执行(chunk 切割,每批 Promise.all)
原理
把请求地址数组按照最大并发数切割成多个小分片,每一个分片内部使用Promise.all并发执行,必须等待当前分片所有请求全部完成之后,才会开启下一批请求。
javascript
/**
* @param {string[]} urls 请求地址数组
* @param {number} limit 每批并发数
*/
async function batchRequest(urls, limit) {
const result = [];
// 切分块
for (let i = 0; i < urls.length; i += limit) {
const chunk = urls.slice(i, i + limit);
// 当前块并发执行
const chunkRes = await Promise.all(
chunk.map(url => fetch(url).then(r => r.json()))
);
result.push(...chunkRes);
}
return result;
}
// 使用
const urls = [...Array(10)].map((_, i) => `https://xxx/api/${i}`);
batchRequest(urls, 3).then(res => console.log(res));
缺点:同一批次内,如果存在一个耗时很长的慢请求,其余已经完成的请求也必须等待,造成窗口空闲浪费。适合任务耗时比较均匀的场景。
方式 2:Promise.race 滑动窗口动态补位
原理
初始化固定数量的请求作为并发窗口;利用Promise.race监听窗口内任务,只要窗口中任意一个请求执行完毕,立刻补充一个新任务进入窗口 ,始终维持窗口的并发数量,不会因为慢请求造成整体等待。这也是p‑limit库的核心设计思想。
特点:只要任意一个请求完成,立刻补上新请求,窗口始终维持 limit 个并发,不会整批等待。
ini
/**
* Promise.race实现滑动窗口并发控制
* @param {string[]} urlList 请求地址数组
* @param {number} limit 最大并发数
*/
async function concurrencyRequest(urlList, limit) {
const results = [];
// 初始化并发池,启动limit个请求
const promises = urlList.slice(0, limit).map((url, index) => {
return fetch(url)
.then(r => r.json())
.then(data => ({ data, index }));
});
let cursor = limit; // 标记下一个待执行任务的下标
while (promises.length > 0) {
// 获取最先完成的任务
const { data, index } = await Promise.race(promises);
results[index] = data;
// 将已完成的promise从池中移除
const finishIndex = promises.findIndex(p => p.then);
promises.splice(finishIndex, 1);
// 还有未执行任务,则补充新请求进入并发窗口
if (cursor < urlList.length) {
const newUrl = urlList[cursor++];
const newP = fetch(newUrl)
.then(r => r.json())
.then(data => ({ data, index: cursor - 1 }));
promises.push(newP);
}
}
return results;
}
// 使用示例
const urls = Array.from({ length: 8 }, (_, i) => `/api/${i}`);
concurrencyRequest(urls, 3).then(console.log);
优点:窗口动态补位,效率高;缺点:代码相对绕,要维护下标保证结果顺序。
方式 3:信号量 Semaphore 实现(通用并发工具,不限于网络请求)
原理
信号量维护一个许可计数器。任务执行前需要先获取许可;当已执行任务达到上限时,新任务进入等待队列;任务执行结束后释放许可,唤醒等待队列里的下一个任务。
它是通用异步调度模型,不局限于网络请求,任何异步任务都可以使用;信号量实例可以复用,多处调用共享一套并发限制。很多成熟并发库底层都是基于信号量实现。
kotlin
class Semaphore {
constructor(max) {
this.max = max;
this.count = 0;
this.waitQueue = [];
}
acquire() {
return new Promise(resolve => {
if (this.count < this.max) {
this.count++;
resolve();
} else {
this.waitQueue.push(resolve);
}
});
}
release() {
this.count--;
if (this.waitQueue.length > 0) {
const next = this.waitQueue.shift();
this.count++;
next();
}
}
// 包装执行任务
async run(taskFn) {
await this.acquire();
try {
return await taskFn();
} finally {
this.release();
}
}
}
// 示例
const sem = new Semaphore(3); // 最多3并发
async function demo() {
const urls = ["/api/1", "/api/2", "/api/3", "/api/4", "/api/5"];
const tasks = urls.map(url => {
return sem.run(async () => {
const res = await fetch(url);
return res.json();
})
})
const data = await Promise.all(tasks);
console.log(data);
}
demo();
优势:通用,不仅可以限制 fetch,任何异步任务都能控并发;实例可复用。很多调度器底层就是信号量模型。
方式 4:生产项目首选成熟库 p‑limit
自己手写调度器需要处理大量边界 case(异常、队列、取消请求等),业务项目优先选择成熟第三方库 p‑limit,它底层就是信号量的实现。
安装
css
npm install p-limit
示例代码
javascript
import pLimit from 'p-limit';
// 创建限制器,最大并发3
const limit = pLimit(3);
const urls = [...Array(8)].map((_, i) => `/api/${i}`);
// 将任务丢入limit,返回Promise
const promises = urls.map(url => {
return limit(async () => {
const resp = await fetch(url);
return resp.json();
})
})
// 等待全部完成
const result = await Promise.all(promises);
console.log(result);
核心优势:
- 同一个
limit实例可以全局复用,多处调用共享并发限制; - 提供 API 可以获取等待队列数量、当前活跃任务数;
- 完善异常处理,支持任务取消。
方式 5:手写请求队列
最开始学习并发控制经常会写请求队列,优化后将业务任务逻辑外部传入,调度器只负责管控并发,不再硬编码fetch逻辑,可以兼容fetch、axios或者其他任意异步任务。
队列中存储的不是 url,而是一个个被闭包包装的任务函数;当请求完成释放槽位,就从队列取出下一个任务执行。
scss
const MAX_CONCURRENT_REQUESTS = 5;
let activeRequests = 0;
const requestQueue = [];
// taskFn: () => Promise<any> 传入异步任务函数
function runTask(taskFn) {
return new Promise((resolve, reject) => {
const task = async () => {
try {
activeRequests++;
const res = await taskFn(); // 执行外部传入的任意异步任务
resolve(res);
} catch (err) {
reject(err);
} finally {
activeRequests--;
// 有排队任务就执行下一个
if(requestQueue.length > 0){
requestQueue.shift()();
}
}
};
if (activeRequests < MAX_CONCURRENT_REQUESTS) {
task();
} else {
requestQueue.push(task);
}
})
}
// 使用示例:可以是fetch,也可以别的异步
runTask(() => fetch("/api/xxx").then(r=>r.json()))
runTask(() => fetch("/api/yyy").then(r=>r.json()))
各方案横向对比总结
| 方案 | 核心特点 | 适用场景 |
|---|---|---|
| 分批批量 Promise.all | 整批完成后才执行下一批;慢请求阻塞同批次任务 | 任务耗时比较平均,简单小批量场景 |
| Promise.race 滑动窗口 | 动态补位,窗口始终维持并发上限,资源利用率高 | 任务耗时差异较大,追求执行效率 |
| Semaphore 信号量 | 通用模型,不限于网络请求;实例可复用 | 多处需要做并发限制,不仅仅是接口请求 |
| p‑limit 库 | 边界完善,功能齐全,工业级实现 | 实际业务项目首选 |
| 手写请求队列 | 简单直观,适合理解原理,边界逻辑需要自己补齐 | 学习原理,不建议直接用于生产 |