React Fiber:深入理解 React reconciliation 算法

  • 2019 年 10 月 4 日
  • 筆記

關鍵詞 | React

作者:Maxim Koretskyi 譯文:Leiy https://indepth.dev/inside-fiber-in-depth-overview-of-the-new-reconciliation-algorithm-in-react/

React 是一個用於構建用戶交互界面的 JavaScript 庫,其核心機制就是跟蹤組件的狀態變化,並將更新的狀態映射到到新的界面。在 React 中,我們將此過程稱之為協調。我們調用setState方法來改變狀態,而框架本身會去檢查stateprops是否已經更改來決定是否重新渲染組件。

React 的官方文檔對協調機制進行了良好的抽象描述:React 元素、生命周期、 render 方法,以及應用於組件子元素的diffing算法綜合起到的作用,就是協調。

render方法返回的不可變的 React 元素通常稱為虛擬 DOM。這個術語有助於早期向人們解釋 React,但它也引起了混亂,並且不再用於 React 文檔。在本文中,我將堅持稱它為 React 元素的樹。

除了 React 元素的樹之外,框架總是在內部維護一個實例來持有狀態(如組件、 DOM 節點等)。從版本 16 開始, React 推出了內部實例樹的新的實現方法,以及被稱之為Fiber的算法。

下文中,我們將結合 ClickCounter 組件展開說明。我們有一個按鈕,點擊它將會使屏幕上渲染的數字加1

代碼如下:

class ClickCounter extends React.Component {      constructor(props) {          super(props);          this.state = {count: 0};          this.handleClick = this.handleClick.bind(this);      }        handleClick() {          this.setState((state) => {              return {count: state.count + 1};          });      }          render() {          return [              <button key="1" onClick={this.handleClick}>Update counter</button>,              <span key="2">{this.state.count}</span>          ]      }  }  

這個組件很簡單,從render() 方法中返回兩個子元素buttonspan

單擊button按鈕時,組件將更新處理程序,進而使span元素的文本進行更新。

React 在協調(reconciliation) 期間執行各種活動。例如,以下是 React 在我們的ClickCounter組件中的第一次渲染和狀態更新之後執行的高級操作:

  • 更新ClickCounter組件中stateconut屬性。
  • 檢索並比較ClickCounter的子組件及其props
  • 更新span元素的props

協調(reconciliation) 期間執行了其他活動,包括調用生命周期方法或更新refs所有這些活動在 Fiber 架構中統稱為 work。 work類型通常取決於 React 元素的類型。

例如,對於class組件,React 需要創建實例,而functional組件則不需要執行此操作。正如我們所了解的,React 中有許多元素類型,例如classfunctional組件,host組件(DOM節點)等。React 元素的類型由createElement函數的第一個參數決定,此函數通常用於創建元素的render方法中。

在我們開始探索活動細節和主要的fiber算法之前,讓我們先熟悉 React 內部使用的數據結構。

React 中的每個組件都有一個UI表示,我們可以稱之為從render方法返回的一個視圖或模板。這是ClickCounter組件的模板:

React Elements

如果模板通過JSX編譯器處理,就會得到一堆 React 元素。這是從React組件的render方法返回的,並不是HTML。由於我們並不需要使用JSX,因此我們的ClickCounter組件的render方法可以像這樣重寫:

class ClickCounter {      ...      render() {          return [              React.createElement(                  'button',                  {                      key: '1',                      onClick: this.onClick                  },                  'Update counter'              ),              React.createElement(                  'span',                  {                      key: '2'                  },                  this.state.count              )          ]      }  }  

render方法中調用的React.createElement會產生兩個如下的數據結構:

[      {          $$typeof: Symbol(react.element),          type: 'button',          key: "1",          props: {              children: 'Update counter',              onClick: () => { ... }          }      },      {          $$typeof: Symbol(react.element),          type: 'span',          key: "2",          props: {              children: 0          }      }  ]  

您可以看到 React 將屬性添加到$$typeof這些對象中,以將它們唯一地標識為React 元素。然後我們有描述元素的屬性typekey、和props。這些值取自傳遞給react.createElement函數的內容。

