为什么要控制并发?
作为一名前端开发者,我们经常会遇到这样的场景:一个页面初始化时,需要同时发起十几个 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');
预期的执行流程是这样的:
- 初始状态下,任务 A 和 B 率先执行(占满 2 个并发名额)。
- 约 4秒后,任务 B 完成,名额空出一个,任务 C 紧接着开始执行。
- 任务 D、E、F 依次排队,只要前面的任务结束,后面的就会立刻补上。
- 即使中间有任务失败(如 A 和 C 超过了 5000ms),
.finally()依然会确保计数器减一,整个调度系统不会因此卡死。
总结
通过这个简单的 Limit 类,我们不仅解决了异步资源的竞争问题,还深入理解了 Promise 的状态传递以及事件循环中微任务的调度机制。
这种“生产者-消费者”模式的变种在前端工程中应用极广,比如 Webpack 的多线程打包、图片懒加载的预加载池等。希望这篇分享能帮你更好地理解 JS 的异步编程世界!