如何實現比 setTimeout 快 80 倍的定時器?

起因

很多人都知道,setTimeout 是有最小延遲時間的,根據 MDN 文檔 setTimeout:實際延時比設定值更久的原因:最小延遲時間 [1] 中所說:

在瀏覽器中,setTimeout()/setInterval() 的每調用一次定時器的最小間隔是 4ms,這通常是由於函數嵌套導致(嵌套層級達到一定深度)。

在 HTML Standard[2] 規範中也有提到更具體的:

Timers can be nested; after five such nested timers, however, the interval is forced to be at least four milliseconds.

簡單來說,5 層以上的定時器嵌套會導致至少 4ms 的延遲。

用如下代碼做個測試:

let a = performance.now();
setTimeout(() ={
  let b = performance.now();
  console.log(b - a);
  setTimeout(() ={
    let c = performance.now();
    console.log(c - b);
    setTimeout(() ={
      let d = performance.now();
      console.log(d - c);
      setTimeout(() ={
        let e = performance.now();
        console.log(e - d);
        setTimeout(() ={
          let f = performance.now();
          console.log(f - e);
          setTimeout(() ={
            let g = performance.now();
            console.log(g - f);
          }, 0);
        }, 0);
      }, 0);
    }, 0);
  }, 0);
}, 0);

在瀏覽器中的打印結果大概是這樣的,和規範一致,第五次執行的時候延遲來到了 4ms 以上。

更詳細的原因,可以參考 爲什麼 setTimeout 有最小時延 4ms ?

探索

假設我們就需要一個「立刻執行」的定時器呢?有什麼辦法繞過這個 4ms 的延遲嗎,上面那篇 MDN 文檔的角落裏有一些線索:

如果想在瀏覽器中實現 0ms 延時的定時器,你可以參考這裏 [3] 所說的 window.postMessage()

這篇文章裏的作者給出了這樣一段代碼,用 postMessage 來實現真正 0 延遲的定時器:

(function () {
  var timeouts = [];
  var messageName = 'zero-timeout-message';

  // 保持 setTimeout 的形態,只接受單個函數的參數,延遲始終爲 0。
  function setZeroTimeout(fn) {
    timeouts.push(fn);
    window.postMessage(messageName, '*');
  }

  function handleMessage(event) {
    if (event.source == window && event.data == messageName) {
      event.stopPropagation();
      if (timeouts.length > 0) {
        var fn = timeouts.shift();
        fn();
      }
    }
  }

  window.addEventListener('message', handleMessage, true);

  // 把 API 添加到 window 對象上
  window.setZeroTimeout = setZeroTimeout;
})();

由於 postMessage 的回調函數的執行時機和 setTimeout 類似,都屬於宏任務,所以可以簡單利用 postMessageaddEventListener('message') 的消息通知組合,來實現模擬定時器的功能。

這樣,執行時機類似,但是延遲更小的定時器就完成了。

再利用上面的嵌套定時器的例子來跑一下測試:

全部在 0.1 ~ 0.3 毫秒級別,而且不會隨着嵌套層數的增多而增加延遲。

測試

從理論上來說,由於 postMessage 的實現沒有被瀏覽器引擎限制速度,一定是比 setTimeout 要快的。但空口無憑,咱們用數據說話。

作者設計了一個實驗方法,就是分別用 postMessage 版定時器和傳統定時器做一個遞歸執行計數函數的操作,看看同樣計數到 100 分別需要花多少時間。讀者也可以在這裏自己跑一下測試 [4]。

實驗代碼:

function runtest() {
  var output = document.getElementById('output');
  var outputText = document.createTextNode('');
  output.appendChild(outputText);
  function printOutput(line) {
    outputText.data += line + '\n';
  }

  var i = 0;
  var startTime = Date.now();
  // 通過遞歸 setZeroTimeout 達到 100 計數
  // 達到 100 後切換成 setTimeout 來實驗
  function test1() {
    if (++i == 100) {
      var endTime = Date.now();
      printOutput(
        '100 iterations of setZeroTimeout took ' +
          (endTime - startTime) +
          ' milliseconds.'
      );
      i = 0;
      startTime = Date.now();
      setTimeout(test2, 0);
    } else {
      setZeroTimeout(test1);
    }
  }

  setZeroTimeout(test1);

  // 通過遞歸 setTimeout 達到 100 計數
  function test2() {
    if (++i == 100) {
      var endTime = Date.now();
      printOutput(
        '100 iterations of setTimeout(0) took ' +
          (endTime - startTime) +
          ' milliseconds.'
      );
    } else {
      setTimeout(test2, 0);
    }
  }
}