注意 React 如何將文本內容表示為spanbutton節點的子節點,以及click處理程序如何成為button元素的props的一部分,以及 React 元素上的其他字段,比如ref字段,超出了本文的範圍。

ClickCounter的 React 元素沒有任何propskey

{      $$typeof: Symbol(react.element),      key: null,      props: {},      ref: null,      type: ClickCounter  }  

Fiber nodes

協調(reconciliation) 過程中,render方法返回的每個 React 元素的數據將被合併到Fiber節點樹中,每個 React 元素都有一個對應的Fiber節點。

與 React 元素不同,Fiber不是在每此渲染上都重新創建的,它們是保存組件狀態和DOM的可變數據結構。

我們之前討論過,根據 React 元素的類型,框架需要執行不同的活動。在我們的示例中,對於類組件ClickCounter,它調用生命周期方法方法和render方法,而對於span host 組件(dom節點),它執行DOM修改。因此,每個 React 元素都被轉換成相應類型的Fiber節點,用於描述需要完成的工作。

您可以將Fiber視為一種數據結構,它表示一些要做的工作,或者一個工作單元。Fiber的架構還提供了一種方便的方式來跟蹤、調度、暫停和中止工作。

當react元素第一次轉換為Fiber節點時,React 使用元素中的數據在createFiberFromTypeAndProps函數中創建一個Fiber。在隨後的更新中,React 重用這個Fiber節點,並使用來自相應的 React 元素的數據更新必要的屬性。

如果不再從render方法返回相應的 React 元素,React 可能還需要根據key屬性來移動或刪除層級結構中的節點。

檢查ChildReconciler函數,查看所有活動的列表以及 React 為現有Fiber節點執行的相應函數。 」

因為 React 為每個 React 元素創建一個Fiber,而且我們有一個這些元素組成的樹,所以我們可以得到一個Fiber節點樹。對於我們的示例,如下所示:

所有fiber節點通過鏈接列表進行連接:childsiblingreturn

Current 樹以及 workInProgress 樹

在第一次呈現之後,React 最終得到一個Fiber樹,它反映了用於渲染UI的應用程序的狀態。這棵樹通常被稱為current樹。當 React 開始處理更新時,它會構建一個所謂的workInProgress樹,反映要刷新到屏幕的未來狀態。

所有的工作都是在工作進度workInProgress樹的fibler上進行的。當 React 遍歷當前樹時,它為每個現有的fiber節點創建一個備用節點,該節點構成workInProgress樹。此節點是使用render方法返回的 React 元素中的數據創建的。

一旦處理了更新並完成了所有相關工作,React 將有一個備用樹準備刷新到屏幕上。在屏幕上呈現此工作進度樹後,它將成為current樹。

React 的核心原則之一是一致性。 React總是一次性更新DOM(它不會顯示部分結果)。workInProgress樹用作用戶看不到的"草稿",因此 React 可以先處理所有組件,然後將其更改刷新到屏幕上。

在源代碼中,您將看到許多函數從current樹和workInProgress樹中獲取fiber節點。下面是這樣一個函數:

function updateHostComponent(current, workInProgress, renderExpirationTime) {...}  

每個fiber節點都保存對備用字段中另一棵樹的對應節點的引用。current樹中的節點指向WorkInProgress樹中的節點,反之亦然。

副作用

我們可以把 React 中的一個組件看作是一個使用stateprops來計算UI呈現的函數,任何其他活動,比如改變DOM或調用生命周期方法,都應該被認為是一種副作用,或者簡單地說,是一種效果。https://reactjs.org/docs/hooks-overview.html#%EF%B8%8F-effect-hook中也提到了影響:

你之前可能已經在 React 組件中執行過數據獲取、訂閱或者手動修改過 DOM。我們統一把這些操作稱為「副作用」,或者簡稱為「作用」。(因為它們會影響其他組件,並且在渲染期間無法完成。) 」

您可以看到大多數stateprops更新將如何導致副作用。由於"作用"work的一種,所以除了更新之外,fiber節點是跟蹤"作用"的一種方便機制。每個fiber節點都可以具有與其相關聯的效果。它們在effectTag字段中編碼。

因此,fiber中的"作用"基本上定義了在處理更新後實例需要完成的工作:

  • 對於host宿主組件(dom元素),包括添加、更新或刪除元素。
  • 對於類組件,React 可能需要更新refs並調用componentDidMountcomponentDiddUpdate生命周期方法。
  • 還有其他與其他fiber相對應的效應。

副作用列表

React 進程更新非常快,為了達到這個性能水平,它使用了一些有趣的技術。其中之一是建立一個具有快速迭代效果的fiber節點線性列表。迭代線性列表比樹快得多,不需要花時間在沒有副作用的節點上。

此列表的目標是標記具有DOM更新或與其相關聯的其他作用的節點。此列表是finishedWork樹的子集,使用nextEfect屬性而不是current樹和workInProgress樹中使用的子屬性進行鏈接。

這裡有一個作用列表的類比,把它想像成一棵聖誕樹,"聖誕燈"把所有有效的節點綁在一起。為了將其可視化,讓我們想像一下下面的fiber節點樹,其中突出顯示的節點有一些工作要做,例如,我們的更新導致C2插入到DOM中,D2C1更改屬性,B2觸發生命周期方法。效果列表將它們鏈接在一起,以便 React 可以稍後跳過其他節點:

可以看到具有副作用的節點是如何鏈接在一起的。當遍歷節點時,React 使用firstEffect指針來確定列表的起始位置。所以上面的圖表可以表示為這樣的線性列表:

如您所見,React 按照從子到父的順序應用副作用。

Fiber 的根節點

每個 React 應用程序都有一個或多個充當容器的DOM元素。在我們的例子中它是帶有idcontainerdiv元素。

const domContainer = document.querySelector('#container');  ReactDOM.render(React.createElement(ClickCounter), domContainer);  

React為每個容器創建一個fiber根對象。您可以使用對DOM元素的引用來訪問它:

const fiberRoot = query('#container')._reactRootContainer._internalRoot  

這個Fiber根節點是 React 保存對fibler樹的引用的地方。它存儲在fiber根對象的currrent屬性中:

const hostRootFiberNode = fiberRoot.current  

這個Fiber樹以一個特殊類型的Fiber節點HostRoot 開始。它在內部創建的,並充當最頂層組件的父級。HostRoot節點可通過stateNode屬性返回到FiberRoot

fiberRoot.current.stateNode === fiberRoot; // true  

您可以通過fiber根訪問最頂層的hostRoot fiber節點來瀏覽fiber樹。或者可以從組件實例中獲取單個fiber節點,如下所示:

compInstance._reactInternalFiber  

Fiber 節點結構

現在讓我們看看為ClickCounter組件創建的fiber節點的結構:

{      stateNode: new ClickCounter,      type: ClickCounter,      alternate: null,      key: null,      updateQueue: null,      memoizedState: {count: 0},      pendingProps: {},      memoizedProps: {},      tag: 1,      effectTag: 0,      nextEffect: null  }  

以及span DOM 元素:

{      stateNode: new HTMLSpanElement,      type: "span",      alternate: null,      key: "2",      updateQueue: null,      memoizedState: null,      pendingProps: {children: 0},      memoizedProps: {children: 0},      tag: 5,      effectTag: 0,      nextEffect: null  }  

fiber節點上有很多字段。在前面的我已經描述了字段alternateeffectTagnextEfect的用途。現在讓我們看看為什麼我們需要其他的字段。

stateNode

保存組件的類實例、DOM節點或與Fiber節點關聯的其他 React 元素類型的引用。總的來說,我們可以認為該屬性用於保持與一個Fiber節點相關聯的局部狀態。

type

定義與此fiber關聯的函數或類。

對於類組件,它指向構造函數;對於DOM元素,它指定HTML標記。(使用這個字段來了解fiber節點與什麼元素相關。)

tag

定義fiber的類型,它在reconciliation(協調)算法中確定需要做什麼工作。

