React Scheduler 原始碼詳解(2)

嫌疑犯X發表於2019-02-11
上一篇

React Scheduler 原始碼詳解(1)

上次講述了任務的優先順序,以及如何根據優先順序(過期時間)加入任務連結串列,今天來分析一下如何在一個合適的時機去執行任務。

1 requestIdleCallback pollyfill

上文講到要用requetAnimationFrame去模擬requestIdleCallback,但requetAnimationFrame有個缺點,就是當前tab如果處於不啟用狀態的話,requestAnimationFrame是不工作的,所以需要requestAnimationFramesetTimeout聯合起來保證任務的執行。這就是上文末講到的requestAnimationFrameWithTimeout的作用,當前tab處於啟用狀態時,相當於requestAnimationFrame在排程任務,當前tab切到未啟用時setTimeout接管任務執行。為了理解方便,下文我們就用requestAnimationFrame來表示requestAnimationFrameWithTimeout

0.流程

我們先來描述一下整個的執行流程,在每一幀開始的rAF的回撥裡記錄每一幀的開始時間,並計算每一幀的過期時間,然後通過messageChannel傳送訊息。在幀末messageChannel的回撥裡接收訊息,根據當前幀的過期時間和當前時間進行比對來決定當前幀能否執行任務,如果能的話會依次從任務連結串列裡拿出隊首任務來執行,執行儘可能多的任務後如果還有任務,下一幀再重新排程。

1.宣告變數

    var scheduledHostCallback = null; //代表任務連結串列的執行器
    var timeoutTime = -1; //代表最高優先順序任務firstCallbackNode的過期時間
    var activeFrameTime = 33; // 一幀的渲染時間33ms,這裡假設 1s 30幀
    var frameDeadline = 0; //代表一幀的過期時間,通過rAF回撥入參t加上activeFrameTime來計算
複製程式碼

2.計算每一幀的截止時間

首先我們先利用requestAnimationFrame來計算每一幀的截止時間

    // rAF的回撥是每一幀開始的時候,所以適合做一些輕量任務,不然會阻塞渲染。
    function animationTick(rafTime) {
        // 有任務再進行遞迴,沒任務的話不需要工作
        if (scheduledHostCallback !== null) {
            requestAnimationFrame(animationTick)
        }
        //計算當前幀的截止時間,用開始時間加上每一幀的渲染時間
        frameDeadline = rafTime + activeFrameTime; 
    }
    
    //某個地方會呼叫
    requestAnimationFrame(animationTick)
複製程式碼

原始碼裡有對每一幀渲染時間的一個優化過程,會在渲染過程中不斷壓縮每一幀的渲染時間,達到系統的重新整理頻率(60hz為16.6ms)。因為不是重點就先略過了,這裡假設就是33ms。

3.建立一個訊息通道

     var channel = new MessageChannel();
     var port = channel.port2; //port2用來發訊息
     channel.port1.onmessage = function(event) {
        //port1監聽訊息的回撥來做任務排程的具體工作,後面再說
        //onmessage的回撥函式的呼叫時機是在一幀的paint完成之後,所以適合做一些重型任務,也能保證頁面流暢不卡頓
     }
複製程式碼

4.執行任務

下面就在animationTick裡向channel發訊息,然後在port1的回撥裡去決定當前幀要不要執行任務,執行多少任務等問題。

     function animationTick(rafTime) {
        // 有任務再進行遞迴,沒任務的話不需要工作
        if (scheduledHostCallback !== null) {
            requestAnimationFrame(animationTick)
        }
        //計算當前幀的截止時間,用開始時間加上每一幀的渲染時間
        frameDeadline = rafTime + activeFrameTime; 
        
        //新加的程式碼,在當前幀結束去搞一些事情
        port.postMessage(undefined);
    }
    
      //仔細看這段註釋
      //下面的程式碼邏輯決定當前幀要不要執行任務
      // 1、如果當前幀沒過期,說明當前幀有富餘時間,可以執行任務
      // 2、如果當前幀過期了,說明當前幀沒有時間了,這裡再看一下當前任務firstCallbackNode是否過期,如果過期了也要執行任務;如果當前任務沒過期,說明不著急,那就先不執行去下一幀再說。
      channel.port1.onmessage = function(event) {
         var currentTime = getCurrentTime(); //獲取當前時間,
         var didTimeout = false; //是否過期
         
         
         if (frameDeadline - currentTime <= 0) {  // 當前幀過期
            if (timeoutTime <= currentTime) {
                // 當前任務過期
                // timeoutTime 為當前任務的過期時間,會有個地方賦值。
                didTimeout = true;
            } else {
                //當前幀由於瀏覽器渲染等原因過期了,那就去下一幀再處理
                return;
            }
         }
         // 到了這裡有兩種情況,1是當前幀沒過期;2是當前幀過期且當前任務過期,也就是上面第二個if裡的邏輯。下面就是要呼叫執行器,依次執行連結串列裡的任務
         scheduledHostCallback(didTimeout)
     }
