拒绝拥堵!手写一个 JS 并发控制器,让你的异步请求井然有序

2 阅读3分钟

为什么要控制并发?

作为一名前端开发者,我们经常会遇到这样的场景:一个页面初始化时,需要同时发起十几个 API 请求。如果放任这些请求像脱缰的野马一样全部冲出去,不仅会瞬间占满浏览器的并发连接数,导致后续资源加载阻塞,还可能因为瞬间流量过大把后端服务器打挂。

这时候,我们就需要一个 “交通指挥官” ——并发控制器。今天,我们就来利用 Promise 和队列机制,手写一个轻量级的并发控制器。

️ 核心思路:餐厅服务员模型

在动手写代码之前,我们先建立一个直观的思维模型。你可以把这个并发控制器想象成一家只有几张餐桌的小餐厅

  • 最大并发数 :餐厅里的餐桌数量。桌子坐满了,后来的客人就得排队。
  • 任务队列 :门口等待叫号的顾客列表。
  • 正在运行的任务 :当前正在用餐的客人数量。

我们的逻辑很简单:只要有空桌子,且门口还有人在排队,就叫号让下一位顾客进去吃饭;等顾客吃完离开(任务完成),立刻清理桌子(更新计数),并再次检查是否要叫下一位。

代码实现:打造你的并发控制器

首先,我们准备一个模拟的异步任务函数 ajax,它接收一个时间参数,模拟网络请求的耗时:

function ajax(time) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      // 模拟请求超时或失败的情况
      if (time > 5000) {
        reject(new Error('请求超时'));
      } else {
        resolve(`成功:耗时 ${time}ms`);
      }
    }, time);
  });
}

接下来是重头戏——Limit 类的实现。

class Limit {
  constructor(maxConcurrency = 2) {
    this.taskQueue = [];          // 存放待执行任务的队列
    this.runningCount = 0;        // 当前正在运行中的任务数
    this.maxConcurrency = maxConcurrency; // 最大允许的并发数(餐桌数)
  }

  // 添加任务的方法
  add(task) {
    return new Promise((resolve, reject) => {
      // 将任务及其对应的 resolve/reject 存入队列,以便后续执行结果回调
      this.taskQueue.push({ task, resolve, reject });
      // 每次添加新任务后,都尝试去执行队列里的任务
      this._run();
    });
  }

  // 核心调度逻辑:检查并执行任务
  _run() {
    // 只要当前运行数小于最大并发数,且队列里还有任务,就继续执行
    while (this.runningCount < this.maxConcurrency && this.taskQueue.length) {
      const { task, resolve, reject } = this.taskQueue.shift(); // 取出队首任务
      
      this.runningCount++; // 占用一个“坑位”

      // 执行任务,并在完成后释放“坑位”
      task()
        .then(resolve)
        .catch(reject)
        .finally(() => {
          this.runningCount--; // 任务结束,释放资源
          this._run();         // 递归调用,看看是否还能继续执行下一个任务
        });
    }
  }
}

实战演练:见证秩序的力量

有了控制器,我们就可以优雅地管理那堆杂乱的异步任务了。我们来实例化一个最大并发数为 2 的控制器,并添加 6 个不同耗时的任务:

const limit = new Limit(2); // 限制最多同时执行 2 个任务

// 封装一个添加任务的辅助函数,方便打印日志
function addTask(time, name) {
  limit
    .add(() => ajax(time))
    .then((res) => {
      console.log(` 任务 ${name} 完成 -> ${res}`);
    })
    .catch((err) => {
      console.log(` 任务 ${name} 失败 -> ${err.message}`);
    });
}

// 提交一堆任务
addTask(10000, 'A'); // 耗时最长,且会触发 reject
addTask(4000, 'B');
addTask(8000, 'C');  // 也会触发 reject
addTask(1000, 'D');
addTask(3000, 'E');
addTask(2000, 'F');

image.png 预期的执行流程是这样的:

  1. 初始状态下,任务 A 和 B 率先执行(占满 2 个并发名额)。
  2. 约 4秒后,任务 B 完成,名额空出一个,任务 C 紧接着开始执行。
  3. 任务 D、E、F 依次排队,只要前面的任务结束,后面的就会立刻补上。
  4. 即使中间有任务失败(如 A 和 C 超过了 5000ms),.finally() 依然会确保计数器减一,整个调度系统不会因此卡死。

总结

通过这个简单的 Limit 类,我们不仅解决了异步资源的竞争问题,还深入理解了 Promise 的状态传递以及事件循环中微任务的调度机制。

这种“生产者-消费者”模式的变种在前端工程中应用极广,比如 Webpack 的多线程打包、图片懒加载的预加载池等。希望这篇分享能帮你更好地理解 JS 的异步编程世界!