你不知道的 requestIdleCallback

本文副標題是 Request Schedule 源碼解析一。在本章中會介紹 requestIdleCallback 的用法以及其缺陷, 接着對 React 團隊對該 api 的 hack 部分的源碼進行剖析。在下一篇中會結合優先級對 React 的調度算法進行宏觀的解釋, 歡迎關注我的博客react

React 調度算法requestIdleCallback 這個 api 息息相關。requestIdleCallback 的做用是是在瀏覽器一幀的剩餘空閒時間內執行優先度相對較低的任務, 其用法以下:git

var tasksNum = 10000

requestIdleCallback(unImportWork)

function unImportWork(deadline) {
  while (deadline.timeRemaining() && tasksNum > 0) {
    console.log(`執行了${10000 - tasksNum + 1}個任務`)
    tasksNum--
  }

  if (tasksNum > 0) { // 在將來的幀中繼續執行
    requestIdleCallback(unImportWork)
  }
}
複製代碼

deadline 有兩個參數github

  • timeRemaining(): 當前幀還剩下多少時間
  • didTimeout: 是否超時

另外 requestIdleCallback 後若是跟上第二個參數 {timeout: ...} 則會強制瀏覽器在當前幀執行完後執行。算法

requestIdleCallback 的缺陷

requestIdleCallback is called only 20 times per second - Chrome on my 6x2 core Linux machine, it's not really useful for UI work。—— from Releasing Suspensechrome

也就是說 requestIdleCallback 的 FPS 只有 20, 這遠遠低於頁面流暢度的要求!(通常 FPS 爲 60 時對用戶來講是感受流程的, 即一幀時間爲 16.7 ms), 這也是 React 須要本身實現 requestIdleCallback 的緣由。api

源碼解析之 requestIdleCallback

非 DOM 環境

在不能操做 DOM 的環境下, 能夠藉助 setTimeout 來模擬 requestIdleCallback 的實現。瀏覽器

requestIdleCallback = (callback) => {
  setTimeout(callback({
    timeRemaining() {
      return Infinity
    }
  }))
}
複製代碼

下面將 React 源碼中關於服務端的實現也呈現出來:bash

let _callback = null;
const _flushCallback = function (didTimeout) {
  if (_callback !== null) {
    try {
      _callback(didTimeout);
    } finally {
      _callback = null;
    }
  }
};
requestHostCallback = function (cb) {
  if (_callback !== null) {
    // 若是 _callback 不爲空, 則將 requestHostCallback 放到下一個事件隊列中再次執行
    setTimeout(requestHostCallback, 0, cb);
  } else {
    _callback = cb;
    setTimeout(_flushCallback, 0, false);
  }
};
cancelHostCallback = function () {
  _callback = null;
};
shouldYieldToHost = function () {
  return false;
};
複製代碼

DOM 環境

在瀏覽器端的環境下, 介紹一個與 requestIdleCallback 功能相近的 api —— requestAnimationFrame(callback), 其會在下次重繪前執行指定的回調函數,所以這個 api 在動效領域獲得了普遍的使用。下面經過一個簡單的 demo 來認識它:異步

let frame
let n = 5
function callback(timeStamp) {
  console.log(timeStamp) // 開始執行回調的時間戳
  // 若是想要產生循環動畫的效果, 需在回調函數中再次調用 requestAnimationFrame()
  while (n > 0) {
    requestAnimationFrame(callback)
    console.log('測試執行順序')
    n--
  }
}

frame = requestAnimationFrame(callback) // 在下次重繪以前調用回調

// 若是想要銷燬該回調, 能夠執行 cancelAnimationFrame(frame)
複製代碼

執行上述代碼, 控制檯(chrome)打印以下數據:函數

先輸出 5 次 '測試執行順序'
1795953.649
1795970.318
1795986.987
1796003.656
1796020.325
...
複製代碼

能夠看到在瀏覽器上一幀的時間大體爲 16ms。同時能夠看到 requestAnimation(callback) 中的 callback 也是異步的(只不過它是基於幀與幀間的異步), 因此上述打印結果是先打印出 5 次 '測試執行順序' 後再依次打印出 5 個時間戳。

requestHostCallback(也就是 requestIdleCallback) 這部分源碼的實現比較複雜, 能夠將其分解爲如下幾個重要的步驟(有一些細節點能夠看註釋):

  1. 步驟一: 若是有優先級更高的任務, 則經過 postMessage 觸發步驟四, 不然若是 requestAnimationFrame 在當前幀沒有安排任務, 則開始一個幀的流程;
  2. 步驟二: 在一個幀的流程中調用 requestAnimationFrameWithTimeout 函數, 該函數調用了 requestAnimationFrame, 並對執行時間超過 100ms 的任務用 setTimeout 放到下一個事件隊列中處理;
  3. 步驟三: 執行 requestAnimationFrame 中的回調函數 animationTick, 在該回調函數中獲得當前幀的截止時間 frameDeadline, 並經過 postMessage 觸發步驟四;
  4. 步驟四: 經過 onmessage 接受 postMessage 指令, 觸發消息事件的執行。在 onmessage 函數中根據 frameDeadline - currentTime <= 0 判斷任務是否能夠在當前幀執行,若是能夠的話執行該任務, 不然進入下一幀的調用。
export let requestHostCallback;
export let cancelHostCallback;
export let shouldYieldToHost;
export let getCurrentTime;

