如何實現一個零延遲的定時器?

做者:sshhtml

原由

不少人都知道,setTimeout是有最小延遲時間的,根據MDN 文檔 setTimeout:實際延時比設定值更久的緣由:最小延遲時間中所說:瀏覽器

在瀏覽器中,setTimeout()/setInterval() 的每調用一次定時器的最小間隔是 4ms,這一般是因爲函數嵌套致使(嵌套層級達到必定深度)。markdown

HTML Standard規範中也有提到更具體的:app

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

簡單來講,5 層以上的定時器嵌套會致使至少 4ms 的延遲。函數

用以下代碼作個測試:oop

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 以上。post

更詳細的緣由,能夠參考爲何 setTimeout 有最小時延 4ms ?測試

探索

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

若是想在瀏覽器中實現 0ms 延時的定時器,你能夠參考這裏所說的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 分別須要花多少時間。讀者也能夠在這裏本身跑一下測試

實驗代碼:

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 實現這篇文章中的一段僞代碼:

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. 爲何時間切片須要用宏任務,而不是微任務。
相關文章
相關標籤/搜索