剖析 React 源碼:調度原理

這是個人剖析 React 源碼的第四篇文章,以前的文章都是具體剖析代碼,可是以爲這種方式可能並非太好。所以從這篇文章開始,我打算把在源碼中學習到的內容單獨寫成一篇文章,這樣對於讀者來講可能更加的友好。前端

文章相關資料

爲何須要調度?

你們都知道 JS 和渲染引擎是一個互斥關係。若是 JS 在執行代碼,那麼渲染引擎工做就會被中止。假如咱們有一個很複雜的複合組件須要從新渲染,那麼調用棧可能會很長react

調用棧過長,再加上若是中間進行了複雜的操做,就可能致使長時間阻塞渲染引擎帶來很差的用戶體驗,調度就是來解決這個問題的。git

React 會根據任務的優先級去分配各自的 expirationTime,在過時時間到來以前先去處理更高優先級的任務,而且高優先級的任務還能夠打斷低優先級的任務(所以會形成某些生命週期函數屢次被執行),從而實如今不影響用戶體驗的狀況下去分段計算更新(也就是時間分片)。github

React 如何實現調度

React 實現調度主要靠兩塊內容:瀏覽器

  1. 計算任務的 expriationTime
  2. 實現 requestIdleCallback 的 polyfill 版本

接下來就讓筆者爲你們一一介紹着兩塊內容。函數

expriationTime

expriationTime 在前文簡略的介紹過它的做用,這個時間能夠幫助咱們對比不一樣任務之間的優先級以及計算任務的 timeout。post

那麼這個時間是如何計算出來的呢?學習

當前時間指的是 performance.now(),這個 API 會返回一個精確到毫秒級別的時間戳(固然也並非高精度的),另外瀏覽器也並非全部都兼容 performance API 的。若是使用 Date.now() 的話那麼精度會更差,可是爲了方便起見,咱們這裏統一把當前時間認爲是 performance.now()動畫

常量指的是根據不一樣優先級得出的一個數值,React 內部目前總共有五種優先級,分別爲:spa

var ImmediatePriority = 1;
var UserBlockingPriority = 2;
var NormalPriority = 3;
var LowPriority = 4;
var IdlePriority = 5;
複製代碼

它們各自的對應的數值都是不一樣的,具體的內容以下

var maxSigned31BitInt = 1073741823;

// Times out immediately
var IMMEDIATE_PRIORITY_TIMEOUT = -1;
// Eventually times out
var USER_BLOCKING_PRIORITY = 250;
var NORMAL_PRIORITY_TIMEOUT = 5000;
var LOW_PRIORITY_TIMEOUT = 10000;
// Never times out
var IDLE_PRIORITY = maxSigned31BitInt;
複製代碼

也就是說,假設當前時間爲 5000 而且分別有兩個優先級不一樣的任務要執行。前者屬於 ImmediatePriority,後者屬於 UserBlockingPriority,那麼兩個任務計算出來的時間分別爲 49995250。經過這個時間能夠比對大小得出誰的優先級高,也能夠經過減去當前時間獲取任務的 timeout。

requestIdleCallback

說完了 expriationTime,接下來的主題就是實現 requestIdleCallback 了,咱們首先來了解下該函數的做用

該函數的回調方法會在瀏覽器的空閒時期依次調用, 可讓咱們在事件循環中執行一些任務,而且不會對像動畫和用戶交互這樣延遲敏感的事件產生影響。

在上圖中咱們也能夠發現,該回調方法是在渲染之後才執行的。那麼介紹完了函數的做用,接下來就來講說它的兼容性吧。

這個函數的兼容性並非很好,而且它還有一個致命的缺陷:

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

也就是說 requestIdleCallback 只能一秒調用回調 20 次,這個徹底知足不了現有的狀況,由此 React 團隊纔打算本身實現這個函數。

若是你想了解更多關於替換 requestIdleCallback 的內容,能夠閱讀 該 Issus

如何實現 requestIdleCallback

實現 requestIdleCallback 函數的核心只有一點,如何屢次在瀏覽器空閒時且是渲染後才調用回調方法?

說到屢次執行,那麼確定得使用定時器了。在多種定時器中,惟有 requestAnimationFrame 具有必定的精確度,所以 requestAnimationFrame 就是當下實現 requestIdleCallback 的一個步驟。

requestAnimationFrame 的回調方法會在每次重繪前執行,另外它還存在一個瑕疵:頁面處於後臺時該回調函數不會執行,所以咱們須要對於這種狀況作個補救措施

rAFID = requestAnimationFrame(function(timestamp) {
  // cancel the setTimeout
  localClearTimeout(rAFTimeoutID);
  callback(timestamp);
});
rAFTimeoutID = setTimeout(function() {
  // 定時 100 毫秒是算是一個最佳實踐
  localCancelAnimationFrame(rAFID);
  callback(getCurrentTime());
}, 100);
複製代碼

