Inside Fiber: 深度解析react新的協調演算法
React是用於建立使用者互動介面的JavaScript庫。它的核心機制是跟蹤元件的狀態並且更新顯示到螢幕上,這個過程被稱為協調(reconciliation)當元件的state或者props發生改變時,我們使用setState方法並且進行檢查,重新渲染UI。
React的文件 提供了對這個機制的講解:React元素,生命週期函式和render方法的作用,以及diff演算法在子元件的應用。由render函式返回的react元素被稱為“virtual DOM”。這個詞早起常被用來解釋react的工作原理,但是這個詞經常引起誤解,並且已經不被react的官方文件所使用。在這篇文章我會繼續用它表示react的元素樹。
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> ] } }
正如你所見,這是一個簡單的元件,返回 button
和 span
兩個子元素。只要一點選按鈕,元件的狀態就會更新,反過來元件德狀態就會更新到 span
元素。
在這步協調演算法中react進行了多個步驟。比如下面是在第一次渲染和狀態更新之間的React高階步驟:
span
在協調演算法階段還有其他活動如生命週期函式和更新refs。所有的這些步驟在Fiber中統一稱為“工作(work)”。這些工作的種類通常取決於React元素的種類。比如:對於類元件,React需要建立一個例項,而函式式元件則不需要。正如你所知,在React中有許多元素型別,如類元件和函式式元件,主機元素(host elements)和 portals 等等。React元件的型別由創造元件時函式第一個單詞決定。這個函式通常是在render函式中建立元素。
在我們開始探索fiber演算法的前,先熟悉一下React內部的資料結構。
2. 從React元素到Fiber節點
Every component in React has a UI representation we can call a view or a template that’s returned from the render method. Here’s the template for our ClickCounter component:
React的每個元件都有一個由render函式返回的我們稱為檢視或者模板的UI。下面是ClickCounter元件的模板:
<button key="1" onClick={this.onClick}>Update counter</button> <span key="2">{this.state.count}</span>
2.1. React元素
當一個模板進入JSX的編譯器後,輸出的是一些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元素。接下來我們就有了屬性的類別,key和props來描述這個元素。這些值來自於你傳給 React.createElement
函式的。請注意React是怎麼把文字內容表現為span和button節點的子元素的。點選事件時怎麼成為button元素的props的。React還有其他屬性像refs,但這些超過了本文章討論的內容。
The React element for ClickCounter doesn’t have any props or a key:
ClickCounter不會擁有任何的props或者key:
{ $$typeof: Symbol(react.element), key: null, props: {}, ref: null, type: ClickCounter }
2.2. Fiber 節點
在協調演算法階段,從render方法中返回的每一個React元素合併成一個fiber節點樹。每一個React元素都有與之對應的fiber節點。跟React元素不同的是,fiber並不是每次render都會重新建立的。fiber就是保持元件狀態和DOM結構的可變的資料結構。
我們先前討論過React根據元素的種類的不同表現不同的活動。在我們的簡單的應用中,對於類元件我們稱ClickCounter為生命週期方法。對於render方法,span元素元件提供了Dom變化的功能。所以每一個React元素轉化成描述需要執行的活動的Fiber節點。fiber結構同時也對追蹤,安排,暫停和遺棄這些功能提供了方便的方法。
當React元素第一次轉化成fiber節點時,React在 createFiberFromTypeAndProps
方法中將元素的資料編譯成fiber。在接下來的更新中,React會重複使用fiber節點,並更新需要更新的元素的對應的fiber節點。React還會根據key轉化節點層級或者刪除在render方法中沒有返回的React元素的對應節點。
因為React的每個fiber都有對應的React元素,同時又有這些元素組成的樹,所以我們也有fiber樹,在我們簡單的應用中它是這樣的:
所有的fiber節點通過由child,sibling和return組成的fiber節點連線的列表。
3. Current and work in progres s trees
在首次渲染之後,React生成一顆表示應用狀態並用於渲染UI的fiber樹。這顆樹通常被稱為 current 。當React開始進行更新工作時會生成一顆稱為 workInProgress 的樹,這顆樹表示將要顯示到螢幕上的狀態。
fibers上展示的所有的工作都是來自於 workInProgress 樹的。當React檢查 current 樹,每個存在的fiber節點都會生成一個替代的節點,這些節點構成 workInProgress 樹這些節點是由render函式返回的React元素生成的。一旦更新和相關的工作都完成了,React將會有另一顆樹準備顯示到螢幕上。當 workInProgress 樹顯示到螢幕上時就變成了 current 樹。
React的核心準則之一就是連續性。React通常是一次性更新DOM的——它不會顯示部分結果。 workInProgress 樹不會在使用者前顯示就如同是草稿一樣,所以React可以先對所有的元件進行檢查更新,然後改變DOM結構。
In the sources you’ll see a lot of functions that take fiber nodes from both the current and workInProgress trees. Here’s the signature of one such function:
在原始碼裡面你可以看到有許多方法會從 current 樹和 workInProgress 樹上拿取fiber節點。下面就是這樣的一個方法:
function updateHostComponent(current, workInProgress, renderExpirationTime) {...}
4. 副作用
我們可以把React元件想象為用來計算state和props並展示UI介面的函式。任何其他的像改變DOM和使用生命週期函式可以被稱作副作用,或者簡單的稱為作用。作用也在 文件 裡被提及:
你可能之前通過網路獲取或者訂閱獲取資料,又或者在React元件裡手動的改變DOM。我們稱之為副作用,因為他們會影響其他的元件並且不能再渲染期間完成。
你可以看到大部分的state和props是如何因為更新產生副作用的。由於使用副作用也是工作的一種,fiber節點也是更重副作用的有效機制。每一個節點可以擁有與他聯絡的副作用,他們被編碼到一個叫做作用標籤(effectTag)的地方。
所以Fiber裡的副作用定義了在更新完成之後例項需要做的工作。對於Dom元素這些工作由增加,更新或者刪除元素組成。對於類元件,則是需要更新refs和發起componentDidMount 和componentDidUpdate 生命週期函式。還有其他型別的fibers聯絡的副作用。
5. 作用列表
React更新過程非常快,React通過採取一些有趣的技術到達這樣的高效能。其中之一就是建立了一個帶effects的線性列表的fiber節點,可以快速迭代。迭代線性列表比樹結構快的多,不需要花費時間在沒有副作用的節點上了。
這個列表的目的是標記具有DOM更新和其他作用相聯絡的節點。這個列表是 finishedWork tree
的子集,並且使用 nextEffect
屬性代替在current和workInProgress 樹種使用的子屬性。
Dan Abramov 對effect樹舉了個例子。他喜歡把effect樹想象為一顆聖誕樹,用聖誕燈把所有的effect節點聯絡起來。讓我們來看下面這張圖,高亮顯示的是需要工作的fiber節點。比如,我們的更新把c2插入到Dom結構中,改變d2和c1的屬性,觸發b2的生命週期的屬性。effect列表會把他們連起來,那麼react後面的處理就可以跳過其他節點了:
你可以看到有作用的節點是如何連在一起的。為了遍歷這些節點,React使用firstEffect來指出這個列表從哪裡開始,就像下面這樣:
6. fiber樹的跟節點
Every React application has one or more DOM elements that act as containers. In our case it’s the div element with the ID container.
每一個React應用都有一個或者多個Dom元素用來作為容器。在我們的例子就是帶有ID屬性的div元素。
const domContainer = document.querySelector('#container'); ReactDOM.render(React.createElement(ClickCounter), domContainer);
React creates a fiber root object for each of those containers. You can access it using the reference to the DOM element:
React為每一個容器都建立了一個fiber根物件。你可以通過這些參考看到這個DOM元素:
const fiberRoot = query('#container')._reactRootContainer._internalRoot
這個fiber跟物件就是React控制fiber樹的參考。它儲存在fiber根物件中的current屬性中。
const hostRootFiberNode = fiberRoot.current
fiber樹開始於一個特別的fiber種類就是HostRoot。它有內部建立並且就像是其他元件的父元素。HostRoot元素節點可以通過stateNode屬性返回FiberRoot:
fiberRoot.current.stateNode === fiberRoot; // true
你可以通過fiber根物件探索fiber樹,或者可以對一個元件的fiber節點像下面那樣操作:
compInstance._reactInternalFiber
7.Fiber節點的結構
Let’s now take a look at the structure of fiber nodes created for the ClickCounter component
讓我們看看有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
和 nextEffect
,讓我們看看其他的屬性的作用:
- stateNode :是一個元件的例項, 與fiber相聯絡的Dom節點或其他React元素種類。
- type : 定義了與fiber相關的函式或種類。對於類元件,他指向建構函式,對於Dom元素它指向HTML標籤。我經常使用它瞭解一個fiber節點相關的元素。
- tag : 定義了fiber的型別。它被用在協調演算法中決定應該去做那個工作單元。就像之前提過的,工作單元的種類由React元素決定。
createFiberFromTypeAndProps
函式映射了React元素對fiber種類。在我們的應用中,ClickCounte的tag屬性是1,這代表是類元件而span元素是5代表了HostComponent(需要改變外觀的元件)。 - updateQueue : 儲存著狀態更新,回撥函式和Dom更新的佇列。
- memoizedState :儲存fiber用來建立輸出的狀態。當進行更新時,它反映了現在在螢幕上渲染的內容。
- memoizedProps :儲存先前渲染時的props。
- pendingProps : 在React元素中已經從最新資料更新的,需要傳遞給子元件和Dom元素的props。
- key : 幫助React識別在一組子元素中哪一個元素被改變,增加和刪除的唯一標記。
你可以在這裡發現一個複雜的節點, 我已經省略了先前已經解釋過的一些屬性。而像 expirationTime
, childExpirationTime
和 mode
與排程有關。
8.General algorithm
React的工作可以分為兩個階段: render 和 commit 。
在第一個render階段,React元件通過setState和React.render方法分辨哪些需要在UI中更新。如果是首次渲染,則React會為每一個從render函式中返回的元素創造一個新的fiber節點。React會對於其他的已經存在的React元素再次使用並更新。這個階段的結果就是生成一顆帶有副作用的fiber節點數。這個階段的描述的effetcs要在接下來的commit階段完成。
我們要明白在render階段的工作可以是非同步的。React可以根據可用的時間來進行一個或多個工作單元,然後停止並儲存已完成的工作單元,進行其他的事件處理。當其他的事件處理完成後再執行剩下的工作單元。不過有時候,可能需要丟棄已經完成的工作單元,從頭開始。這些暫停使得介面可以對使用者的操作進行反應,例如dOM更新。然而,commit階段則是同步的,這是因為這個階段的工作改變了呈現給使用者的介面。
Calling lifecycle methods is one type of work performed by React. Some methods are called during the render phase and others during the commit phase. Here’s the list of lifecycles called when working through the first render phase:
生命週期函式就是React的一種工作型別。一些方法在render階段被使用,另外一些在commit階段被使用。下面是在render階段使用的生命週期函式的列表:
- [不安全]componentWillMount (將被棄用了)
- [不安全]componentWillReceiveProps (將被棄用了)
- getDerivedStateFromProps
- shouldComponentUpdate
- [不安全]componentWillUpdate (將被棄用了)
- render
正如你所見,從16.3版本開始一些以前的生命週期函式在render階段被標記為不安全。它們現在在文件中稱為以前(legacy )的生命週期函式。
你對這其中的原因好奇嘛?
就像我們剛剛學習的所說的,在render階段並不產生像DOM更新這樣的副作用,React可以進行非同步更新(甚至可以多執行緒工作)。然而在上面標記不安全的生命週期函式通常被誤解以及被不合理的使用。開發者通常把有副作用的程式碼放到這些函式中,可能引起新的非同步的渲染的問題。
下面是在commit階段執行的生命週期函式:
- getSnapshotBeforeUpdate
- componentDidMount
- componentDidUpdate
- componentWillUnmount
因為在這些函式在commit階段同步執行,所以他們包含了副作用並且與DOM密切相關。
9. Render 階段
協調演算法總是用renderRoot函式從最開始的HostRott fiber節點開始。然而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準備進行commit了。
There are 4 main functions that are used to traverse the tree and initiate or complete the work:
有四個主要的函式被用到遍歷樹和初始化或者完成工作單元:
- performUnitOfWork
- beginWork
- completeUnitOfWork
- completeWork
來看看下面的動畫來演示在遍歷fiber樹時這些函式是怎麼工作的。我簡化了這些函式在這個demo中的執行過程。每個函式都在一個fiber節點執行,當React你可以看見現在活動中fiber節點的改變。你可以清楚的看見演算法是怎樣從一個分支到另一個分支的。它首先從子元素,然後到父元素。
注意垂直的直線連線表示兄弟元素,橫向的連線代表父子,比如,b1沒有子元素,而被b2有一個子元素c1
Let’s start with the first two functions performUnitOfWork and beginWork:
讓我們先從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執行的活動。為了達到演示的目的,我們簡單的到引出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; }
You can see that the gist of the function is a big while loop. React gets into this function when a workInProgress node has no children. After completing the work for the current fiber, it checks if there’s a sibling. If found, React exits the function and returns the pointer to the sibling. It will be assigned to the nextUnitOfWork variable and React will perform the work for the branch starting with this sibling. It’s important to understand that at this point React has only completed work for the preceding siblings. It hasn’t completed work for the parent node. Only once all branches starting with child nodes are completed does it complete the work for the parent node and backtracks.
你可以看見這個函式其實是一個大的完整的迴圈。
10 .Commit phase
這個階段由completeRoot函式開始。在這個階段React更新Dom和觸發mutation生命週期函式。當進入這個階段, React有兩個數結構和effect列表。第一個樹結構是現在渲染到螢幕上的state。另外一個是在render階段用來替換的是結構。它在原始碼中被稱為finishedWork 或者workInProgress 。
然後說道effects列表——finishedWork tree與nextEffect 指標相關聯的節點的子集。記住effects列表是render階段的成果。整個render階段的意義是決定哪些節點需要插入,更新,或者刪除,哪些元件需要呼叫他們的生命週期函式。這就是effect要告訴我們的。
在commit階段執行的主要函式是commitRoot。下面的基本上就是它的工作:
-在有快照 effect的標籤的節點上呼叫getSnapshotBeforeUpdate 生命週期函式。
- 在有刪除 effect的標籤的節點上呼叫componentWillUnmount 生命週期函式。
- 實現所有的DOM的插入,更新和刪除。
- 把finishedWork 樹設定為當前樹結構。
- 列表專案
- 在有Placement effect的標籤的節點上呼叫componentDidMount 生命週期。
- 在有更新 effect的標籤的節點上呼叫componentDidUpdate 生命週期。
11. DOM 更新
commitAllHostEffects是React進行Dom更新的函式。這個函式通過下面的操作進行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); ... } } }
commitAllLifecycles 是呼叫componentDidUpdate 和componentDidMount生命週期函式的方法。
asdasd