这篇文章主要记录下最近面试中,出现超高频次的异步流程控制题。这类型题目,没做过的很容易掉坑里,而做不出来,基本上面试就拜拜了。
本文主要分为三道题目(都是新鲜的题目哦),前两题都是控制并发数量的题目,相对高频,第三道是控制并发执行的顺序题,相对低频但是很有实用价值。好了,直接上题。
题目1: 设计一个异步队列,并发数量为n
这一道题目可以说是超高频次,题目描述如下:
实现一个 taskpool类,其至少具有 add 方法和最大并发数 max,该 add 方法接收函数(返回值为 promise),当当前执行的任务数小于设定值 max 时,立即执行,大于 max,则等待任务空闲时执行该任务,模版代码如下:
class TaskPool {
// 在此处写下你的实现
}
const taskpool = new TaskPool(2);
for (let i = 0; i < 10; i++) {
const task = () => new Promise(resolve => {
// 这里 i 的值也是以前非常高频的闭包题哦
setTimeout(() => {
console.log(`task${i} complete`);
resolve(`task${i}`);
}, 2000);
});
schedual.add(task);
}
// 预期输出
// 2s 后
// task0 complete
// task1 complete
// 再 2s 后
// task2 complete
// task3 complete
// 再 2s 后
// task4 complete
// task5 complete
// ...
// task8 complete
// task9 complete
这个题目在我遇到的面试中,非常高频,在面试的场景下第一次遇到这个题,不免手忙脚乱,但是实际上逻辑非常简单,设置当前执行任务数 cur,并设计一个数组(模拟队列)。每次添加任务或者任务执行完,都去执行如下逻辑:
- 判断 cur 是否小于 max
- 小于 max,cur++, 第一个任务出队并执行,并在执行结束后,cur–,再次执行相同逻辑
- 大于 max,不做处理
如此便可得到本题的答案:
class TaskPool {
// 记录最大并发数量
max = 0;
// 记录当前在执行中的任务数量
cur = 0;
// 保存任务
tasks = [];
constructor(num) {
this.max = num;
}
add(task) {
// 每次添加,就是往队列尾部增加任务
this.tasks.push(task);
// 并执行 run 方法
this.run();
}
run() {
// 判断是否还有任务,以及当前执行中的任务是否小于并发数量
while (this.tasks.length && this.cur < this.max) {
// 取出队列第一个任务
const task = this.tasks.shift();
// 记录执行中任务数量 +1
this.cur++;
// 执行任务
task().finally(() => {
// 任务结束后,执行中任务数量 -1
this.cur--;
// 并执行同样的逻辑
this.run();
});
}
}
}
可以说是思路比较清晰了。但是实际在面试中,很多同学很容易把 add 和 run 的逻辑混在一起,此时,由于任务执行完之后,还需要执行同样的 run 逻辑,此时,如果没有把 add 和 run 的逻辑拆开,这里的逻辑就不好写了,这也是很多同学做不出来的根本原因,其实和大部分递归一样,有些初始化的逻辑,和需要反复执行的逻辑,需要拆分开来,这样在需要重复执行某一逻辑时,就可以非常简单的通过递归处理掉。
题目2: 设计一个任务队列,并发数量为 n,按顺序输出任务执行结果
这一题的题目描述如下:
设计一个函数 schedule,schedule 接收一个数量 n,返回一个新函数,新函数接受一个返回 promise 的函数数组,会按照顺序执行函数,并且同时执行的函数数量不超过 n。并且该函数的返回值是一个 promsie,该 promise 会在所有函数执行完后 resolve, 其值是函数数组的返回值按顺序构成的数组。看描述比较容易犯迷糊,直接看模版代码和输出示例:
function schedule(n) {
// 在此处写下你的实现
}
const runTask = schedule(4);
const tasks = new Array(10).fill(0).map((x, i) => () => new Promise(resolve => {
setTimeout(() => {
console.log(`task${i} complete`);
resolve(`task${i}`);
}, 2000);
}));
runTask(tasks).then(console.log);
// 预期输出
// 2s 后
// task0 complete
// task1 complete
// task2 complete
// task3 complete
// 再2s 后
// task4 complete
// task5 complete
// task6 complete
// task7 complete
// 再2s 后
// task8 complete
// task9 complete
// ['task0', 'task1', ..., 'task9']
这一题其实和上一题类似,但是多了几个细节上的变化,从 类,变成了普通函数,这在一定程度上会导致代码相对混乱(但是本质是一样的)。另一个是在任务执行完之后,返回按顺序输出任务执行结果,这里就需要多几个变量来保存任务执行的顺序和结果。
解答如下:
function schedule(n) {
// 此处不需要做任何操作,借助作用域可直接保存并发限制 n
return function (tasks) {
// 保存执行结果
const res = [];
// 待执行的任务队列
let queue = [];
// 记录当前执行中的任务数量
let cur = 0;
// 记录执行完成的任务数量
let finished = 0;
// 映射一下,保留任务的顺序 index,以便后期按顺序存储到 res 中
queue = tasks.map((v, index) => ({
task: v,
index,
}));
return new Promise((resolve) => {
// 类似于上一题中的 run,抽离统一的 runTask 逻辑
function runTask() {
// 判断当前执行中的任务数量和待执行的数量
while (cur < n && queue[0]) {
// 取出第一个任务
const item = queue.shift();
// 执行任务
item.task().then((result) => {
// 结束后记录结果
res[item.index] = result;
// 执行中的任务数量减少
cur--;
// 完成的任务数量增加
finished++;
// 如果全部完成
if (finished === tasks.length) {
// 返回结果
resolve(res);
} else {
// 否则继续执行
runTask(resolve);
}
});
// 增加执行中的任务数量
cur++;
}
}
runTask();
});
};
}
相比于上一题,由于需要按顺序记录结果,所以多了一些中间变量进行存储,整体由于是函数而不是类,代码看起来会略显混乱,但是只要清楚作用域,相信理解起来还是非常容易的。容易出问题的点,也还是在于 runTask 的抽离,因为会存在递归反复调用,所以如果不做抽离,代码很容易混乱导致写不出来
题目3: 设计一个异步事件队列,能够由任务本身控制后续流程
这一道题的描述如下:
设计一个类 AsyncQueue,其具备两个方法,tap 和 exec,tap 可以绑定回调(可以绑定多个),exec 执行回调。回调是一个函数,该函数接受一个入参 cb,如果在该函数中不主动调用 cb,则后续的回调不会执行。 示例代码如下:
class AsyncQueue {
constructor() {
// 你的代码
}
// 事件注册
tap(name, fn) {
// 你的代码
}
// 事件触发
exec(name, fn) {
// 你的代码
}
}
function fn1(cb) {
console.log('fn1');
cb();
}
function fn2(cb) {
console.log('fn2');
cb();
}
function fn3(cb) {
setTimeout(() => {
console.log('fn3');
cb();
}, 2000);
}
function fn4(cb) {
setTimeout(() => {
console.log('fn4');
cb();
}, 3000);
}
// 创建事件队列
const asyncQueue = new AsyncQueue();
// 注册事件队列
asyncQueue.tap('init', fn1);
asyncQueue.tap('init', fn2);
asyncQueue.tap('init', fn3);
asyncQueue.tap('init', fn4);
// 执行事件队列
asyncQueue.exec('init', () => {
console.log('执行结束');
});
// 预期输出
// fn1
// fn2
// 2s 后
// fn3
// 再 3s 后
// fn4
// 执行结束
这一题和前面两题有比较大的区别,关键点在于任务函数的入参。前面两题后续任务的执行其实是由外部控制的,而本题中,后续任务的执行以及执行的顺序,是由前一个任务本身决定的,不调用 cb,则后续任务不会触发。
在这个题目中 tap 比较好实现,而 exec 的实现,需要提前把后续任务的执行先构建好,这里相对绕一些。可以直接看解答:
class AsyncQueue {
constructor() {
// 函数实现
this.events = {};
}
// 事件注册
tap(name, fn) {
if (this.events[name]) {
this.events[name].push(fn);
} else {
this.events[name] = [fn];
}
}
// 事件触发
exec(name, fn) {
// 函数实现
if (this.events[name]) {
const dispatch = (i) => {
// 取出第 i 个任务
const event = this.events[name][i];
if (event) {
// 执行的时候,将 dispatch(i + 1) 作为入参提供给当前任务,由其决定调用时机
event(() => dispatch(i+1));
} else {
// 都执行完了,则执行回调 fn
fn();
}
}
// 手动触发第一个任务的执行
dispatch(0);
}
}
}
核心就在于 dispatch 的实现,本身会执行第 i 个任务,而在执行第 i 个任务时,将 dispatch(i+1) 作为入参传递给当前任务,由当前任务决定下一次任务的调用时机,以此来实现任务的自由控制。
看到这里,其实这一题和 koa 的中间件就非常接近了,由于下一个任务的执行时机由当前任务本身控制,那么当前任务只需要在执行下一任务前后执行个人的逻辑,就能够实现 koa 类似的洋葱模型,而如果加入 acync 等待下一任务执行完之后,就可以实现和 koa 中间件一摸一样的支持异步的洋葱模型。
感兴趣的同学可以查看 koa 的 相关源码,其实在实现上已经非常接近了。
总结
以上,就是我最近在面试中遇到的和异步控制相关的题目了,这种题目看起来确实会绕一些,前两题都用到了递归,也需要对 promise 有比较正确的理解,不过理解了之后其实思路还是很简单的,任务执行结束后,再次判断队列的状态来决定是否需要运行新的任务。
第三题相对麻烦一点,看似也是流程控制,但是控制权其实是在任务本身,需要提前构建 next 方法作为入参,才能做到这样的流程控制,稍加改造,就能够实现 koa 的中间件系统。
最后,这几道题目出现的频次由高到低为: 1 > 2 > 3,即便没有遇到这种题目,变种的概率也是非常大的,但是万变不离其宗,分析好入参,出参,其实就已经成功了一大半了。
希望能够帮助到接下来需要面试的小伙伴们。祝你们能拿一个好 offer。
最后的最后,还是希望网上能少一些散播焦虑的文章,实打实的过好自己的生活。(特指:前端已死,前端死透了,前端还没死,前端到底死没死等一系列的文章,建议转行去做营销总监,给你日薪千万呀)。