React Fiber 架構淺析

  1. 瀏覽器渲染

爲了更好的理解 React Fiber, 我們先簡單瞭解下渲染器進程的內部工作原理。

參考資料:

  1. 從內部瞭解現代瀏覽器 (3)[1]

  2. 渲染樹構建、佈局及繪製 [2]

1.1 渲染幀

幀 (frame): 動畫過程中,每一幅靜止的畫面叫做幀。

幀率 (frame per second): 即每秒鐘播放的靜止畫面的數量。

幀時長 (frame running time): 每一幅靜止的畫面的停留時間。

丟幀 (dropped frame): 當某一幀時長高於平均幀時長。

1.2 幀生命週期

圖: 簡單描述幀生命週期

簡單描述一幀的生命週期:

1. 一幀開始。

2. 主線程:

- Event Handlers: UI交互輸入的事件回調, 例如input、click、wheel等。

- RAF: 執行requestAnimationFrame回調。

- DOM Tree: 解析HTML, 構建DOM Tree, 當JS對DOM有變更會重新觸發該流程。

- CSS Tree: 構建CSS Tree。至此構建出Render Tree。

- Layout: 所有元素的position、size信息。

- Paint: 像素填充, 例如顏色、文字、邊框等可視部分。

- Composite: 繪製的指令信息傳到合成線程中。

- RequestIdleCallback: 如果此時一幀還有空餘時間, 則執行該回調。

3. 合成線程:

- Raster: 合成線程將信息分塊, 並把每塊發送給光柵線程, 光柵線程創建位圖, 並通知GPU進程刷新這一幀。

4. 一幀結束。

1.3 丟幀實驗

怎麼就丟幀了呢?

對於流暢的動畫,如果一幀處理時間超過 16ms,就能感到頁面的卡頓了。

Demo: https://linjiayu6.github.io/FE-RequestIdleCallback-demo/

Github: RequestIdleCallback 實驗 [3]

當用戶點擊任一按鍵 A,B,C,因爲主線程執行 Event Handlers 任務,動畫因爲瀏覽器不能及時處理下一幀,導致動畫出現卡頓的現象。

// 處理同步任務,並佔用主線程

const bindClick = id =>

element(id).addEventListener('click', Work.onSyncUnit)

// 綁定click事件

bindClick('btnA')

bindClick('btnB')

bindClick('btnC')

var Work = {

// 有1萬個任務

unit: 10000,

// 處理每個任務

onOneUnit: function () { for (var i = 0; i <= 500000; i++) {} },

// 同步處理: 一次處理完所有任務

onSyncUnit: function () {

let _u = 0

while (_u < Work.unit) {

Work.onOneUnit()

_u ++

}

}

}

1.4 解決丟幀

上述,我們發現 JS 運算是佔用渲染的時間的。

在連續動畫中,要做高耗時的操作,如何保證幀平穩呢?

解決丟幀思考如下:

  1. 在一幀空閒時處理, 利用 RequestIdleCallback[4] 處理任務。

window.requestIdleCallback() 方法將在瀏覽器的空閒時段內調用的函數排隊。這使開發者能夠在主事件循環上執行後臺和低優先級工作,而不會影響延遲關鍵事件,如動畫和輸入響應。函數一般會按先進先調用的順序執行,然而,如果回調函數指定了執行超時時間 timeout,則有可能爲了在超時前執行函數而打亂執行順序。

  1. 對高耗時的任務,進行分步驟處理。

  1. Web worker 貌似也可以解決上述問題,這裏不做擴展。

  2. ...

這裏我們利用 RequestIdleCallback[5] 做個實驗咩。

Demo: https://linjiayu6.github.io/FE-RequestIdleCallback-demo/

Github: RequestIdleCallback 實驗 [6]

const bindClick = id =>

element(id).addEventListener('click', Work.onAsyncUnit)

// 綁定click事件

bindClick('btnA')

bindClick('btnB')

bindClick('btnC')