如前所述,工作取決於 React 元素的類型。函數createFiberFromTypeAndProps將 React 元素映射到相應的fiber節點類型。在我們的案例中,ClickCounter組件的tag1,表示classComponent;而對於span元素,屬性tag5,表示hostComponent

updateQueue

狀態更新、回調和DOM更新的隊列。

memoizedState

用於創建輸出的fiber的狀態,處理更新時,它會反映當前在屏幕上呈現的狀態。

memoizedProps

在前一次渲染期間用於創建輸出的fiberprops

pendingProps

已從 React 元素中的新數據更新並且需要應用於子組件或DOM元素的props

key

唯一標識符,當具有一組子元素的時候,可幫助 React 確定哪些項發生了更改、添加或刪除。

在上文中省略了一些字段:特別是數據結構指針childsiblingreturn。以及一類特定於調度器的字段,如expirationTimechildExpirationTimemode。 」

通用算法

React 在兩個主要階段執行工作:rendercommit

在第一個render階段,React 通過setUpdateReact.render計劃性的更新組件,並確定需要在UI中更新的內容。

如果是初始渲染,React 會為render方法返回的每個元素創建一個新的Fiber節點。在後續更新中,現有 React 元素的Fiber節點將被重複使用和更新。這一階段是為了得到標記了副作用的Fiber節點樹。

副作用描述了在下一個commit階段需要完成的工作。在當前階段,React 持有標記了副作用的Fiber樹並將其應用於實例。它遍歷副作用列表、執行 DOM更新和用戶可見的其他更改。

我們需要重點理解的是,第一個render階段的工作是可以異步執行的。React 可以根據可用時間片來處理一個或多個Fiber節點,然後停下來暫存已完成的工作,並轉而去處理某些事件,接着它再從它停止的地方繼續執行。但有時候,它可能需要丟棄完成的工作並再次從頂部開始。

由於在此階段執行的工作不會導致任何用戶可見的更改(如 DOM 更新),因此暫停行為才有了意義。

與之相反的是,後續commit階段始終是同步的。這是因為在此階段執行的工作會導致用戶可見的變化,例如DOM更新。這就是為什麼 React 需要在一次單一過程中完成這些更新。

React 要做的一種工作就是調用生命周期方法。一些方法是在render階段調用的,而另一些方法則是在commit階段調用。

這是在第一個render階段調用的生命周期列表:

  • [UNSAFE_] componentWillMount(棄用)
  • [UNSAFE_] componentWillReceiveProps(棄用)
  • getDerivedStateFromProps
  • shouldComponentUpdate
  • [UNSAFE_] componentWillUpdate(棄用)

render

正如你所看到的,從版本 16.3 開始,在render階段執行的一些保留的生命周期方法被標記為UNSAFE,它們現在在文檔中被稱為遺留生命周期。它們將在未來的16.x 發佈版本中棄用,而沒有UNSAFE前綴的方法將在17.0中移除。

那麼這麼做的目的是什麼呢?

好吧,我們剛剛了解到,因為render階段不會產生像DOM更新這樣的副作用,所以 React 可以異步處理組件的異步更新(甚至可能在多個線程中執行)。

但是,標有UNSAFE的生命周期經常被誤解和濫用。開發人員傾向於將帶有副作用的代碼放在這些方法中,這可能會導致新的異步渲染方法出現問題。雖然只有沒有UNSAFE 前綴的對應方法將被刪除,但它們仍可能在即將出現的併發模式(您可以選擇退出)中引起問題。

接下來羅列的生命周期方法是在第二個 commit 階段執行的:

  • getSnapshotBeforeUpdate
  • componentDidMount
  • componentDidUpdate
  • componentWillUnmount

因為這些方法都在同步的commit階段執行,他們可能會包含副作用,並對DOM進行一些操作。

至此,我們已經有了充分的背景知識,下面我們可以看下用來遍歷樹和執行一些工作的通用算法。

Render 階段

協調算法始終使用renderRoot函數從最頂層的HostRoot節點開始。不過,React 會略過已經處理過的Fiber節點,直到找到未完成工作的節點。例如,如果在組件樹中的深層組件中調用setState方法,則 React 將從頂部開始,但會快速跳過各個父項,直到它到達調用了setState方法的組件。