requestAnimationFrame 不執行時,會有 setTimeout 去補救,兩個定時器內部能夠互相取消對方。

使用 requestAnimationFrame 只完成了屢次執行這一步操做,接下來咱們須要實現如何知道當前瀏覽器是否空閒呢?

你們都知道在一幀當中,瀏覽器可能會響應用戶的交互事件、執行 JS、進行渲染的一系列計算繪製。假設當前咱們的瀏覽器支持 1 秒 60 幀,那麼也就是說一幀的時間爲 16.6 毫秒。若是以上這些操做超過了 16.6 毫秒,那麼就會致使渲染沒有完成並出現掉幀的狀況,繼而影響用戶體驗;若是以上這些操做沒有耗時 16.6 毫秒的話,那麼咱們就認爲當下存在空閒時間讓咱們能夠去執行任務。

所以接下去咱們須要計算出當前幀是否還有剩餘時間讓咱們使用。

let frameDeadline = 0
let previousFrameTime = 33
let activeFrameTime = 33
let nextFrameTime = performance.now() - frameDeadline + activeFrameTime
if (
  nextFrameTime < activeFrameTime &&
  previousFrameTime < activeFrameTime
) {
  if (nextFrameTime < 8) {
    nextFrameTime = 8;
  }
  activeFrameTime =
    nextFrameTime < previousFrameTime ? previousFrameTime : nextFrameTime;
} else {
  previousFrameTime = nextFrameTime;
}
複製代碼

以上這部分代碼核心就是得出每一幀所耗時間及下一幀的時間。簡單來講就是假設當前時間爲 5000,瀏覽器支持 60 幀,那麼 1 幀近似 16 毫秒,那麼就會計算出下一幀時間爲 5016。

得出下一幀時間之後,咱們只需對比當前時間是否小於下一幀時間便可,這樣就能清楚地知道是否還有空閒時間去執行任務。

那麼最後一步操做就是如何在渲染之後纔去執行任務。這裏就須要用到事件循環的知識了

想必你們都知道微任務宏任務的區別,這裏就再也不贅述這部分的內容了。從上圖中咱們能夠發現,在渲染之後只有宏任務是最早會被執行的,所以宏任務就是咱們實現這一步的操做了。

可是生成一個宏任務有不少種方式而且各自也有優先級,那麼爲了最快地執行任務,咱們確定得選擇優先級高的方式。在這裏咱們選擇了 MessageChannel 來完成這個任務,不選擇 setImmediate 的緣由是由於兼容性太差。

到這裏爲止,requestAnimationFrame + 計算幀時間及下一幀時間 + MessageChannel 就是咱們實現 requestIdleCallback 的三個關鍵點了。

調度的流程

上文說了這麼多,這一小節咱們未來梳理一遍調度的整個流程。

  • 首先每一個任務都會有各自的優先級,經過當前時間加上優先級所對應的常量咱們能夠計算出 expriationTime高優先級的任務會打斷低優先級任務
  • 在調度以前,判斷當前任務是否過時,過時的話無須調度,直接調用 port.postMessage(undefined),這樣就能在渲染後立刻執行過時任務了
  • 若是任務沒有過時,就經過 requestAnimationFrame 啓動定時器,在重繪前調用回調方法
  • 在回調方法中咱們首先須要計算每一幀的時間以及下一幀的時間,而後執行 port.postMessage(undefined)
  • channel.port1.onmessage 會在渲染後被調用,在這個過程當中咱們首先須要去判斷當前時間是否小於下一幀時間。若是小於的話就表明咱們尚有空餘時間去執行任務;若是大於的話就表明當前幀已經沒有空閒時間了,這時候咱們須要去判斷是否有任務過時,過時的話無論三七二十一仍是得去執行這個任務。若是沒有過時的話,那就只能把這個任務丟到下一幀看能不能執行了

調度不會僅僅只有 React 擁有

在將來,調度這個功能不會僅僅只有 React 才擁有。由於 React 已經嘗試把調度這個模塊單獨抽離成一個庫,這個庫在將來可以被你們置入到本身的應用中去提升用戶體驗。

而且社區也有提案,但願瀏覽器能自帶這方面的功能,具體能夠閱讀 這個庫

最後

閱讀源碼是一個很枯燥的過程,可是收益也是巨大的。若是你在閱讀的過程當中有任何的問題,都歡迎你在評論區與我交流。

另外寫這系列是個很耗時的工程,須要維護代碼註釋,還得把文章寫得儘可能讓讀者看懂,最後還得配上畫圖,若是你以爲文章看着還行,就請不要吝嗇你的點贊。

最後,以爲內容有幫助能夠關注下個人公衆號 「前端真好玩」咯,會有不少好東西等着你。

相關文章
相關標籤/搜索