var Work = {

// 有1萬個任務

unit: 10000,

// 處理每個任務

onOneUnit: function () { for (var i = 0; i <= 500000; i++) {} },

// 異步處理

onAsyncUnit: function () {

// 空閒時間 1ms

const FREE_TIME = 1

let _u = 0

function cb(deadline) {

// 當任務還沒有被處理完 & 一幀還有的空閒時間 > 1ms

while (_u < Work.unit && deadline.timeRemaining() > FREE_TIME) {

Work.onOneUnit()

_u ++

}

// 任務幹完, 執行回調

if (_u >= Work.unit) {

// 執行回調

return

}

// 任務沒完成, 繼續等空閒執行

window.requestIdleCallback(cb)

}

window.requestIdleCallback(cb)

}

}

requestIdleCallback 啓發

將一個大任務分割成 N 個小任務,在每一幀有空餘時間情況下,逐步去執行小任務。

2.React15 (-) 架構缺點

React: stack reconciler 實現 [7]

React 算法之深度優先遍歷 [8]

遞歸 Recursion: 利用 調用棧 [9],實現自己調用自己的方法。

最常見的就是 Leetcode: 斐波拉契數列 [10] 、Leetcode: 70. 爬樓梯 [11]。

2.1 概述原因

該情況,類似我們上述# 1.3 丟幀實驗。

2.2 流程和代碼解析

可能需要你有點 深度優先遍歷、遞歸、回溯思想、🌲 等數據結構的知識。

這裏只做流程解析,代碼也爲閹割版,重點是理解思想哈。

某 React 節點如下:

  class A extends React.Component {

    ...



    render() {

      return (

        <div id="app">

          <h1></h1>

          <p><h2></h2></p>

          <h3></h3>

        </div>

      )

    }

 }

圖 DFS + 遞歸遍歷的路徑

下面是 ReactFiberWorkLoop.old.js[12] 閹割版代碼,爲了簡要說明該流程。

// 工作循環同步處理

function workLoopSync() {

  // 有任務

  while (workInProgress !== null) {

    performUnitOfWork(workInProgress);

  }

}



function performUnitOfWork(unitOfWork: Fiber): void {

  // 對該節點 開始工作: return workInProgress.child; 返回的是該節點的孩子

  let next = beginWork(...);



  if (next === null) {

    // 對某Node 完成工作: 回溯向上, 向上找到某節點的兄弟 sibling 或 直到向上爲root代表, 遍歷結束。

    completeUnitOfWork(unitOfWork);

  } else {

    // 從ta 孩子入手, 繼續向下工作

    workInProgress = next;

  }

}



/**

 * siblingFiber: 兄弟節點

 * returnFiber: 父親節點

 */

function completeUnitOfWork(unitOfWork: Fiber): void {

  let completedWork = unitOfWork;



  // 這裏又是一個循環

  do {

    // 1. 判斷任務是否完成, 完成就打個完成的標籤, 沒有完成就拋出異常



    // 2. 如果有兄弟節點, 那麼接下來工作節點是該 xd

    if (completedWork.sibling !== null) {

      workInProgress = siblingFiber;

      return;

    }



    // 3. 否則, 返回父親節點

    completedWork = completedWork.return;

    workInProgress = completedWork;

  } while (completedWork !== null);



  // 最後, 是root節點, 結束

  if (workInProgressRootExitStatus === RootIncomplete) {

    workInProgressRootExitStatus = RootCompleted;

  }

}
  1. 上述總結

因果關係

基於這些原因,React 不得不重構整個框架。

1. React (15ver-) 對創建和更新節點的處理,是通過 遞歸 🌲。

2. 遞歸 , 在未完成對整個🌲 的遍歷前,是不會停止的。

3. 該 任務 一直佔用瀏覽器主線程,導致無 響應優先級更高 的任務。

4. 故,瀏覽器渲染超過臨界時間,從視覺上來看,卡死 🐶。

主動思考

爲了快速響應,防止丟幀,解決思路:


1. 將 任務 分解成 N個小任務;

2. If 一幀裏沒有 優先級更高的任務,則執行自己。

   else 有其他 優先級高的事務, 優先執行其他。

     If 等一幀有 空閒 再執行自己。

     else 下一幀。

我們再回頭看下這個圖,問題即轉換如下:

如何將任務拆分?

如何判斷優先級?

如何判斷一幀空閒時,再執行?

...

Fiber 架構

推薦 👍 https://github.com/7kms/react-illustration-series/tree/v17.0.1

推薦 👍 https://react.iamkasong.com/preparation/oldConstructure.html

下面,不會有大段大段代碼,去講具體的實現。

而是,以因果邏輯,帶你去了解 why,how,when (爲什麼、怎麼做、何時做)。

  1. 抽象問題

上面我們說到了什麼任務、優先級等等,我們通過圖的方式,抽象下問題。

描述:

1. 任務A進入執行區域。

2. 在執行任務A的過程中,更高優先級任務B,請求被執行。

3. 但因爲先來後到嘛,此時任務B因爲無法被執行,而暫時被掛起,只能等待執行。

4. 只有執行完任務A後,纔會執行任務B。

上述流程可類比:  你在喫飯,突然你老闆 給你打電話,你一定要堅持喫完飯,才接你老闆的電話。

(腦補一下老闆的表情😭)

很明顯,這樣處理問題,效率奇低無比。

按照我們在前情總結部分的訴求,將上述圖變成這樣是不是更合理些。

描述:

1. 任務A進入執行區域。

2. 在執行任務A的過程中,更高優先級任務B,請求被執行。

3. 考慮到任務B優先級更高,則將任務A沒有執行完成的部分,Stash暫存。

4. 任務B被執行。當任務B被執行完成後,去執行剩餘沒有完成的任務A。

上述流程可類比:  你在喫飯,突然你老闆給你打電話,即使你沒有喫完飯,也接起了你老闆的電話,後繼續喫飯。(腦補一下老闆的表情😊)

  1. 核心關注

5.1 併發、調度

Concurrency & Scheduler

Concurrency 併發:  有能力優先處理更高優事務,同時對正在執行的中途任務可暫存,待高優完成後,再去執行。

concurrency is the ability of different parts or units of a program[13], algorithm[14], or problem[15] to be executed) out-of-order or at the same time simultaneously partial order[16], without affecting the final outcome.

https://en.wikipedia.org/wiki/Concurrency_(computer_science)

Scheduler 協調調度: 暫存未執行任務,等待時機成熟後,再去安排執行剩下未完成任務。

考慮 所有任務可以被併發執行,就需要有個協調任務的調度算法。

看到這裏,不知道你有沒有發現一個大 bug。

肯定是 Call Stack[17]。

5.2 調用棧、虛擬調用棧幀

調用棧這裏看起來就很不合理。

因爲瀏覽器是利用調用棧來管理函數執行順序的,秉承着先進後出原則,是如何做到某任務都入棧了,但是因爲中途有其他事兒,就被中斷。中斷就不算了,還能中斷後,接着後續再執行。

問題突然間就變成: pause a functioin call (暫停對一個函數的調用)。

巧了,像 generator 和 瀏覽器 debugger 就可以做到中斷函數調用。但考慮到可中斷渲染,並可重回構造。React 自行實現了一套體系叫做 React fiber 架構。

React Fiber 核心: 自行實現 虛擬棧幀。

That's the purpose of React Fiber. Fiber is reimplementation of the stack, specialized for React components. You can think of a single fiber as a virtual stack frame.

https://github.com/acdlite/react-fiber-architecture

看到這裏,是不是覺得 React yyds。ps: 反正看不太懂的都是 yyds。

5.3 React 16 (+) 架構

  1. 數據結構

FiberNode.js[18]

Fiber 的數據結構有三層信息: 實例屬性、構建屬性、工作屬性。

下面以該 demo 代碼爲例:

<div id="linjiayu">123</div>

<script type="text/babel">

    const App = () ={

        const [sum, onSetSum] = React.useState(0)



        return (

            <div id="app 1">

                <h1 id="2-1 h1">標題 h1</h1>

                <ul id="2-2 ul"> 

                    <li id="3-1 li" onClick={() => onSetSum(d => d + 1)}>點擊 h2</li>

                    <li id="3-2 li">{sum}</li>

                </ul>



                <h3 id="2-3 h3">標題 h3</h3>

            </div>

        )

    }



    ReactDOM.render(

        <App />,

        document.getElementById('linjiayu')

    );

</script>

6.1 實例屬性

該 Fiber 的基本信息,例如組件類型等。

6.2 構建屬性

構建屬性 (return、child、sibling),根據上面代碼,我們構建一個 Fiber 樹🌲。

構建流程