工作循環的主要步驟

所有的Fiber節點都會在工作循環中進行處理。如下是該循環的同步部分的實現:

function workLoop(isYieldy) {    if (!isYieldy) {      while (nextUnitOfWork !== null) {        nextUnitOfWork = performUnitOfWork(nextUnitOfWork);      }    } else {...}  }  

在上面的代碼中,nextUnitOfWork持有workInProgress樹中的Fiber 節點的引用,這個樹有一些工作要做:當 React 遍歷Fiber樹時,它會使用這個變量來知曉是否有任何其他Fiber節點具有未完成的工作。處理過當前Fiber後,變量將持有樹中下一個Fiber節點的引用或null。在這種情況下,React 退出工作循環並準備好提交更改。

遍歷樹、初始化或完成工作主要用到4個函數:

  • performUnitOfWork
  • beginWork
  • completeUnitOfWork
  • completeWork

為了演示他們的使用方法,我們可以看看如下展示的遍歷Fiber樹的動畫。我已經在演示中使用了這些函數的簡化實現。每個函數都需要對一個Fiber節點進行處理,當 React 從樹上下來時,您可以看到當前活動的Fiber節點發生了變化。從GIF中我們可以清楚地看到算法如何從一個分支轉到另一個分支。它首先完成子節點的工作,然後才轉移到父節點進行處理。

注意,垂直方向的連線表示同層關係,而折線連接表示父子關係,例如,b1 沒有子節點,而 b2 有一個子節點 c1。

從概念上講,你可以將開始視為進入一個組件,並將完成視為離開它。

我們首先開始研究performUnitOfWorkbeginWork這兩個函數:

function performUnitOfWork(workInProgress) {      let next = beginWork(workInProgress);      if (next === null) {          next = completeUnitOfWork(workInProgress);      }      return next;  }    function beginWork(workInProgress) {      console.log('work performed for ' + workInProgress.name);      return workInProgress.child;  }  

函數performUnitOfWorkworkInProgress樹接收一個Fiber節點,並通過調用beginWork函數啟動工作,這個函數將啟動所有Fiber執行工作所需要的活動。出於演示的目的,我們只logFiber節點的名稱來表示工作已經完成。函數beginWork始終返回指向要在循環中處理的下一個子節點的指針或null

如果有下一個子節點,它將被賦值給workLoop函數中的變量nextUnitOfWork。但是,如果沒有子節點,React 知道它到達了分支的末尾,因此它可以完成當前節點。一旦節點完成,它將需要為同層的其他節點執行工作,並在完成後回溯到父節點。

這是completeUnitOfWork函數執行的代碼:

