React Fiber架構原理

xiangzhihong 發表於 2022-06-16
React

一,概述

在 React 16 之前,VirtualDOM 的更新過程是採用 Stack 架構實現的,也就是迴圈遞迴方式。這種對比方式有一個問題,就是一旦任務開始進行就無法中斷,如果應用中元件數量龐大,Virtual DOM 的層級就會比較深。如果主執行緒被長期佔用,就會阻塞渲染,造成卡頓。為了避免這種情況,需要執行更新操作時不能超過16ms,如果超過16ms,就需要先暫停,讓給瀏覽器進行渲染操作,後續再繼續執行更新計算。

而Fiber架構就是為了支援“可中斷渲染”而建立的。在React中,fiber tree是一種資料結構,它可以把虛擬dom tree轉換成一個連結串列,從而可以在執行遍歷操作時支援斷點重啟,示意圖如下。

在這裡插入圖片描述

二、Fiber原理

Fiber 可以理解為是一個執行單元,也可以理解為是一種資料結構。

2.1 一個執行單元

Fiber 可以理解為一個執行單元,每次執行完一個執行單元,react 就會檢查現在還剩多少時間,如果沒有時間則將控制權讓出去。React Fiber 與瀏覽器的核心互動流程如下圖:

在這裡插入圖片描述

可以看到,React 首先向瀏覽器請求排程,瀏覽器在一幀中如果還有空閒時間,會去判斷是否存在待執行任務,不存在就直接將控制權交給瀏覽器;如果存在就會執行對應的任務,執行完成後會判斷是否還有時間,有時間且有待執行任務則會繼續執行下一個任務,否則將控制權交給瀏覽器執行渲染。

所以,我們可以將Fiber 理解為一個執行單元,並且一個執行單元必須是一次完成的,不能出現暫停,並且這個小的執行單元在認為執行完後可以移交控制權給瀏覽器去響應使用者,從而提升渲染的效率。

2.2 一種資料結構

在官方的文件介紹中,Fiber 被解釋為一種資料結構,即我們熟知的連結串列。每個 Virtual DOM 都可以表示為一個 fiber,如下圖所示,每個節點都是一個 fiber。

在這裡插入圖片描述

通常,一個 fiber包括了 child(第一個子節點)、sibling(兄弟節點)、return(父節點)等屬性,React Fiber 機制的實現,就是依賴於上面的資料結構。

2.3 Fiber連結串列結構

Fiber結構是使用的是連結串列,準確的說是單連結串列樹結構,詳見ReactFiber.js原始碼,下面我們就看下Fiber連結串列結構,以便後續更好的理解 Fiber 的遍歷過程。

在這裡插入圖片描述

以上每一個單元都包含了payload(資料)和nextUpdate(指向下一個單元的指標),定義結構如下:

class Update {
  constructor(payload, nextUpdate) {
    this.payload = payload          //payload 資料
    this.nextUpdate = nextUpdate    //指向下一個節點的指標
  }
}

接下來定義一個佇列,把每個單元串聯起來,其中定義了兩個指標:頭指標firstUpdate和尾指標lastUpdate,作用是指向第一個單元和最後一個單元,並加入了baseState屬性儲存React中的state狀態。

class UpdateQueue {
  constructor() {
    this.baseState = null // state
    this.firstUpdate = null // 第一個更新
    this.lastUpdate = null // 最後一個更新
  }
}

接下來定義兩個方法:插入節點單元(enqueueUpdate)、更新佇列(forceUpdate)。插入節點單元時需要考慮是否已經存在節點,如果不存在直接將firstUpdate、lastUpdate指向此節點即可。更新佇列是遍歷這個連結串列,根據payload中的內容去更新state的值

class UpdateQueue {
  //.....
  
  enqueueUpdate(update) {
    // 當前連結串列是空連結串列
    if (!this.firstUpdate) {
      this.firstUpdate = this.lastUpdate = update
    } else {
      // 當前連結串列不為空
      this.lastUpdate.nextUpdate = update
      this.lastUpdate = update
    }
  }
  