和 2.2 流程和代碼解析 部分不同的是:

  1. 分爲同步或異步更新。

  2. 且增加的異步更新 使用該字段 shouldYield 來判斷是否需要中斷。

// performSyncWorkOnRoot會調用該方法

function workLoopSync() {

  while (workInProgress !== null) {

    performUnitOfWork(workInProgress);

  }

}



// performConcurrentWorkOnRoot會調用該方法

function workLoopConcurrent() {

  while (workInProgress !== null && ! shouldYield ()) {

    performUnitOfWork(workInProgress);

  }

}

在一個遞歸循環裏,遞: beginWork()[19], 歸 completeWork()[20]

虛線: 表達構建關係,但未完成狀態。

實線: 已構建關係,並已執行某個狀態。

  • 實線 child 和 sibling 已執行 beginWork()

  • 實線 return 已執行 completeUnitOfWork()

1. 創建fiberNode FiberRootNode 

2. 創建fiberNode rootFiber (即示例中 <div id="linjiayu">)



進入循環工作區域, workInProgress(工作指針指向 rootFiber)

3. 創建fiberNode App 

   beginWork() -> 只有一個子節點 -> workInProgress(工作指針指向App) 

   

4. 創建fiberNode div 

   beginWork() -> 有多個子節點 -> workInProgress(工作指針指向div)

5. 構建孩子們節點

按照5.1 -> 5.2 -> 5.3 順序將每個節點創建。

6. workInProgress (工作指針指向h1)

   beginWork() -> 沒有子節點 -> completeUnitOfWork() -> 有兄弟節點,繼續 ...

6.3 工作屬性

  1. 【數據】數據的變更會導致 UI 層的變更。

  2. 【協調】爲了減少對 DOM 的直接操作,通過 Reconcile 進行 diff 查找,並將需要變更節點,打上標籤,變更路徑保留在 effectList 裏。

  3. 【調度】待變更內容要有 Scheduler 優先級處理。

故,涉及到 diff 等查找操作,是需要有個高效手段來處理前後變化,即雙緩存機制。

有關雙緩存機制、數據更新、diff 算法等,這裏不做過多介紹。

7.Reconciler 和 Scheduler

上面,我們概述了 fiberNode 的數據結構,鏈表結構即可支持隨時隨時中斷的訴求

下面我們簡述下架構中兩個核心模塊:

7.1 Reconciler 運行流程淺析

  1. 【輸入】  當數據初始化或變化,最後會調用schedulerUpdateOnFiber該方法。
// scheduleUpdateOnFiber(fiber, lane, eventTime) 以下爲閹割版代碼

// 同步

if (lane === SyncLane) {

    if ( 

       // Check if we're inside unbatchedUpdates (沒有一次事件回調中觸發多次更新)

      (executionContext & LegacyUnbatchedContext) !== NoContext && 

      // Check if we're not already rendering (是否尚未渲染)

      (executionContext & (RenderContext | CommitContext)) === NoContext) {

      // 不調度, 直接去構造fiber樹

      performSyncWorkOnRoot(root);

   }

}



// 否則,需要調度交給Scheduler後,再去構造fiber樹

ensureRootIsScheduled(root, eventTime);
  1. 【註冊任務】  ensureRootIsScheduled

兩類任務:

scheduleSyncCallback 或 scheduleCallback: 將上述兩類任務封裝到了對應的任務隊列中。

// ensureRootIsScheduled

function ensureRootIsScheduled(root, currentTime) {

    // ....

    

    // 1. 優先級最高,立刻馬上要同步執行

    if (newCallbackPriority === SyncLanePriority) {

      newCallbackNode = scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root));

     // 2. 同步批量更新

    } else if (newCallbackPriority === SyncBatchedLanePriority) {

      newCallbackNode = scheduleCallback(ImmediatePriority$1, performSyncWorkOnRoot.bind(null, root));

    } else {

      // 3. 異步優先級登記

      var schedulerPriorityLevel = lanePriorityToSchedulerPriority(newCallbackPriority);

      newCallbackNode = scheduleCallback(schedulerPriorityLevel, performConcurrentWorkOnRoot.bind(null, root));

    }

    

    // ...

    

    // 更新rootFiber 任務

    root.callbackNode = newCallbackNode;

}

