你不知道的 requestIdleCallback


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

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

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 有兩個參數

  • 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 Suspense

也就是說 requestIdleCallback 的 FPS 只有 20, 這遠遠低於頁面流暢度的要求!(一般 FPS 為 60 時對用戶來說是感覺流程的, 即一幀時間為 16.7 ms), 這也是 React 需要自己實現 requestIdleCallback 的原因。

源碼解析之 requestIdleCallback

非 DOM 環境

在不能操作 DOM 的環境下, 可以借助 setTimeout 來模擬 requestIdleCallback 的實現。

requestIdleCallback = (callback) => {
  setTimeout(callback({
    timeRemaining() {
      return Infinity
    }
  }))
}

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

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) 這部分源碼的實現比較復雜, 可以將其分解為以下幾個重要的步驟(有一些細節點可以看注釋):

步驟一: 如果有優先級更高的任務, 則通過 postMessage 觸發步驟四, 否則如果 requestAnimationFrame 在當前幀沒有安排任務, 則開始一個幀的流程;
步驟二: 在一個幀的流程中調用 requestAnimationFrameWithTimeout 函數, 該函數調用了 requestAnimationFrame, 並對執行時間超過 100ms 的任務用 setTimeout 放到下一個事件隊列中處理;
步驟三: 執行 requestAnimationFrame 中的回調函數 animationTick, 在該回調函數中得到當前幀的截止時間 frameDeadline, 並通過 postMessage 觸發步驟四;
步驟四: 通過 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;
};

相關資料


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM