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
方法來改變狀態,而框架本身會去檢查state
或 props
是否已經更改來決定是否重新渲染組件。
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()
方法中返回兩個子元素button
和span
。
單擊button
按鈕時,組件將更新處理程序,進而使span
元素的文本進行更新。
React 在協調(reconciliation) 期間執行各種活動。例如,以下是 React 在我們的ClickCounter
組件中的第一次渲染和狀態更新之後執行的高級操作:
- 更新
ClickCounter
組件中state
的conut
屬性。 - 檢索並比較
ClickCounter
的子組件及其props
。 - 更新
span
元素的props
。
在協調(reconciliation) 期間執行了其他活動,包括調用生命周期方法
或更新refs
。所有這些活動在 Fiber 架構中統稱為 work。 work
類型通常取決於 React 元素的類型。
例如,對於class
組件,React 需要創建實例,而functional
組件則不需要執行此操作。正如我們所了解的,React 中有許多元素類型,例如class
和functional
組件,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 元素。然後我們有描述元素的屬性type
、key
、和props
。這些值取自傳遞給react.createElement
函數的內容。
注意 React 如何將文本內容表示為span
和button
節點的子節點,以及click
處理程序如何成為button
元素的props
的一部分,以及 React 元素上的其他字段,比如ref
字段,超出了本文的範圍。
ClickCounter
的 React 元素沒有任何props
或key
:
{ $$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
節點通過鏈接列表進行連接:child
、sibling
和return
。
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 中的一個組件看作是一個使用state
和props
來計算UI
呈現的函數,任何其他活動,比如改變DOM
或調用生命周期
方法,都應該被認為是一種副作用,或者簡單地說,是一種效果。https://reactjs.org/docs/hooks-overview.html#%EF%B8%8F-effect-hook
中也提到了影響:
你之前可能已經在 React 組件中執行過數據獲取、訂閱或者手動修改過 DOM。我們統一把這些操作稱為「副作用」,或者簡稱為「作用」。(因為它們會影響其他組件,並且在渲染期間無法完成。) 」
您可以看到大多數state
和props
更新將如何導致副作用。由於"作用"
是work
的一種,所以除了更新之外,fiber
節點是跟蹤"作用"的一種方便機制。每個fiber
節點都可以具有與其相關聯的效果。它們在effectTag
字段中編碼。
因此,fiber
中的"作用"
基本上定義了在處理更新後實例需要完成的工作:
- 對於
host
宿主組件(dom元素),包括添加、更新或刪除元素。 - 對於類組件,React 可能需要更新
refs
並調用componentDidMount
和componentDiddUpdate
生命周期方法。 - 還有其他與其他
fiber
相對應的效應。
副作用列表
React 進程更新非常快,為了達到這個性能水平,它使用了一些有趣的技術。其中之一是建立一個具有快速迭代效果的fiber
節點線性列表。迭代線性列表比樹快得多,不需要花時間在沒有副作用的節點上。
此列表的目標是標記具有DOM
更新或與其相關聯的其他作用的節點。此列表是finishedWork
樹的子集,使用nextEfect
屬性而不是current
樹和workInProgress
樹中使用的子屬性進行鏈接。
這裡有一個作用列表的類比,把它想像成一棵聖誕樹,"聖誕燈"把所有有效的節點綁在一起。為了將其可視化,讓我們想像一下下面的fiber
節點樹,其中突出顯示的節點有一些工作要做,例如,我們的更新導致C2
插入到DOM
中,D2
和C1
更改屬性,B2
觸發生命周期方法。效果列表將它們鏈接在一起,以便 React 可以稍後跳過其他節點:
可以看到具有副作用的節點是如何鏈接在一起的。當遍歷節點時,React 使用firstEffect
指針來確定列表的起始位置。所以上面的圖表可以表示為這樣的線性列表:
如您所見,React 按照從子到父的順序應用副作用。
Fiber 的根節點
每個 React 應用程序都有一個或多個充當容器的DOM
元素。在我們的例子中它是帶有id
為container
的div
元素。
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
節點上有很多字段。在前面的我已經描述了字段alternate
、effectTag
和nextEfect
的用途。現在讓我們看看為什麼我們需要其他的字段。
stateNode
保存組件的類實例、DOM
節點或與Fiber
節點關聯的其他 React 元素類型的引用。總的來說,我們可以認為該屬性用於保持與一個Fiber
節點相關聯的局部狀態。
type
定義與此fiber
關聯的函數或類。
對於類組件,它指向構造函數;對於DOM
元素,它指定HTML
標記。(使用這個字段來了解fiber
節點與什麼元素相關。)
tag
定義fiber
的類型,它在reconciliation(協調)
算法中確定需要做什麼工作。
如前所述,工作取決於 React 元素的類型。函數createFiberFromTypeAndProps
將 React 元素映射到相應的fiber
節點類型。在我們的案例中,ClickCounter
組件的tag
是1
,表示classComponent
;而對於span
元素,屬性tag
是5
,表示hostComponent
。
updateQueue
狀態更新、回調和DOM
更新的隊列。
memoizedState
用於創建輸出的fiber
的狀態,處理更新時,它會反映當前在屏幕上呈現的狀態。
memoizedProps
在前一次渲染期間用於創建輸出的fiber
的props
。
pendingProps
已從 React 元素中的新數據更新並且需要應用於子組件或DOM
元素的props
。
key
唯一標識符,當具有一組子元素的時候,可幫助 React 確定哪些項發生了更改、添加或刪除。
在上文中省略了一些字段:特別是數據結構指針
child
、sibling
、return
。以及一類特定於調度器的字段,如expirationTime
、childExpirationTime
和mode
。 」
通用算法
React 在兩個主要階段執行工作:render
和commit
。
在第一個render
階段,React 通過setUpdate
或React.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。
從概念上講,你可以將開始
視為進入
一個組件,並將完成
視為離開
它。
我們首先開始研究performUnitOfWork
和beginWork
這兩個函數:
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; }
函數performUnitOfWork
從workInProgress
樹接收一個Fiber
節點,並通過調用beginWork
函數啟動工作,這個函數將啟動所有Fiber
執行工作所需要的活動。出於演示的目的,我們只log
出Fiber
節點的名稱來表示工作已經完成。函數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 只完成了前面的同層節點的工作。它尚未完成父節點的工作。只有在完成以子節點開始的所有分支後,才能完成父節點和回溯的工作。
從實現中可以看出,performUnitOfWork
和completeUnitOfWork
主要用於迭代目的,而主要活動則在beginWork
和completeWork
函數中進行。
commit 階段
這一階段從函數completeRoot
開始。在這個階段,React 更新DOM
並調用變更生命周期之前及之後方法的地方。
當 React 進入這個階段時,它有2
棵樹和副作用列表。第一個樹表示當前在屏幕上渲染的狀態,然後在render
階段會構建一個備用樹。它在源代碼中稱為finishedWork
或workInProgress
,表示需要映射到屏幕上的狀態。此備用樹會用類似的方法通過child
和sibling
指針鏈接到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
。
最後,文章有點長,希望您您耐心的看完,中秋小長假已經過去了,要收收心儘快進入工作狀態哦。