前端并发请求控制: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逻辑,可以兼容fetchaxios或者其他任意异步任务。

队列中存储的不是 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 库 边界完善,功能齐全,工业级实现 实际业务项目首选
手写请求队列 简单直观,适合理解原理,边界逻辑需要自己补齐 学习原理,不建议直接用于生产
相关推荐
郑州光合科技余经理24 分钟前
本地生活平台搭建:统一订单表与多后台切换怎么拆
java·开发语言·前端·系统架构·uni-app·php·ai编程
iFlyCai1 小时前
深入理解Flutter:StatefulWidget生命周期全解析(四)
前端·javascript·flutter
xm_xm_xm_12 小时前
Vue 3 项目开发常用操作详细汇总
前端·vue.js
计算机魔术师2 小时前
我国日均词元调用量突破 500 万亿,中国大模型稳居全球第一梯队
前端
码视野2 小时前
基于 Spring Boot + Vue3 的【高校化学实验室安全准入考试与危化品配伍排查系统】设计与实现(含PRD/三端高保真源码/大屏)
前端·人工智能·spring boot·后端·安全·vue3
玄魂2 小时前
VisActor 全新图可视化开源项目:VGraph
前端·数据可视化
宇智波亚索3 小时前
TypeScript 实际应用
前端·javascript·typescript
明飞19873 小时前
C表达式_表达式类型
前端
雪芽蓝域zzs3 小时前
Vue3+Vite 本地数据模拟 JSON 读取和Mock 方案完整讲解总结
前端·vue.js