function completeUnitOfWork(workInProgress) {      while (true) {          let returnFiber = workInProgress.return;          let siblingFiber = workInProgress.sibling;            nextUnitOfWork = completeWork(workInProgress);            if (siblingFiber !== null) {              // If there is a sibling, return it              // to perform work for this sibling              return siblingFiber;          } else if (returnFiber !== null) {              // If there's no more work in this returnFiber,              // continue the loop to complete the parent.              workInProgress = returnFiber;              continue;          } else {              // We've reached the root.              return null;          }      }  }    function completeWork(workInProgress) {      console.log('work completed for ' + workInProgress.name);      return null;  }  

你可以看到函數的核心就是一個大的while的循環。當workInProgress節點沒有子節點時,React 會進入此函數。完成當前 Fiber 節點的工作後,它就會檢查是否有同層節點。

如果找的到,React 退出該函數並返回指向該同層節點的指針。它將被賦值給 nextUnitOfWork變量,React將從這個節點開始執行分支的工作。

我們需要着重理解的是,在當前節點上,React 只完成了前面的同層節點的工作。它尚未完成父節點的工作。只有在完成以子節點開始的所有分支後,才能完成父節點和回溯的工作。

從實現中可以看出,performUnitOfWorkcompleteUnitOfWork主要用於迭代目的,而主要活動則在beginWorkcompleteWork函數中進行。

commit 階段

這一階段從函數completeRoot開始。在這個階段,React 更新DOM並調用變更生命周期之前及之後方法的地方。

當 React 進入這個階段時,它有2棵樹和副作用列表。第一個樹表示當前在屏幕上渲染的狀態,然後在render階段會構建一個備用樹。它在源代碼中稱為finishedWorkworkInProgress,表示需要映射到屏幕上的狀態。此備用樹會用類似的方法通過childsibling指針鏈接到current樹。

然後,有一個副作用列表(它是finishedWork樹的節點子集),通過nextEffect 指針進行鏈接。需要記住的是,副作用列表是運行render階段的結果。渲染的重點就是確定需要插入、更新或刪除的節點,以及哪些組件需要調用其生命周期方法。這就是副作用列表告訴我們的內容,它頁正是在 commit 階段迭代的節點集合。

出於調試目的,可以通過Fiber根的屬性current訪問current樹。可以通過 current樹中HostFiber節點的alternate屬性訪問finishedWork樹。

commit階段運行的主要函數是commitRoot。它執行如下下操作:

  • 在標記為Snapshot副作用的節點上調用getSnapshotBeforeUpdate生命周期。
  • 在標記為Deletion副作用的節點上調用componentWillUnmount生命周期。
  • 執行所有DOM插入、更新、刪除操作。
  • finishedWork樹設置為current
  • 在標記為Placement副作用的節點上調用componentDidMount生命周期。
  • 在標記為Update副作用的節點上調用componentDidUpdate生命周期。

在調用變更前方法getSnapshotBeforeUpdate之後,React 會在樹中提交所有副作用,這會通過兩波操作來完成。

第一波執行所有 DOM(宿主)插入、更新、刪除和 ref 卸載。然後 React 將finishedWork樹賦值給FiberRoot,將 workInProgress樹標記為current樹。這是在提交階段的第一波之後、第二波之前完成的,因此在componentWillUnmount中前一個樹仍然是current,在componentDidMount/Update期間已完成工作是current

在第二波,React 調用所有其他生命周期方法和引用回調。這些方法單獨傳遞執行,從而保證整個樹中的所有放置、更新和刪除能夠被觸發執行。

以下是運行上述步驟的函數的要點:

function commitRoot(root, finishedWork) {      commitBeforeMutationLifecycles()      commitAllHostEffects();      root.current = finishedWork;      commitAllLifeCycles();  }  

這些子函數中都實現了一個循環,該循環遍歷副作用列表並檢查副作用的類型。當它找到與函數目的相關的副作用時,就會執行。

更新前的生命周期方法

例如,這是在副作用樹上遍歷並檢查節點是否具有Snapshot副作用的代碼:

function commitBeforeMutationLifecycles() {      while (nextEffect !== null) {          const effectTag = nextEffect.effectTag;          if (effectTag & Snapshot) {              const current = nextEffect.alternate;              commitBeforeMutationLifeCycles(current, nextEffect);          }          nextEffect = nextEffect.nextEffect;      }  }  

對於一個類組件,這一副作用意味着會調用getSnapshotBeforeUpdate生命周期方法。

DOM 更新

commitAllHostEffects是 React 執行DOM更新的函數。該函數基本上定義了節點需要完成的操作類型,並執行這些操作:

function commitAllHostEffects() {      switch (primaryEffectTag) {          case Placement: {              commitPlacement(nextEffect);              ...          }          case PlacementAndUpdate: {              commitPlacement(nextEffect);              commitWork(current, nextEffect);              ...          }          case Update: {              commitWork(current, nextEffect);              ...          }          case Deletion: {              commitDeletion(nextEffect);              ...          }      }  }  

有趣的是,React 調用componentWillUnmount方法作為commitDeletion函數中刪除過程的一部分。

更新後的生命周期方法

commitAllLifecycles是 React 調用所有剩餘生命周期方法的函數。在 React 的當前實現中,唯一會調用的變更方法就是componentDidUpdate

最後,文章有點長,希望您您耐心的看完,中秋小長假已經過去了,要收收心儘快進入工作狀態哦。

掃碼關注公眾號