同步任務會放到 syncQueue 隊列,會被立即被執行。

var _queue = syncQueue;



// 執行所有同步任務

runWithPriority(ImmediatePriority, () ={

    for (; i < queue.length; i++) {

    let callback = queue[i];

    do {

        callback = callback(isSync);

    } while (callback !== null);

    }

});

// 清空同步任務

syncQueue = null;

異步處理會調用 scheduler 方法 unstable_scheduleCallback,其實是 requestIdleCallback 替代品,該方法傳入回調任務,和過期時間,來安排任務的執行。

function unstable_scheduleCallback(callback, deprecated_options) {}
  1. 【執行任務回調】

下面 performSyncWorkOnRoot 和 performConcurrentWorkOnRoot 不同的是: 異步執行任務,可隨時中斷渲染 shouldYield()

同步執行構建樹

function performSyncWorkOnRoot(root) {

  // 1. 構建樹

  /*

    renderRootSync 會 調用該方法 workLoopSync

    while (workInProgress !== null) {

      performUnitOfWork(workInProgress);

    }

  */

  renderRootSync(root, lanes)

  

  // 2. 輸出樹 (可看下雙緩存機制)

  finishedWork = root.current.alternate;

}

異步執行構建樹

function performConcurrentWorkOnRoot(root) {

   // 1. 構建樹

   /*

    renderRootConcurrent 會 調用該方法 workLoopConcurrent

    while (workInProgress !== null &&  !shouldYield() ) {

      performUnitOfWork(workInProgress);

    }

  */

   renderRootConcurrent(root, lanes);

   // 2. 輸出樹 (可看下雙緩存機制)

   finishConcurrentRender(root, exitStatus, lanes);

   

   // 3. check 是否還有其他更新, 是否需要發起新調度

   ensureRootIsScheduled(root, now());

    if (root.callbackNode === originalCallbackNode) {

      // 當前執行的任務被中斷,返回個新的,再次渲染。

      return performConcurrentWorkOnRoot.bind(null, root);

    }



    return null;

}
  1. 輸出

將變更內容,輸出至界面。詳細看 commitRoot方法的實現。這裏不做擴展。

  1. 小總結

7.2 Scheduler 運行流程淺析

workloop.js[21]

上面我們說到了同步和異步的任務,異步任務是可以中斷且需要 Scheduler 配合處理。

注意只有異步任務即開啓了併發模式,纔會有時間分片。

workLoop 是 實現時間切片 和 可中斷渲染的核心。也是我們上面說到的虛擬棧幀的能力 

以下爲了說明,簡化流程:

// 併發任務的入口

function workLoopConcurrent() {

  // Perform work until Scheduler asks us to yield

  // 有任務 & 是否需要中斷

  while (workInProgress !== null && !shouldYield() ) {

    performUnitOfWork(workInProgress);

  }

}

const scheduler = {

    // 任務放到隊列裏,等待空閒執行

    taskQueue: [

       {

          // 每個任務是個回調的概念, 且回調任務是可中斷的

          callback: workLoopConcurrent

       }

    ],


    // 判斷: 是否需要中斷, 將控制權交給主進程

    shouldYieldToHost () {

        // 沒有剩餘時間

        if (currentTime >= deadline) {

            // 但需要渲染 和 有更高優任務

            if (needsPaint || scheduling.isInputPending()) {

                return true; // 中斷

            }

            // 是否超過 300ms

            return currentTime >= maxYieldInterval;

        }


        // 還有剩餘時間

        return false;

    },


    // 執行入口可見

    workLoop () {

        // 當前第一個任務

        currentTask = taskQueue[0];

 
        // 每次 currentTask 退出 就是一個時間切切片

        while(currentTask !== null) {

            // 任務沒有過期, 但一幀已經無可用時間 或 需要被中斷, 則讓出主線程

            // 每一次執行均進行超時檢測,做到讓出主線程。

            if (currentTask.expirationTime > currentTime

 && (!hasTimeRemaining || shouldYieldToHost())) {

 break

 }

            // 執行任務

            const callback = currentTask.callback;

            const continuationCallback = callback(didUserCallbackTimeout);

            // 如果該任務後, 還有連續回調

            if (typeof continuationCallback === 'function') {

                // 則保留當前

                currentTask.callback = continuationCallback;

            } else  {

                // 將currentTask移除該隊列

                pop(taskQueue);

            }


            // 更新currentTask

            currentTask = peek(taskQueue);

        }

    },

}

