React Scheduler 源碼詳解(1)

下一篇

React Scheduler 源碼詳解(2)vue

一、引言

自從react 16出來之後,react fiber相關的文章層出不窮,但大多都是講解fiber的數據結構,以及組件樹的diff是如何由遞歸改成循環遍歷的。對於time slicing的描述通常都說利用了requestIdleCallback這個api來作調度,但對於任務如何調度卻很難找到詳細的描述。node

所以,本篇文章就是來幹這個事情的,從源碼角度來一步步闡述React Scheduler是怎麼實現任務調度的。react

雖說標題是React Scheduler,但本文的內容跟react是不相關的,由於任務調度器其實跟react是沒有關係的,它只是描述怎麼在合適的時機去執行一些任務,也就是說你即便沒有react基礎也能夠進行本文的閱讀,若是你是框架做者,也能夠借鑑這個scheduler的實現,在本身的框架裏來進行任務調度。git

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

二、基礎知識

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

一、window.performance.now

這個是瀏覽器內置的時鐘,從頁面加載開始計時,返回到當前的總時間,單位ms。意味着你在打開頁面第10分鐘在控制檯調用這個方法,返回的數字大概是 600000(誤)。api

二、window.requestAnimationFrame

  • 這個方法應該很常見了,它讓咱們能夠在下一幀開始時調用指定的函數。它的執行是是跟隨系統的刷新頻率的。requestAnimationFrame 方法接收一個參數,即要執行的回調函數。這個回調函數會默認地傳入一個參數,即從打開頁面到回調函數被觸發時的時間長度,單位爲毫秒。瀏覽器

  • 能夠理解爲系統在調用回調前立馬執行了一下performance.now()傳給了回調當參數。這樣咱們就能夠在執行回調的時候知道當前的執行時間了。bash

    requestAnimationFrame(function F(t) {
           console.log(t, '===='); //會不斷打印執行回調的時間,若是刷新頻率爲60Hz,則相鄰的t間隔時間大約爲1000/60 = 16.7ms
           requestAnimationFrame(F)
       })
    複製代碼
  • requestAnimationFrame有個特色,就是當頁面處理未激活的狀態下,requestAnimationFrame會中止執行;當頁面後面再轉爲激活時,requestAnimationFrame又會接着上次的地方繼續執行。數據結構

三、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內部正是利用了這一點來在一幀渲染結束後的剩餘時間來執行任務的

四、 鏈表

先默認你們對鏈表有個基本的認識。沒有的話本身去補一下知識。

這裏要介紹的是雙向循環鏈表

  • 雙向鏈表是指每一個節點有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對任務的調度就會比較容易了。

三、正文

注:爲了梳理總體的運行流程,下面的示例代碼有可能會在源碼基礎上有少許刪減

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

```
    getCurrentTime = function() {
        return performance.now();
        //若是不支持performance,利用 Date.now()作fallback
    }
```
複製代碼

一、任務優先級

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天半,任務纔有可能過時。

二、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

一、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

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

相關文章
相關標籤/搜索