  // 獲取state,然後遍歷這個連結串列,進行更新
  forceUpdate() {
    let currentState = this.baseState || {}
    let currentUpdate = this.firstUpdate
    while (currentUpdate) {
      // 判斷是函式還是物件,是函式則需要執行,是物件則直接返回
      let nextState = typeof currentUpdate.payload === 'function' ? currentUpdate.payload(currentState) : currentUpdate.payload
      currentState = { ...currentState, ...nextState }
      currentUpdate = currentUpdate.nextUpdate
    }
    // 更新完成後清空連結串列
    this.firstUpdate = this.lastUpdate = null
    this.baseState = currentState
    return currentState
  }
}

最後,我們寫一個測試的用例:例項化一個佇列,向其中加入很多節點,再更新這個佇列。

let queue = new UpdateQueue()
queue.enqueueUpdate(new Update({ name: 'www' }))
queue.enqueueUpdate(new Update({ age: 10 }))
queue.enqueueUpdate(new Update(state => ({ age: state.age + 1 })))
queue.enqueueUpdate(new Update(state => ({ age: state.age + 1 })))
queue.forceUpdate()
console.log(queue.baseState);       //輸出{ name:'www',age:12 }

2.4 Fiber節點

Fiber 框架的拆分單位是 fiber(fiber tree上的一個節點),實際上就是按虛擬DOM節點拆,我們需要根據虛擬dom去生成 fiber tree。 Fiber節點的資料結構如下:

{
    type: any,   //對於類元件,它指向建構函式;對於DOM元素,它指定HTML tag
    key: null | string,  //唯一識別符號
    stateNode: any,  //儲存對元件的類例項,DOM節點或與fiber節點關聯的其他React元素型別的引用
    child: Fiber | null, //大兒子
    sibling: Fiber | null, //下一個兄弟
    return: Fiber | null, //父節點
    tag: WorkTag, //定義fiber操作的型別, 詳見https://github.com/facebook/react/blob/master/packages/react-reconciler/src/ReactWorkTags.js
    nextEffect: Fiber | null, //指向下一個節點的指標
    updateQueue: mixed, //用於狀態更新,回撥函式,DOM更新的佇列
    memoizedState: any, //用於建立輸出的fiber狀態
    pendingProps: any, //已從React元素中的新資料更新,並且需要應用於子元件或DOM元素的props
    memoizedProps: any, //在前一次渲染期間用於建立輸出的props
    // ……     
}

最終, 所有的fiber 節點通過以下屬性:child,sibling 和 return來構成一個樹連結串列。

在這裡插入圖片描述

其他的屬性還有memoizedState(建立輸出的 fiber 的狀態)、pendingProps(將要改變的 props )、memoizedProps(上次渲染建立輸出的 props )、pendingWorkPriority(定義 fiber 工作優先順序)等等就不在過多的介紹了。

三、Fiber執行流程

Fiber的執行流程總體可以分為渲染和排程兩個階段:render階段和commit 階段。其中,render 階段是可中斷的,需要找出所有節點的變更;而commit 階段是不可中斷的,只會執行所有的變更。

3.1 render階段

此階段的主要任務就是找出所有節點的變更,如節點新增、刪除、屬性變更等,這些變更, React 統稱為副作用,此階段會構建一棵Fiber tree,以虛擬dom節點為維度對任務進行拆分,即一個虛擬Dom節點對應一個任務,最後產出的結果是effect list,從中統計出知道哪些節點需要更新、哪些節點需要增加、哪些節點需要刪除。

3.1.1 遍歷流程

React Fiber首先是將虛擬DOM樹轉化為Fiber tree,因此每個節點都有child、sibling、return屬性,遍歷Fiber tree時採用的是後序遍歷方法,後序遍歷的流程如下:
從頂點開始遍歷;
如果有大兒子,先遍歷大兒子;如果沒有大兒子,則表示遍歷完成;
大兒子: a. 如果有弟弟,則返回弟弟,跳到2 b. 如果沒有弟弟,則返回父節點,並標誌完成父節點遍歷,跳到2 d. 如果沒有父節點則標誌遍歷結束