簡而言之:

  1. 有個任務隊列 queue,該隊列存放可中斷的任務。

  2. workLoop對隊列裏取第一個任務 currentTask,進入循環開始執行。

這裏還涉及更多細節,例如:

這裏不做詳細說明。

  1. 小總結

(即任務 (狀態: 運行 / 中斷 / 繼續) Lane 運行策略)

(實際上,scheduler + Lane 調度策略遠比該處理複雜的多😭)

圖: 前後對比 (個人理解, 錯誤請指正)

以上,同學們是不是對 React Fiber 架構有了初步的理解哦~

其他說明

雙緩存機制

參考: 雙緩存 Fiber 樹 [22]

至多有兩棵 Fiber Tree。

分別叫做 current fiber tree 和 workInProgress fiber tree。

即在屏幕上已建立的 fiber tree 和 因爲數據變化重新在內存裏創建的 fiber tree。

他們之間是通過 alternate 屬性 (指針) 建立連接。

簡單的說:

  1. 就是 workInProgress fiber 的創建 是否可複用 current fiber 的節點。後續可再詳看 diff 算法。

  2. workInProgress fiber tree 將確定要變更節點,渲染到屏幕上。

  3. workInProgress fiber tree 晉升爲 current fiber tree。

參考資料

[1]

從內部瞭解現代瀏覽器 (3): https://juejin.cn/post/6844903687383416840

[2]

渲染樹構建、佈局及繪製: https://developers.google.com/web/fundamentals/performance/critical-rendering-path/render-tree-construction

[3]

RequestIdleCallback 實驗: https://github.com/Linjiayu6/FE-RequestIdleCallback-demo

[4]

RequestIdleCallback: https://developer.mozilla.org/zh-CN/docs/Web/API/Window/requestIdleCallback

[5]

RequestIdleCallback: https://developer.mozilla.org/zh-CN/docs/Web/API/Window/requestIdleCallback

[6]

RequestIdleCallback 實驗: https://github.com/Linjiayu6/FE-RequestIdleCallback-demo

[7]

React: stack reconciler 實現: https://zh-hans.reactjs.org/docs/implementation-notes.html

[8]

React 算法之深度優先遍歷: https://juejin.cn/post/6912280245055782920

[9]

調用棧: https://segmentfault.com/a/1190000010360316

[10]

Leetcode: 斐波拉契數列: https://leetcode-cn.com/problems/fei-bo-na-qi-shu-lie-lcof/

[11]

Leetcode: 70. 爬樓梯: https://leetcode-cn.com/problems/climbing-stairs/

[12]

ReactFiberWorkLoop.old.js: https://github.com/facebook/react/blob/v17.0.1/packages/react-reconciler/src/ReactFiberWorkLoop.old.js#L1558

[13]

program: https://en.wikipedia.org/wiki/Computer_program

[14]

algorithm: https://en.wikipedia.org/wiki/Algorithm

[15]

problem: https://en.wikipedia.org/wiki/Problem_solving

[16]

partial order: https://en.wikipedia.org/wiki/Partial_Order

[17]

Call Stack: https://segmentfault.com/a/1190000021456103

[18]

FiberNode.js: https://github.com/facebook/react/blob/1fb18e22ae66fdb1dc127347e169e73948778e5a/packages/react-reconciler/src/ReactFiber.new.js#L117

[19]

beginWork(): https://github.com/facebook/react/blob/970fa122d8188bafa600e9b5214833487fbf1092/packages/react-reconciler/src/ReactFiberBeginWork.new.js#L3058

[20]

completeWork(): https://github.com/facebook/react/blob/970fa122d8188bafa600e9b5214833487fbf1092/packages/react-reconciler/src/ReactFiberCompleteWork.new.js#L652

[21]

workloop.js: https://github.com/facebook/react/blob/v17.0.1/packages/scheduler/src/Scheduler.js#L164

[22]

雙緩存 Fiber 樹: https://react.iamkasong.com/process/doubleBuffer.html#update%E6%97%B6

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/F7Z-x3QNSWRUv0Gn67BxMA