React Scheduler 原始碼詳解(1)

嫌疑犯X發表於2019-01-07
下一篇

React Scheduler 原始碼詳解(2)

1、引言

自從react 16出來以後,react fiber相關的文章層出不窮,但大多都是講解fiber的資料結構,以及元件樹的diff是如何由遞迴改為迴圈遍歷的。對於time slicing的描述一般都說利用了requestIdleCallback這個api來做排程,但對於任務如何排程卻很難找到詳細的描述。

因此,本篇文章就是來幹這個事情的,從原始碼角度來一步步闡述React Scheduler是怎麼實現任務排程的。

雖然說標題是React Scheduler,但本文的內容跟react是不相關的,因為任務排程器其實跟react是沒有關係的,它只是描述怎麼在合適的時機去執行一些任務,也就是說你即使沒有react基礎也可以進行本文的閱讀,如果你是框架作者,也可以借鑑這個scheduler的實現,在自己的框架裡來進行任務排程。

  • 本文講解的是react v16.7.0版本的原始碼,請注意時效性。
  • 原始碼路徑 Scheduler.js

2、基礎知識

接下來先來了解一下閱讀本文需要知道的一些基礎知識。

1、window.performance.now

這個是瀏覽器內建的時鐘,從頁面載入開始計時,返回到當前的總時間,單位ms。意味著你在開啟頁面第10分鐘在控制檯呼叫這個方法,返回的數字大概是 600000(誤)。

2、window.requestAnimationFrame

  • 這個方法應該很常見了,它讓我們可以在下一幀開始時呼叫指定的函式。它的執行是是跟隨系統的重新整理頻率的。requestAnimationFrame 方法接收一個引數,即要執行的回撥函式。這個回撥函式會預設地傳入一個引數,即從開啟頁面到回撥函式被觸發時的時間長度,單位為毫秒。

  • 可以理解為系統在呼叫回撥前立馬執行了一下performance.now()傳給了回撥當引數。這樣我們就可以在執行回撥的時候知道當前的執行時間了。

     requestAnimationFrame(function F(t) {
           console.log(t, '===='); //會不斷列印執行回撥的時間,如果重新整理頻率為60Hz,則相鄰的t間隔時間大約為1000/60 = 16.7ms
           requestAnimationFrame(F)
       })
    複製程式碼
  • requestAnimationFrame有個特點,就是當頁面處理未啟用的狀態下,requestAnimationFrame會停止執行;當頁面後面再轉為啟用時,requestAnimationFrame又會接著上次的地方繼續執行。

3、window.MessageChannel

這個介面允許我們建立一個新的訊息通道,並通過它的兩個MessagePort(port1,port2) 屬性傳送資料。 示例程式碼如下

    var channel = new MessageChannel();
    var port1 = channel.port1;
    var port2 = channel.port2;
    port1.onmessage = function(event){
        console.log(event.data)  // someData
    }
    port2.postMessage('someData')
複製程式碼

這裡有一點需要注意,onmessage的回撥函式的呼叫時機是在一幀的paint完成之後。據觀察vuenextTick也是用MessageChannel來做fallback的(優先用setImmediate)。
react scheduler內部正是利用了這一點來在一幀渲染結束後的剩餘時間來執行任務的

4、 連結串列

先預設大家對連結串列有個基本的認識。沒有的話自己去補一下知識。