const ANIMATION_FRAME_TIMEOUT = 100;
let rAFID;
let rAFTimeoutID;
// ② 調用 requestAnimationFrame, 並對執行時間超過 100 ms 的任務用 setTimeout 進行處理
const requestAnimationFrameWithTimeout = function (callback) {
  rAFID = requestAnimationFrame(function (timestamp) {
    clearTimeout(rAFTimeoutID);
    callback(timestamp); // 一幀中任務調用的核心流程的實現, 接着看第 ③ 步
  });
  // 若是在一幀中某個任務執行時間超過 100 ms 則終止該幀的執行並將該任務放入下一個事件隊列中
  rAFTimeoutID = setTimeout(function () {
    cancelAnimationFrame(rAFID);
    callback(getCurrentTime());
  }, ANIMATION_FRAME_TIMEOUT);
};

getCurrentTime = function () {
  return performance.now();
};

let scheduledHostCallback = null; // 調度器回調函數
let isMessageEventScheduled = false; // 消息事件是否執行
let timeoutTime = -1;

let isAnimationFrameScheduled = false;

let isFlushingHostCallback = false;

let frameDeadline = 0; // 當前幀的截止時間

// 假設最開始的 FPS(feet per seconds) 爲 30, 但這個值會隨着動畫幀調用的頻率而動態變化
let previousFrameTime = 33; // 一幀的時間: 1000 / 30 ≈ 33
let activeFrameTime = 33;

shouldYieldToHost = function () {
  return frameDeadline <= getCurrentTime();
};

const channel = new MessageChannel();
const port = channel.port2;
// ④ 接受 `postMessage` 指令, 觸發消息事件的執行。在其中判斷任務是否在當前幀執行,若是在的話執行該任務
channel.port1.onmessage = function (event) {
  isMessageEventScheduled = false;

  const prevScheduledCallback = scheduledHostCallback;
  const prevTimeoutTime = timeoutTime;
  scheduledHostCallback = null;
  timeoutTime = -1;

  const currentTime = getCurrentTime();

  let didTimeout = false; // 是否超時
  // 若是當前幀已經沒有時間剩餘, 檢查是否有 timeout 參數,若是有的話是否已經超過這個時間
  if (frameDeadline - currentTime <= 0) {
    if (prevTimeoutTime !== -1 && prevTimeoutTime <= currentTime) {
      // didTimeout 爲 true 後, 在當前幀中執行(針對優先級較高的任務)
      didTimeout = true;
    } else {
      // 在下一幀中執行
      if (!isAnimationFrameScheduled) {
        isAnimationFrameScheduled = true;
        requestAnimationFrameWithTimeout(animationTick);
      }
      scheduledHostCallback = prevScheduledCallback;
      timeoutTime = prevTimeoutTime;
      return;
    }
  }

  if (prevScheduledCallback !== null) {
    isFlushingHostCallback = true;
    try {
      prevScheduledCallback(didTimeout);
    } finally {
      isFlushingHostCallback = false;
    }
  }
};

// ③ requestAnimationFrame 的回調函數。傳入的 rafTime 爲執行該幀的時間戳。
const animationTick = function (rafTime) {
  // 若是存在調度器回調函數則在一幀的開頭急切地安排下一幀的動畫回調(急切是由於若是在幀的後半段安排動畫回調的話, 就會增大下一幀超過 100ms 的概率, 從而會浪費一個幀的利用, 能夠結合步驟②來理解這句話), 若是不存在調度器回調函數不然立馬終止執行。
  if (scheduledHostCallback !== null) {
    requestAnimationFrameWithTimeout(animationTick);
  } else {
    isAnimationFrameScheduled = false;
    return;
  }

  let nextFrameTime = rafTime - frameDeadline + activeFrameTime; // 當前幀開始調用動畫的時間 - 上一幀調用動畫的截止時間 + 當前幀執行的時間,這裏的 nextFrameTime 僅僅是臨時變量
  // 若是連續兩幀的時間都小於當前幀的時間, 則說明得調高 FPS
  if (nextFrameTime < activeFrameTime && previousFrameTime < activeFrameTime) {
    // 將 activeFrameTime 的值減少至關於調高 FPS。同時取 nextFrameTime 與 previousFrameTime 中較大的一個以讓先後兩幀都不出問題。
    activeFrameTime =
      nextFrameTime < previousFrameTime ? previousFrameTime : nextFrameTime;
  } else {
    previousFrameTime = nextFrameTime;
  }
  frameDeadline = rafTime + activeFrameTime; // 當前幀的截止時間(上面幾行代碼的目的是獲得該 frameDeadline 值, 該值在 postMessage 會用來判斷)
  if (!isMessageEventScheduled) {
    isMessageEventScheduled = true;
    port.postMessage(undefined); // 最後進入第④步, 經過 postMessage 觸發消息事件。
  }
};

// DOM 環境下 requestIdleCallback 的實現, 這裏第二個參數在最新的 requestIdleCallback 中由於對象類型
requestHostCallback = function (callback, absoluteTimeout) {
  scheduledHostCallback = callback; // 這裏的 callback 爲調度器回調函數
  timeoutTime = absoluteTimeout;
  if (isFlushingHostCallback || absoluteTimeout < 0) {
    // 針對優先級較高的任務不等下一個幀,在當前幀經過 postMessage 儘快執行
    port.postMessage(undefined);
  } else if (!isAnimationFrameScheduled) {
    // ① 若是 rAF 在當前幀沒有安排任務, 則開始一個幀的流程
    isAnimationFrameScheduled = true;
    requestAnimationFrameWithTimeout(animationTick);
  }
};

cancelHostCallback = function () {
  scheduledHostCallback = null;
  isMessageEventScheduled = false;
  timeoutTime = -1;
};
複製代碼

相關資料

相關文章
相關標籤/搜索