實驗代碼很簡單,先通過 setZeroTimeout 也就是 postMessage 版本來遞歸計數到 100,然後切換成 setTimeout 計數到 100。

直接放結論,這個差距不固定,在我的 mac 上用無痕模式排除插件等因素的干擾後,以計數到 100 爲例,大概有 80 ~ 100 倍的時間差距。在我硬件更好的臺式機上,甚至能到 200 倍以上。

Performance 面板

只是看冷冰冰的數字還不夠過癮,我們打開 Performance 面板,看看更直觀的可視化界面中,postMessage 版的定時器和 setTimeout 版的定時器是如何分佈的。

這張分佈圖非常直觀的體現出了我們上面所說的所有現象,左邊的 postMessage 版本的定時器分佈非常密集,大概在 5ms 以內就執行完了所有的計數任務。

而右邊的 setTimeout 版本相比較下分佈的就很稀疏了,而且通過上方的時間軸可以看出,前四次的執行間隔大概在 1ms 左右,到了第五次就拉開到 4ms 以上。

作用

也許有同學會問,有什麼場景需要無延遲的定時器?其實在 React 的源碼中,做時間切片的部分就用到了。

借用 React Scheduler 爲什麼使用 MessageChannel 實現 [5] 這篇文章中的一段僞代碼:

const channel = new MessageChannel();
const port = channel.port2;

// 每次 port.postMessage() 調用就會添加一個宏任務
// 該宏任務爲調用 scheduler.scheduleTask 方法
channel.port1.onmessage = scheduler.scheduleTask;

const scheduler = {
  scheduleTask() {
    // 挑選一個任務並執行
    const task = pickTask();
    const continuousTask = task();

    // 如果當前任務未完成,則在下個宏任務繼續執行
    if (continuousTask) {
      port.postMessage(null);
    }
  },
};

React 把任務切分成很多片段,這樣就可以通過把任務交給 postMessage 的回調函數,來讓瀏覽器主線程拿回控制權,進行一些更優先的渲染任務(比如用戶輸入)。

爲什麼不用執行時機更靠前的微任務呢?參考我的這篇對 EventLoop 規範的解讀 深入解析 EventLoop 和瀏覽器渲染、幀動畫、空閒回調的關係,關鍵的原因在於微任務會在渲染之前執行,這樣就算瀏覽器有緊急的渲染任務,也得等微任務執行完才能渲染。

總結

通過本文,你大概可以瞭解如下幾個知識點:

  1. setTimeout 的 4ms 延遲歷史原因,具體表現。

  2. 如何通過 postMessage 實現一個真正 0 延遲的定時器。

  3. postMessage 定時器在 React 時間切片中的運用。

  4. 爲什麼時間切片需要用宏任務,而不是微任務。

參考資料

[1]

MDN 文檔 setTimeout:實際延時比設定值更久的原因:最小延遲時間: https://developer.mozilla.org/zh-CN/docs/Web/API/WindowOrWorkerGlobalScope/setTimeout#%E5%AE%9E%E9%99%85%E5%BB%B6%E6%97%B6%E6%AF%94%E8%AE%BE%E5%AE%9A%E5%80%BC%E6%9B%B4%E4%B9%85%E7%9A%84%E5%8E%9F%E5%9B%A0%EF%BC%9A%E6%9C%80%E5%B0%8F%E5%BB%B6%E8%BF%9F%E6%97%B6%E9%97%B4

[2]

HTML Standard: https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#timers

[3]

這裏: https://dbaron.org/log/20100309-faster-timeouts

[4]

這裏自己跑一下測試: https://dbaron.org/mozilla/zero-timeout

[5]

React Scheduler 爲什麼使用 MessageChannel 實現: https://juejin.cn/post/6953804914715803678

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/NqzWkeOhqAU85XPkJu_wCA