這裡要介紹的是雙向迴圈連結串列

  • 雙向連結串列是指每個節點有previousnext兩個屬性來分別指向前後兩個節點。
  • 迴圈的意思是,最後一個節點的next指向第一個節點,而第一個節點的previous指向最後一個節點,形成一個環形的人體蜈蚣
  • 我們還需要用一個變數firstNode來儲存第一個節點。
  • 下面以一個具體例子來講一下雙向迴圈連結串列的插入和刪除操作,假設有一群人需要按照年齡進行排隊,小孩站前邊,大人站後邊。在一個過程內會不斷有人過來,我們需要把他插到正確的位置。刪除的話只考慮每次把排頭的人給去掉。
    //person的型別定義
    interface Person {
        name : string  //姓名
        age : number  //年齡,依賴這個屬性排序
        next : Person  //緊跟在後面的人,預設是null
        previous : Person //前面相鄰的那個人,預設是null
    }
    var firstNode = null; //一開始連結串列裡沒有節點
    
    //插入的邏輯
    function insertByAge(newPerson:Person){
        if(firstNode = null){
        
        //如果 firstNode為空,說明newPerson是第一個人,  
        //把它賦值給firstNode,並把next和previous屬性指向自身,自成一個環。
          firstNode = newPerson.next = newPerson.previous = newPerson;
          
        } else { //隊伍裡有人了,新來的人要找準自己的位置
        
             var next = null; //記錄newPerson插入到哪個人前邊
             var person = firstNode; // person 在下邊的迴圈中會從第一個人開始往後找
             
             do {
                  if (person.age > newPerson.age) {
                  //如果person的年齡比新來的人大,說明新來的人找到位置了,他恰好要排在person的前邊,結束
                    next = person;
                    break;
                  }
                  //繼續找後面的人
                  node = node.next;
            } while (node !== firstNode); //這裡的while是為了防止無限迴圈,畢竟是環形的結構
            
            if(next === null){ //找了一圈發現 沒有person的age比newPerson大,說明newPerson應該放到隊伍的最後,也就是說newPerson的後面應該是firstNode。
                next = firstNode;
            }else if(next === firstNode){ //找第一個的時候就找到next了,說明newPerson要放到firstNode前面,這時候firstNode就要更新為newPerson
                firstNode = newPerson
            }
            
            //下面是newPerson的插入操作,給next及previous兩個人的前後連結都關聯到newPerson
            var previous = next.previous;
            previous.next = next.previous = newPerson; 
            newPerson.next = next;
            newPerson.previous = previous;
        }
        //插入成功
    }
    
    //刪除第一個節點
    function deleteFirstPerson(){
        if(firstNode === null) return; //隊伍裡沒有人,返回
        
        var next = firstNode.next; //第二個人
        if(firstNode === next) {
            //這時候只有一個人
            firstNode = null;
            next = null;
        } else {
            var lastPerson = firstNode.previous; //找到最後一個人
            firstNode = lastPerson.next = next; //更新新的第一人
            next.previout = lastPerson; //並在新的第一人和最後一人之間建立連線
        }
        
    }
    
複製程式碼

由於react16內大量利用了連結串列來記錄資料,尤其react scheduler內對任務的操作使用了雙向迴圈連結串列結構。所以理解了上述的程式碼,對於理解react對任務的排程就會比較容易了。

3、正文

注:為了梳理整體的執行流程,下面的示例程式碼有可能會在原始碼基礎上有少量刪減

0、 幾個方法,下文不再贅述

```
    getCurrentTime = function() {
        return performance.now();
        //如果不支援performance,利用 Date.now()做fallback
    }
```
複製程式碼

1、任務優先順序

react內對任務定義的優先順序分為5種,數字越小優先順序越高

   var ImmediatePriority = 1;  //最高優先順序
   var UserBlockingPriority = 2; //使用者阻塞型優先順序
   var NormalPriority = 3; //普通優先順序
   var LowPriority = 4; // 低優先順序
   var IdlePriority = 5; // 空閒優先順序
複製程式碼

這5種優先順序依次對應5個過期時間

   // Max 31 bit integer. The max integer size in V8 for 32-bit systems.
   // Math.pow(2, 30) - 1
   var maxSigned31BitInt = 1073741823;

   // 立馬過期 ==> ImmediatePriority
   var IMMEDIATE_PRIORITY_TIMEOUT = -1;
   // 250ms以後過期
   var USER_BLOCKING_PRIORITY = 250;
   //
   var NORMAL_PRIORITY_TIMEOUT = 5000;
   //
   var LOW_PRIORITY_TIMEOUT = 10000;
   // 永不過期
   var IDLE_PRIORITY = maxSigned31BitInt;
