一道面试题让你更加了解事件队列

Featured Image

今天在群里聊天,突然有人放出了一道面试题。经过群里一番讨论,最终解题思路慢慢完善起来,我这里就整理一下群内解题的思路。

该题定义了一个同步函数对传入的数组进行遍历乘二操作,同时每执行一次就会给 executeCount 累加。最终我们需要实现一个 batcher 函数,使用其对该同步函数包装后,实现每次调用依旧返回预期的二倍结果,同时还需要保证 executeCount 执行次数为1。

let executeCount = 0
const fn = nums => {
  executeCount++
  return nums.map(x => x * 2)
}

const batcher = f => {
  // todo 实现 batcher 函数
}

const batchedFn = batcher(fn);

const main = async () => {
  const [r1, r2, r3] = await Promise.all([
    batchedFn([1,2,3]),
    batchedFn([4,5]),
    batchedFn([7,8,9])
  ]);

  //满足以下 test case
  assert(r1).tobe([2, 4, 6])
  assert(r2).tobe([8, 10])
  assert(r3).tobe([14, 16, 18])
  assert(executeCount).tobe(1)
}

抖机灵解法

拿到题目的第一时间,我就想到了抖机灵的方法。直接面向用例编程,执行完之后重置下 executeCount 就好了。

const batcher = f => {
  return nums => {
    try { return f(nums) } finally { executeCount = 1 }
  }
}

当然除非你不在乎这次面试,否则一般不建议你用这种抖机灵的方法回答面试官(不要问我为什么知道)。由于 executeCount 的值和 fn() 函数的调用次数呈正相关,所以这道理也就换成了我们需要实现 batcher() 方法返回新的包装函数,该函数会被调用多次,但最终只会执行一次 fn() 函数。

setTimeout 解法

由于题干中使用了 Promise.all(),我们自然而然想到使用异步去解决。也就是每次调用的时候会把所以的传参存下来,直到最后的时候再执行 fn() 返回对应的结果。问题在于什么时候触发开始执行呢?自然而然我们想到了类似 debounce 的方式使用 setTimeout 增加延迟时间。

const batcher = f => {
  let nums = [];
  const p = new Promise(resolve => setTimeout(_ => resolve(f(nums)), 100));

  return arr => {
    let start = nums.length;
    nums = nums.concat(arr);
    let end = nums.length;
    return p.then(ret => ret.slice(start, end));
  };
};

这里的难点在于预先定义了一个 Promise 在 100ms 之后才会 resolve。返回的函数本质只是将参数推入到 nums 数组中,待 100ms 后触发 resolve 返回统一执行 fn() 后的结果并获取对应于当前调用的结果片段。

后来有群友反馈,实际上不用定义 100ms 直接 0ms 也是可以的。由于 setTimeout 是在 UI 渲染结束之后才会执行的宏任务,所以理论上来说 setTimeout() 的最小间隔值无法设置为 0。它的最小值和浏览器的刷新频率有关系,根据 MDN 描述,它的最小值一般为 4ms。所以理论上它设置 0ms 和 100ms 效果是差不多的,都类似于 debounce 的效果。

Promise 解法

那么如何能实现延迟 0ms 执行呢?我们知道除了宏任务之外 JS 还有微任务,微任务队列是在 JS 主线程执行完成之后立即执行的事件队列。Promise 的回调就会存储在微任务队列中。于是我们将 setTimeout 修改成了 Promise.resolve(),最终发现也是可以实现同样的效果。

const batcher = f => {
  let nums = [];
  const p = Promise.resolve().then(_ => f(nums));

  return arr => {
    let start = nums.length;
    nums = nums.concat(arr);
    let end = nums.length;
    return p.then(ret => ret.slice(start, end));
  };
};

由于 Promise 的微任务队列效果将 _ => f(nums) 推入微任务队列,待主线程的三次 batcherFn() 调用都执行完成之后才会执行。之后 p 的状态变为 fulfilled 后继续完成最终 slice 的操作。

**2020-03-17:**感谢 @kricsleo 帮忙指出由于存在副作用多次调用会存在问题,并提供了优化版本。

const batcher = (f) => {
  let nums = [];
  let p;
  
  return (nums) => {
    if(!p) {
      p = Promise.resolve().then(_ => f(nums));
    }
    
    const start = nums.length;
    nums = nums.concat(arr);
    const end = nums.length;
    
    return p.then(ret => {
      nums = [];
      p = null;
      return ret.slice(start, end);
    });
  };
};

后记

最终分析下来,其实这道理的本质就是要通过某些方法将 fn() 函数的执行后置到主线程执行完毕,至于是使用宏任务还是微任务队列,就看具体的需求了。除了 setTimeout() 之外,还有 setInterval(), requestAnimationFrame() 都是宏任务队列。而微任务队列里除了有 Promise 之外,还有 MutationObserver。关于宏任务和微任务队列相关的,感兴趣的可以看看《微任务、宏任务与Event-Loop》这篇文章。

Avatar
怡红公子 擅长前端和 Node.js 服务端方向。热爱开源时常在 Github 上活跃,也是博客爱好者,喜欢将所学内容总结成文章分享给他人。

4 评论

热搜 Chrome 78.0.3904.108 Windows 10
2021-03-17T08:26:57.906Z 回复

文章不错支持一下

kricsleo Chrome 88.0.4324.192 Mac OS 11.2.2
2021-03-16T07:02:03.412Z 回复

解法不严谨


const [r1, r2, r3] = await Promise.all([
  batchedFn([1, 2, 3]),
  batchedFn([4, 5]),
  batchedFn([7, 8, 9])
]);
const [r4, r5, r6] = await Promise.all([
  batchedFn([10, 2, 3]),
  batchedFn([40, 5]),
  batchedFn([70, 8, 9])
]);

如果main函数里面再加上一次类似的调用你就会看到结果第二次的结果不正确了

可以做如下改造


const batcher = (f) => {
  let gatheredNums = [];
  let p;
  return (nums) => {
    !p && p = Promise.resolve().then(() => f(gatheredNums));
    const start = gatheredNums.length;
    gatheredNums.push(…nums);
    const end = gatheredNums.length;
    return p.then(v => {
      gatheredNums = [];
      p = null;
      return v.slice(start, end);
    });
  };
};

一个队列的调用结束后要清除副作用, 这样可以保证下一个队列正常

怡红公子 Chrome 89.0.4389.82 Mac OS 10.15.7
2021-03-17T01:47:57.859Z 回复

@kricsleo , 嗯,是的,你说的对,我修改一下文章最后的答案。

站元素主机 Firefox 86.0 Windows 10
2021-03-16T00:44:40.132Z 回复

感谢分享,涨知识了