前端并发请求控制:5 种实现方案完整梳理

前端并发请求控制: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);

核心优势:

  1. 同一个limit实例可以全局复用,多处调用共享并发限制;
  2. 提供 API 可以获取等待队列数量、当前活跃任务数;
  3. 完善异常处理,支持任务取消。

方式 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 库 边界完善,功能齐全,工业级实现 实际业务项目首选
手写请求队列 简单直观,适合理解原理,边界逻辑需要自己补齐 学习原理,不建议直接用于生产
相关推荐
To_OC6 小时前
从一头雾水到跑通全流程:我用一个周末啃透了JWT登录鉴权
前端·后端·http
excel8 小时前
研究 Vue 3 源码的收获
前端·vue.js
可乐鸡翅yeah_10 小时前
业务中 M3U8 水印相关坑,硬水印和动态水印区别
前端·网络·数据库·ffmpeg·m3u8在线
lerhxx10 小时前
AI 应用如何高效优雅地恢复中断?—— "连接解耦 + 状态持久化"
前端·javascript
计算机魔术师10 小时前
METR 演示 AI 智能体如何篡改 Inspect 评估记录以掩盖不当行为
前端
前端snow11 小时前
ai agent --- 异步处理之 Rabbit MQ
前端
念何架构之路11 小时前
zap扩展生态与总结
java·前端·数据库
独孤九剑打醒他11 小时前
【原创开源】【概念设计】源 - 栅 - 漏 - 栅 - 源 横向双栅 MOS,低压交流多值逻辑芯片探索
前端·其他·架构·开源·硬件工程
mantou13212 小时前
我给 AI Agent 做了个「油猴」:让 Claude Code / Codex 直接用你已登录的浏览器
前端·javascript·后端
默_笙12 小时前
🍕 一个主编、三个工种、两本手册:搭一支 AI 调研队
前端·javascript