複製程式碼

每個任務在新增到連結串列裡的時候,都會通過 performance.now() + timeout來得出這個任務的過期時間,隨著時間的推移,當前時間會越來越接近這個過期時間,所以過期時間越小的代表優先順序越高。如果過期時間已經比當前時間小了,說明這個任務已經過期了還沒執行,需要立馬去執行(asap)。

上面的maxSigned31BitInt,通過註釋可以知道這是32位系統V8引擎裡最大的整數。react用它來做IdlePriority的過期時間。

據粗略計算這個時間大概是12.427天。也就是說極端情況下你的網頁tab如果能一直開著到12天半,任務才有可能過期。

2、function scheduleCallback()

  • 程式碼裡的方法叫做unstable_scheduleCallback,意思是當前還是不穩定的,這裡就以scheduleCallback作名字。
  • 這個方法的作用就是把任務以過期時間作為優先順序進行排序,過程類似上文雙向迴圈連結串列的操作過程。

下面上程式碼

   function scheduleCallback(callback, options? : {timeout:number} ) {
       //to be coutinued
   }
複製程式碼

這個方法有兩個入參,第一個是要執行的callback,暫時可以理解為一個任務。第二個引數是可選的,可以傳入一個超時時間來標識這個任務過多久超時。如果不傳的話就會根據上述的任務優先順序確定過期時間。

  //這是一個全域性變數,代表當前任務的優先順序,預設為普通
  var currentPriorityLevel = NormalPriority
  
  function scheduleCallback(callback, options? : {timeout:number} ) {
      var startTime = getCurrentTime()
      if (
          typeof options === 'object' &&
          options !== null &&
          typeof options.timeout === 'number'
        ){
          //如果傳了options, 就用入參的過期時間
          expirationTime = startTime + options.timeout;
        } else {
          //判斷當前的優先順序
          switch (currentPriorityLevel) {
            case ImmediatePriority:
              expirationTime = startTime + IMMEDIATE_PRIORITY_TIMEOUT;
              break;
            case UserBlockingPriority:
              expirationTime = startTime + USER_BLOCKING_PRIORITY;
              break;
            case IdlePriority:
              expirationTime = startTime + IDLE_PRIORITY;
              break;
            case LowPriority:
              expirationTime = startTime + LOW_PRIORITY_TIMEOUT;
              break;
            case NormalPriority:
            default:
              expirationTime = startTime + NORMAL_PRIORITY_TIMEOUT;
          }
        }
        
        //上面確定了當前任務的截止時間,下面建立一個任務節點,
        var newNode = {
          callback, //任務的具體內容
          priorityLevel: currentPriorityLevel, //任務優先順序
          expirationTime, //任務的過期時間
          next: null, //下一個節點
          previous: null, //上一個節點
        };
      //to be coutinued
  }
複製程式碼

