100行程式碼實現React核心排程功能

卡頌發表於2021-12-16

大家好,我卡頌。

想必大家都知道React有一套基於Fiber架構的排程系統。

這套排程系統的基本功能包括:

  • 更新有不同優先順序
  • 一次更新可能涉及多個元件的render,這些render可能分配到多個巨集任務中執行(即時間切片
  • 高優先順序更新會打斷進行中的低優先順序更新

本文會用100行程式碼實現這套排程系統,讓你快速瞭解React的排程原理。

我知道你不喜歡看大段的程式碼,所以本文會以+程式碼片段的形式講解原理。

文末有完整的線上Demo,你可以自己上手玩玩。

開整!

歡迎加入人類高質量前端框架群,帶飛

準備工作

我們用work這一資料結構代表一份工作,work.count代表這份工作要重複做某件事的次數。

Demo中要重複做的事是“執行insertItem方法,向頁面插入<span/>”:

const insertItem = (content: string) => {
  const ele = document.createElement('span');
  ele.innerText = `${content}`;
  contentBox.appendChild(ele);
};

所以,對於如下work

const work1 = {
  count: 100
}

代表:執行100次insertItem向頁面插入100個<span/>

work可以類比React的一次更新work.count類比這次更新要render的元件數量。所以Demo是對React更新流程的類比

來實現第一版的排程系統,流程如圖:

包括三步:

  1. workList佇列(用於儲存所有work)插入work
  2. schedule方法從workList中取出work,傳遞給perform
  3. perform方法執行完work的所有工作後重復步驟2

程式碼如下:

// 儲存所有work的佇列
const workList: work[] = [];

// 排程
function schedule() {
  // 從佇列尾取一個work
  const curWork = workList.pop();
  
  if (curWork) {
    perform(curWork);
  }
}

// 執行
function perform(work: Work) {
  while (work.count) {
    work.count--;
    insertItem();
  }
  schedule();
}

為按鈕繫結點選互動,最基本的排程系統就完成了:

button.onclick = () => {
  workList.unshift({
    count: 100
  })
  schedule();
}

點選button就能插入100個<span/>

React類比就是:點選button,觸發同步更新,100個元件render

接下來我們將其改造成非同步的。

Scheduler

React內部使用Scheduler完成非同步排程。

Scheduler是獨立的包。所以可以用他改造我們的Demo

Scheduler預置了5種優先順序,從上往下優先順序降低:

  • ImmediatePriority,最高的同步優先順序
  • UserBlockingPriority
  • NormalPriority
  • LowPriority
  • IdlePriority,最低優先順序

scheduleCallback方法接收優先順序與回撥函式fn,用於排程fn

// 將回撥函式fn以LowPriority優先順序排程
scheduleCallback(LowPriority, fn)

Scheduler內部,執行scheduleCallback後會生成task這一資料結構:

const task1 = {
  expiration: startTime + timeout,
  callback: fn
}

task1.expiration代表task1的過期時間,Scheduler會優先執行過期的task.callback

expirationstartTime為當前開始時間,不同優先順序的timeout不同。

比如,ImmediatePrioritytimeout為-1,由於:

startTime - 1 < startTime

所以ImmediatePriority會立刻過期,callback立刻執行。

IdlePriority對應timeout為1073741823(最大的31位帶符號整型),其callback需要非常長時間才會執行。

callback會在新的巨集任務中執行,這就是Scheduler排程的原理。

用Scheduler改造Demo

改造後的流程如圖:

改造前,work直接從workList佇列尾取出:

// 改造前
const curWork = workList.pop();

改造後,work可以擁有不同優先順序,通過priority欄位表示。

比如,如下work代表以NormalPriority優先順序插入100個\<span/\>

const work1 = {
  count: 100,
  priority: NormalPriority
}

所以,改造後每次都使用最高優先順序的work

// 改造後
// 對workList排序後取priority值最小的(值越小,優先順序越高)
const curWork = workList.sort((w1, w2) => {
   return w1.priority - w2.priority;
})[0];

改造後流程的變化

由流程圖可知,Scheduler不再直接執行perform,而是通過執行scheduleCallback排程perform.bind(null, work)

即,滿足一定條件的情況下,生成新task

const someTask = {
  callback: perform.bind(null, work),
  expiration: xxx
}

同時,work的工作也是可中斷的。在改造前,perform會同步執行完work中的所有工作:

while (work.count) {
  work.count--;
  insertItem();
}

改造後,work的執行流程隨時可能中斷:

while (!needYield() && work.count) {
  work.count--;
  insertItem();
}
needYield方法的實現(何時會中斷)請參考文末線上Demo

高優先順序打斷低優先順序的例子

舉例來看一個高優先順序打斷低優先順序的例子:

  1. 插入一個低優先順序work,屬性如下
const work1 = {
  count: 100,
  priority: LowPriority
}
  1. 經歷schedule(排程),perform(執行),在執行了80次工作時,突然插入一個高優先順序work,此時:
const work1 = {
  // work1已經執行了80次工作,還差20次執行完
  count: 20,
  priority: LowPriority
}
// 新插入的高優先順序work
const work2 = {
  count: 100,
  priority: ImmediatePriority
}
  1. work1工作中斷,繼續schedule。由於work2優先順序更高,會進入work2對應perform,執行100次工作
  2. work2執行完後,繼續schedule,執行work1剩餘的20次工作

在這個例子中,我們需要區分2個打斷的概念:

  1. 在步驟3中,work1執行的工作被打斷。這是微觀角度的打斷
  2. 由於work1被打斷,所以繼續schedule。下一個執行工作的是更高優的work2work2的到來導致work1被打斷,這是巨集觀角度的打斷

之所以要區分巨集/微觀,是因為微觀的打斷不一定意味著巨集觀的打斷

比如:work1由於時間切片用盡,被打斷。沒有其他更高優的work與他競爭schedule的話,下一次perform還是work1

這種情況下微觀下多次打斷,但是巨集觀來看,還是同一個work在執行。這就是時間切片的原理。

排程系統的實現原理

以下是排程系統的完整實現原理:

對照流程圖來看:

總結

本文是React排程系統的簡易實現,主要包括兩個階段:

  • schedule
  • perform

這裡是完整Demo地址

相關文章