这篇文章主要记录下最近面试中,出现超高频次的异步流程控制题。这类型题目,没做过的很容易掉坑里,而做不出来,基本上面试就拜拜了。

本文主要分为三道题目(都是新鲜的题目哦),前两题都是控制并发数量的题目,相对高频,第三道是控制并发执行的顺序题,相对低频但是很有实用价值。好了,直接上题。

题目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,并设计一个数组(模拟队列)。每次添加任务或者任务执行完,都去执行如下逻辑:

  1. 判断 cur 是否小于 max
  2. 小于 max,cur++, 第一个任务出队并执行,并在执行结束后,cur–,再次执行相同逻辑
  3. 大于 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。

最后的最后,还是希望网上能少一些散播焦虑的文章,实打实的过好自己的生活。(特指:前端已死,前端死透了,前端还没死,前端到底死没死等一系列的文章,建议转行去做营销总监,给你日薪千万呀)。