上面的程式碼根據入參或者當前的優先順序來確定當前callback的過期時間,並生成一個真正的任務節點。接下來就要把這個節點按照expirationTime排序插入到任務的連結串列裡邊去。

   // 代表任務連結串列的第一個節點
   var firstCallbackNode = null;
   
   function scheduleCallback(callback, options? : {timeout:number} ) {
       ...
       var newNode = {
           callback, //任務的具體內容
           priorityLevel: currentPriorityLevel, //任務優先順序
           expirationTime, //任務的過期時間
           next: null, //下一個節點
           previous: null, //上一個節點
       };
       // 下面是按照 expirationTime 把 newNode 加入到任務佇列裡。參考基礎知識裡的person排隊的例子
       
       if (firstCallbackNode === null) {
           firstCallbackNode = newNode.next = newNode.previous = newNode;
           ensureHostCallbackIsScheduled(); //這個方法先忽略,後面講
       } else {
           var next = null;
           var node = firstCallbackNode;
           do {
             if (node.expirationTime > expirationTime) {
               next = node;
               break;
             }
             node = node.next;
           } while (node !== firstCallbackNode);

       if (next === null) {
         next = firstCallbackNode;
       } else if (next === firstCallbackNode) {
         firstCallbackNode = newNode;
         ensureHostCallbackIsScheduled(); //這個方法先忽略,後面講
       }
   
       var previous = next.previous;
       previous.next = next.previous = newNode;
       newNode.next = next;
       newNode.previous = previous;
     }
   
     return newNode;
       
   }
複製程式碼
  • 上面的邏輯除了ensureHostCallbackIsScheduled就是前面講的雙向迴圈連結串列的插入邏輯。
  • 到這裡一個新進來的任務如何確定過期時間以及如何插入現有的任務佇列就講完了。
  • 到這裡就會不禁產生一個疑問,我們把任務按照過期時間排好順序了,那麼何時去執行任務呢?
  • 答案是有兩種情況,1是當新增第一個任務節點的時候開始啟動任務執行,2是當新新增的任務取代之前的節點成為新的第一個節點的時候。因為1意味著任務從無到有,應該 立刻啟動。2意味著來了新的優先順序最高的任務,應該停止掉之前要執行的任務,重新從新的任務開始執行。
  • 上面兩種情況就對應ensureHostCallbackIsScheduled方法執行的兩個分支。所以我們現在應該知道,ensureHostCallbackIsScheduled是用來在合適的時機去啟動任務執行的。
  • 到底什麼是合適的時機?可以這麼描述,在每一幀繪製完成之後的空閒時間。這樣就能保證瀏覽器繪製每一幀的頻率能跟上系統的重新整理頻率,不會掉幀。

接下來就需要實現這麼一個功能,如何在合適的時機去執行一個function。

3 requestIdleCallback pollyfill

現在請暫時忘掉上面那段任務佇列相關的事情,來思考如何在瀏覽器每一幀繪製完的空閒時間來做一些事情。

答案可以是requestIdleCallback,但由於某些原因,react團隊放棄了這個api,轉而利用requestAnimationFrameMessageChannel pollyfill了一個requestIdleCallback

1、function requestAnimationFrameWithTimeout()

首先介紹一個超強的函式,程式碼如下

    var requestAnimationFrameWithTimeout = function(callback) {
      rAFID = requestAnimationFrame(function(timestamp) {
        clearTimeout(rAFTimeoutID);
        callback(timestamp);
      });
      rAFTimeoutID = setTimeout(function() {
        cancelAnimationFrame(rAFID);
        callback(getCurrentTime());
      }, 100);
    }
複製程式碼

這段程式碼什麼意思呢?

  • 當我們呼叫requestAnimationFrameWithTimeout並傳入一個callback的時候,會啟動一個requestAnimationFrame和一個setTimeout,兩者都會去執行callback。但由於requestAnimationFrame執行優先順序相對較高,它內部會呼叫clearTimeout取消下面定時器的操作。所以在頁面active情況下的表現跟requestAnimationFrame是一致的。

  • 到這裡大家應該明白了,一開始的基礎知識裡說了,requestAnimationFrame在頁面切換到未啟用的時候是不工作的,這時requestAnimationFrameWithTimeout就相當於啟動了一個100ms的定時器,接管任務的執行工作。這個執行頻率不高也不低,既能不影響cpu能耗,又能保證任務能有一定效率的執行。

  • 下面我們暫時先認為requestAnimationFrameWithTimeout 等價於 requestAnimationFrame

(不知不覺篇幅已經這麼長了,今天先寫到這裡吧,下次有機會再更)

相關文章