複製程式碼

5.執行器

上文提到的執行器 scheduledHostCallback 也就是下面的flushWork,flushWork根據didTimeout引數有兩種處理邏輯,如果為true,就會把任務連結串列裡的過期任務全都給執行一遍;如果為false則在當前幀到期之前儘可能多的去執行任務。

    function flushWork(didTimeout) {
        if (didTimeout) { //任務過期
            while (firstCallbackNode !== null) {
                var currentTime = getCurrentTime(); //獲取當前時間
                if (firstCallbackNode.expirationTime <= currentTime) {//如果隊首任務時間比當前時間小,說明過期了
                  do {
                    flushFirstCallback(); //執行隊首任務,把隊首任務從連結串列移除,並把第二個任務置為隊首任務。執行任務可能產生新的任務,再把新任務插入到任務連結串列
                  } while (
                    firstCallbackNode !== null &&
                    firstCallbackNode.expirationTime <= currentTime
                  );
                  continue;
                }
                break;
            }
        }else{
            //下面再說
        }
    }
複製程式碼

注意,上面有兩重while迴圈,外層的while迴圈每次都會獲取當前時間,內層迴圈根據這個當前時間去判斷任務是否過期並執行。這樣當內層執行了若干任務後,當前時間又會向前推進一塊。外層迴圈再重新獲取當前時間,直到沒有任務過期或者沒有任務為止。

下面看一下沒有過期的處理情況

    function flushWork(didTimeout) {
        if (didTimeout) { //任務過期
           ...
        }else{ 
                //當前幀有富餘時間,while的邏輯是隻要有任務且當前幀沒過期就去執行任務。
             if (firstCallbackNode !== null) {
                do {
                  flushFirstCallback();//執行隊首任務,把隊首任務從連結串列移除,並把第二個任務置為隊首任務。執行任務可能產生新的任務,再把新任務插入到任務連結串列
                } while (firstCallbackNode !== null && !shouldYieldToHost());
             }
        }
    }
複製程式碼

上面的shouldYieldToHost代表當前幀過期了,取反的話就是沒過期。每次while都會執行這個判斷。

    shouldYieldToHost = function() {
        // 當前幀的截止時間比當前時間小則為true,代表當前幀過期了
        return frameDeadline <= getCurrentTime();
    };
複製程式碼

下面繼續看flushWork

     function flushWork(didTimeout) {
        if (didTimeout) { //任務過期
           ...
        }else{ //當前幀有富餘時間
           ...
        }
        //最後,如果還有任務的話,再啟動一輪新的任務執行排程
        if (firstCallbackNode !== null) {
          ensureHostCallbackIsScheduled();
        }
        //最最後,如果還有任務且有最高優先順序的任務,就都執行一遍。
        flushImmediateWork();
    }
複製程式碼

本文講的比較簡略,原始碼中有大量flag,用來做防止重入、防禦判斷等,並考慮了任務執行過程中有新的任務不斷加入等場景的邏輯。這一塊需要感興趣的讀者自行去體會了。

2 總結

最後在描述一下整體的任務排程流程

  • 1、任務根據優先順序和加入時的當前時間來確定過期時間
  • 2、任務根據過期時間加入任務連結串列
  • 3、任務連結串列有兩種情況會啟動任務的排程,1是任務連結串列從無到有時,2是任務連結串列加入了新的最高優先順序任務時。
  • 4、任務排程指的是在合適的時機去執行任務,這裡通過requestAnimationFramemessageChannel來模擬
  • 5、requestAnimationFrame回撥在幀首執行,用來計算當前幀的截止時間並開啟遞迴,messageChannel的回撥在幀末執行,根據當前幀的截止時間、當前時間、任務連結串列第一個任務的過期時間來決定當前幀是否執行任務(或是到下一幀執行)
  • 6、如果執行任務,則根據任務是否過期來確定如何執行任務。任務過期的話就會把任務連結串列內過期的任務都執行一遍直到沒有過期任務或者沒有任務;任務沒過期的話,則會在當前幀過期之前儘可能多的執行任務。最後如果還有任務,則回到第5步,放到下一幀再重新走流程。

相關文章