下面是後序遍歷的示意圖:

在這裡插入圖片描述

3.1.2 收集effect list

收集effect list的具體步驟為:

1,如果當前節點需要更新,則打tag更新當前節點狀態(props, state, context等);
2,為每個子節點建立fiber。如果沒有產生child fiber,則結束該節點,把effect list歸併到return,把此節點的sibling節點作為下一個遍歷節點;否則把child節點作為下一個遍歷節點;
3,如果有剩餘時間,則開始下一個節點,否則等下一次主執行緒空閒再開始下一個節點;
4,如果沒有下一個節點了,進入pendingCommit狀態,此時effect list收集完畢,結束。

收集effect list的遍歷順序示意圖如下:

在這裡插入圖片描述

3.2 commit階段

commit 階段需要將上階段計算出來的需要處理的副作用一次性執行,此階段不能暫停,否則會出現UI更新不連續的現象。此階段需要根據effect list,將所有更新都 commit 到DOM樹上。

3.2.1 根據effect list 更新檢視

此階段,根據一個 fiber 的effect list列表去更新檢視,此次只列舉了新增節點、刪除節點、更新節點的三種操作 :

/**
* 根據一個 fiber 的 effect list 更新檢視
*/
const commitWork = currentFiber => {
  if (!currentFiber) return
  let returnFiber = currentFiber.return
  let returnDOM = returnFiber.stateNode // 父節點元素
  if (currentFiber.effectTag === INSERT) {  // 如果當前fiber的effectTag標識位INSERT,則代表其是需要插入的節點
    returnDOM.appendChild(currentFiber.stateNode)
  } else if (currentFiber.effectTag === DELETE) {  // 如果當前fiber的effectTag標識位DELETE,則代表其是需要刪除的節點
    returnDOM.removeChild(currentFiber.stateNode)
  } else if (currentFiber.effectTag === UPDATE) {  // 如果當前fiber的effectTag標識位UPDATE,則代表其是需要更新的節點
    if (currentFiber.type === ELEMENT_TEXT) {
      if (currentFiber.alternate.props.text !== currentFiber.props.text) {
        currentFiber.stateNode.textContent = currentFiber.props.text
      }
    }
  }
  currentFiber.effectTag = null
}

/**
* 根據一個 fiber 的 effect list 更新檢視
*/
const commitRoot = () => {
  let currentFiber = workInProgressRoot.firstEffect
  while (currentFiber) {
    commitWork(currentFiber)
    currentFiber = currentFiber.nextEffect
  }
  currentRoot = workInProgressRoot // 把當前渲染成功的根fiber賦給currentRoot
  workInProgressRoot = null
}

3.2.2 完成檢視更新

接下來,就是迴圈執行工作,當計算完成每個 fiber 的effect list後,呼叫 commitRoot 完成檢視更新。

const workloop = (deadline) => {
  let shouldYield = false // 是否需要讓出控制權
  while (nextUnitOfWork && !shouldYield) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
    shouldYield = deadline.timeRemaining() < 1 // 如果執行完任務後,剩餘時間小於1ms,則需要讓出控制權給瀏覽器
  }
  if (!nextUnitOfWork && workInProgressRoot) {
    console.log('render階段結束')
    commitRoot() // 沒有下一個任務了,根據effect list結果批量更新檢視
  }
  // 請求瀏覽器進行再次排程
  requestIdleCallback(workloop, { timeout: 1000 })
}

四、總結

相比傳統的Stack架構,Fiber 將工作劃分為多個工作單元,每個工作單元在執行完成後依據剩餘時間決定是否讓出控制權給瀏覽器執行渲染。 並且它設定每個工作單元的優先順序,暫停、重用和中止工作單元。 每個Fiber節點都是fiber tree上的一個節點,通過子、兄弟和返回引用連線,形成一個完整的fiber tree。