博客 / 詳情

返回

一道面試題讓你更加了解事件隊列

今天在羣裏聊天,突然有人放出了一道面試題。經過羣裏一番討論,最終解題思路慢慢完善起來,我這裏就整理一下羣內解題的思路。

該題定義了一個同步函數對傳入的數組進行遍歷乘二操作,同時每執行一次就會給 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 的操作。

後記

最終分析下來,其實這道理的本質就是要通過某些方法將 fn() 函數的執行後置到主線程執行完畢,至於是使用宏任務還是微任務隊列,就看具體的需求了。除了 setTimeout() 之外,還有 setInterval(), requestAnimationFrame() 都是宏任務隊列。而微任務隊列裏除了有 Promise 之外,還有 MutationObserver。關於宏任務和微任務隊列相關的,感興趣的可以看看《微任務、宏任務與Event-Loop》這篇文章。

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.