一、前言
本文將會通過一個簡單的例子,結合React原始碼(v 16.4.2)來說明 React 是如何工作的,並且幫助讀者理解 ReactElement、Fiber 之間的關係,以及 Fiber 在各個流程的作用。看完這篇文章有助於幫助你更加容易地讀懂 React 原始碼。初期計劃有以下幾篇文章:
- 首次渲染
- 事件機制
- 更新流程
- 排程機制
二、核心型別解析
在正式進入流程講解之前,先了解一下 React 原始碼內部的核心型別,有助於幫助我們更好地瞭解整個流程。為了讓大家更加容易理解,後續的描述只抽取核心部分,把 ref、context、非同步、排程、異常處理 之類的簡化掉了。
1. ReactElement
我們寫 React 元件的時候,通常會使用JSX
來描述元件。<p></p>
這種寫法經過babel轉換後,會變成以 React.createElement(type, props, children)形式。而我們的例子中,type
會是兩種型別:function
、string
。function
一般是指ReactComponent
的constructor
或者函式式元件的function
,而string
型別的就是HTML
標籤。
這個方法,最終是會返回一個 ReactElement ,他是一個普通的 Object ,不是通過某個 class 例項化而來的,大概看看即可,核心成員如下:
key | type | desc |
---|---|---|
$$typeof | Symbol|Number | 物件型別標識,用於判斷當前Object是否一個某種型別的ReactElement |
type | Function|String|Symbol|Number|Object | 如果當前ReactElement是一個ReactComponent,那這裡將是它對應的Constructor;而普通HTML標籤,一般都是String |
props | Object | ReactElement上的所有屬性,包含children這個特殊屬性 |
2. ReactRoot
當前放在ReactDom.js內部,可以理解為React渲染的入口。我們呼叫ReactDom.render
之後,核心就是建立一個 ReactRoot ,然後呼叫 ReactRoot 例項的render
方法,進入渲染流程的。
key | type | desc |
---|---|---|
render | Function | 渲染入口方法 |
_internalRoot | FiberRoot | 根據當前DOMContainer建立的一個FiberTree的根 |
3. FiberRoot
FiberRoot 是一個 Object ,是後續初始化、更新的核心根物件。核心成員如下:
key | type | desc |
---|---|---|
current | (HostRoot)FiberNode | 指向當前已經完成的Fiber Tree 的Root |
containerInfo | DOMContainer | React的DOM容器,把整個React渲染到這個DOM內部 |
finishedWork | (HostRoot)FiberNode|null | 指向當前已經完成準備工作的Fiber Tree Root |
current、finishedWork,都是一個(HostRoot)FiberNode,到底是為什麼呢?先賣個關子,後面將會講解。
4. FiberNode
在 React 16之後,Fiber Reconciler 就作為 React 的預設排程器,核心資料結構就是由FiberNode組成的 Node Tree 。先參觀下他的核心成員:
key | type | desc |
---|---|---|
例項相關 | --- | --- |
tag | Number | FiberNode的型別,可以在packages/shared/ReactTypeOfWork.js中找到。當前文章 demo 可以看到ClassComponent、HostRoot、HostComponent、HostText這幾種 |
type | Function|String|Symbol|Number|Object | 和ReactElement表現一致 |
stateNode | FiberRoot|DomElement|ReactComponentInstance | FiberNode會通過stateNode繫結一些其他的物件,例如FiberNode對應的Dom、FiberRoot、ReactComponent例項 |
Fiber遍歷流程相關 | ||
return | FiberNode|null | 表示父級 FiberNode |
child | FiberNode|null | 表示第一個子 FiberNode |
sibling | FiberNode|null | 表示緊緊相鄰的下一個兄弟 FiberNode |
alternate | FiberNode|null | Fiber排程演算法採取了雙緩衝池演算法,FiberRoot底下的所有節點,都會在演算法過程中,嘗試建立自己的“映象”,後面將會繼續講解 |
資料相關 | ||
pendingProps | Object | 表示新的props |
memoizedProps | Object | 表示經過所有流程處理後的新props |
memoizedState | Object | 表示經過所有流程處理後的新state |
副作用描述相關 | ||
updateQueue | UpdateQueue | 更新佇列,佇列內放著即將要發生的變更狀態,詳細內容後面再講解 |
effectTag | Number | 16進位制的數字,可以理解為通過一個欄位標識n個動作,如Placement、Update、Deletion、Callback……所以原始碼中看到很多 &= |
firstEffect | FiberNode|null | 與副作用操作遍歷流程相關 當前節點下,第一個需要處理的副作用FiberNode的引用 |
nextEffect | FiberNode|null | 表示下一個將要處理的副作用FiberNode的引用 |
lastEffect | FiberNode|null | 表示最後一個將要處理的副作用FiberNode的引用 |
5. Update
在排程演算法執行過程中,會將需要進行變更的動作以一個Update資料來表示。同一個佇列中的Update,會通過next屬性串聯起來,實際上也就是一個單連結串列。
key | type | desc |
---|---|---|
tag | Number | 當前有0~3,分別是UpdateState、ReplaceState、ForceUpdate、CaptureUpdate |
payload | Function|Object | 表示這個更新對應的資料內容 |
callback | Function | 表示更新後的回撥函式,如果這個回撥有值,就會在UpdateQueue的副作用連結串列中掛在當前Update物件 |
next | Update | UpdateQueue中的Update之間通過next來串聯,表示下一個Update物件 |
6. UpdateQueue
在 FiberNode 節點中表示當前節點更新的副作用(主要是Callback)的集合,下面的結構省略了CapturedUpdate部分
key | type | desc |
---|---|---|
baseState | Object | 表示更新前的基礎狀態 |
firstUpdate | Update | 第一個 Update 物件引用,總體是一條單連結串列 |
lastUpdate | Update | 最後一個 Update 物件引用 |
firstEffect | Update | 第一個包含副作用(Callback)的 Update 物件的引用 |
lastEffect | Update | 最後一個包含副作用(Callback)的 Update 物件的引用 |
三、程式碼樣例
本次流程說明,使用下面的原始碼進行分析
//index.js
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
ReactDOM.render(<App />, document.getElementById('root'));
//App.js
import React, { Component } from 'react';
import './App.css';
class App extends Component {
constructor() {
super();
this.state = {
msg:'init',
};
}
render() {
return (
<div className="App">
<p className="App-intro">
To get started, edit <code>{this.state.msg}</code> and save to reload.
</p>
<button onClick={() => {
this.setState({msg: 'clicked'});
}}>hehe
</button>
</div>
);
}
}
export default App;
複製程式碼
四、渲染排程演算法 - 準備階段
從ReactDom.render
方法開始,正式進入渲染的準備階段。
1. 初始化基本節點
建立 ReactRoot、FiberRoot、(HostRoot)FiberNode,建立他們與 DOMContainer 的關係。
2. 初始化(HostRoot)FiberNode
的UpdateQueue
通過呼叫ReactRoot.render
,然後進入packages/react-reconciler/src/ReactFiberReconciler.js
的updateContainer -> updateContainerAtExpirationTime -> scheduleRootUpdate
一系列方法呼叫,為這次初始化建立一個Update,把<App />
這個 ReactElement 作為 Update 的payload.element
的值,然後把 Update 放到 (HostRoot)FiberNode 的 updateQueue 中。
然後呼叫scheduleWork -> performSyncWork -> performWork -> performWorkOnRoot
,期間主要是提取當前應該進行初始化的 (HostFiber)FiberNode,後續正式進入演算法執行階段。
五、渲染排程演算法 - 執行階段
由於本次是初始化,所以需要呼叫packages/react-reconciler/src/ReactFiberScheduler.js
的renderRoot
方法,生成一棵完整的FiberNode Tree finishedWork
。
1. 生成 (HostRoot)FiberNode 的workInProgress
,即current.alternate
。
在整個演算法過程中,主要做的事情是遍歷 FiberNode 節點。演算法中有兩個角色,一是表示當前節點原始形態的current
節點,另一個是表示基於當前節點進行重新計算的workInProgress/alternate
節點。兩個物件例項是獨立的,相互之前通過alternate
屬性相互引用。物件的很多屬性都是先複製再重建
的。
第一次建立結果示意圖:
這個做法的核心思想是雙緩池技術(double buffering pooling technique)
,因為需要做 diff 的話,起碼是要有兩棵樹進行對比。通過這種方式,可以把樹的總體數量限制在2
,節點、節點屬性都是延遲建立的,最大限度地避免記憶體使用量因演算法過程而不斷增長。後面的更新流程的文章裡,會了解到這個雙緩衝
怎麼玩。
2. 工作執行迴圈
示意程式碼如下:
nextUnitOfWork = createWorkInProgress(
nextRoot.current,
null,
nextRenderExpirationTime,
);
....
while (nextUnitOfWork !== null) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}
複製程式碼
剛剛建立的 FiberNode 被作為nextUnitOfWork
,從此進入工作迴圈。從上面的程式碼可以看出,在是一個典型的遞迴的迴圈寫法。這樣寫成迴圈,一來就是和傳統的遞迴改迴圈寫法一樣,避免呼叫棧不斷堆疊以及呼叫棧溢位等問題;二來在結合其他Scheduler
程式碼的輔助變數,可以實現遍歷隨時終止、隨時恢復的效果。
我們繼續深入performUnitOfWork
函式,可以看到類似的程式碼框架:
const current = workInProgress.alternate;
//...
next = beginWork(current, workInProgress, nextRenderExpirationTime);
//...
if (next === null) {
next = completeUnitOfWork(workInProgress);
}
//...
return next;
複製程式碼
從這裡可以看出,這裡對 workInProgress 節點進行一些處理,然後會通過一定的遍歷規則
返回next
,如果next
不為空,就返回進入下一個performUnitOfWork
,否則就進入completeUnitOfWork
。
3. beginWork
每個工作的物件主要是處理workInProgress
。這裡通過workInProgress.tag
區分出當前 FiberNode 的型別,然後進行對應的更新處理。下面介紹我們例子裡面可以遇到的兩種處理比較複雜的 FiberNode 型別的處理過程,然後再單獨講解裡面比較重要的processUpdateQueue
以及reconcileChildren
過程。
3.1 HostRoot - updateHostRoot
HostRoot,即文中經常講到的 (HostRoot)FiberNode,表示它是一個 HostRoot 型別的 FiberNode ,程式碼中通過FiberRoot.tag
表示。
前面講到,在最開始初始化的時候,(HostRoot)FiberNode 在初始化之後,初始化了他的updateQueue
,裡面放了準備處理的子節點。這裡就做兩個動作:
- 處理更新佇列,得出新的state - processUpdateQueue方法
- 建立或者更新 FiberNode 的
child
,得到下一個工作迴圈的入參(也是FiberNode) - ChildReconciler方法
通過這兩個函式的詳細內容屬於比較通用的部分,將在後面單獨講解。
3.2 ClassComponent - updateClassComponent
ClassComponent,即我們在寫 React 程式碼的時候自己寫的 Component,即例子中的App
。
3.2.1 建立ReactComponent
例項階段
對於尚未初始化的節點,這個方法主要是通過FiberNode.type
這個 ReactComponent Constructor 來建立 ReactComponent 例項並建立與 FiberNode 的關係。
(ClassComponent)FiberNode 與 ReactComponent 的關係示意圖:
初始化後,會進入例項的mount
過程,即把 Component render
之前的週期方法都呼叫完。期間,state
可能會被以下流程修改:
- 呼叫getDerivedStateFromProps
- 呼叫componentWillMount -- deprecated
- 處理因上面的流程產生的Update所呼叫的processUpdateQueue
3.2.2 完成階段 - 建立 child FiberNode
在上面初始化Component例項之後,通過呼叫例項的render
獲取子 ReactElement,然後建立對應的所有子 FiberNode 。最終將workInProgress.child
指向第一個子 FiberNode。
3.4 處理節點的更新佇列 - processUpdateQueue 方法
在解釋流程之前,先回顧一下updateQueue的資料結構:
從上面的結構可以看出,UpdateQueue 是存放整個 Update 單向連結串列的容器。裡面的 baseState 表示更新前的原始 State,而通過遍歷各個 Update 連結串列後,最終會得到一個新的 baseState。
對於單個 Update 的處理,主要是根據Update.tag
來進行區分處理。
- ReplaceState:直接返回這裡的 payload。如果 payload 是函式,則使用它的返回值作為新的 State。
- CaptureUpdate:僅僅是將
workInProgress.effectTag
設定為清空ShouldCapture
標記位,增加DidCapture
標記位。 - UpdateState:如果payload是普通物件,則把他當做新 State。如果 payload 是函式,則把執行函式得到的返回值作為新 State。如果新 State 不為空,則與原來的 State 進行合併,返回一個
新物件
。 - ForceUpdate:僅僅是設定
hasForceUpdate
為 true,返回原始的 State。
整體而言,這個方法要做的事情,就是遍歷這個 UpdateQueue ,然後計算出最後的新 State,然後存到workInProgress.memoizedState
中。
3.5 處理子FiberNode - reconcileChildren 方法
在 workInProgress 節點自身處理完成之後,會通過props.children
或者instance.render方法
獲取子 ReactElement。子 ReactElement 可能是物件
、陣列
、字串
、迭代器
,針對不同的型別進行處理。
- 下面通過 ClassComponent 及其
陣列型別 child
的場景來講解子 FiberNode 的建立、關聯流程(reconcileChildrenArray方法
):
在頁面初始化階段,由於沒有老節點的存在,流程上就略過了位置索引比對、兄弟元素清理等邏輯,所以這個流程相對簡單。
遍歷之前render
方法生成的 ReactElement 陣列,一一對應地生成 FiberNode。FiberNode 有returnFiber
屬性和sibling
屬性,分別指向其父親 FiberNode和緊鄰的下一個兄弟 FiberNode。這個資料結構和後續的遍歷過程相關。
現在,生成的FiberNode Tree 結構如下:
圖中的兩個(HostComponent)FiberNode
就是剛剛生成的子 FiberNode,即原始碼中的<p>...</p>
與<button>...</button>
。這個方法最後返回的,是第一個子 FiberNode,就通過這種方式建立了(ClassComponent)FiberNode.child
與第一個子 FiberNode的關係。
這個時候,再搬出剛剛曾經看過的程式碼:
const current = workInProgress.alternate;
//...
next = beginWork(current, workInProgress, nextRenderExpirationTime);
//...
if (next === null) {
next = completeUnitOfWork(workInProgress);
}
//...
return next;
複製程式碼
意味著剛剛返回的 child 會被當做 next
進入下一個工作迴圈。如此往復,會得到下面這樣的 FiberNode Tree :
生成這棵樹之後,被返回的是左下角的那個 (HostText)FiberNode。而重新進入beginWork
方法後,由於這個 FiberNode 並沒有 child ,根據上面的程式碼邏輯,會進入completeUnitOfWork
方法。
注意:雖然說本例子的 FiberNode Tree 最終形態是這樣子的,但實際上演算法是優先深度遍歷,到葉子節點之後再遍歷緊鄰的兄弟節點。如果兄弟節點有子節點,則會繼續擴充套件下去。
4. completeUnitOfWork
進入這個流程,表明 workInProgress 節點是一個葉子節點,或者它的子節點都已經處理完成了。現在開始要完成這個節點處理的剩餘工作。
4.1 建立DomElement,處理子DomElement 繫結關係
completeWork
方法中,會根據workInProgress.tag
來區分出不同的動作,下面挑選2個比較重要的來進一步分析:
4.1.1 HostText
此前提到過,FiberNode.stateNode
可以用於存放 DomElement Instance。在初始化過程中,stateNode 為 null,所以會通過document.createTextNode
建立一個 Text DomElement,節點內容就是workInProgress.memoizedProps
。最後,通過__reactInternalInstance$[randomKey]
屬性建立與自己的 FiberNode的聯絡。
4.1.2 HostComponent
在本例子中,處理完上面的 HostText 之後,排程演算法會尋找當前節點的 sibling 節點進行處理,所以進入了HostComponent
的處理流程。
由於當前出於初始化流程,所以處理比較簡單,只是根據FiberNode.tag
(當前值是code
)來建立一個 DomElement,即通過document.createElement
來建立節點。然後通過__reactInternalInstance$[randomKey]
屬性建立與自己的 FiberNode的聯絡;通過__reactEventHandlers$[randomKey]
來建立與 props 的聯絡。
完成 DomElement 自身的建立之後,如果有子節點,則會將子節點 append 到當前節點中。現在先略過這個步驟。
後續,通過setInitialProperties
方法對 DomElement 的屬性進行初始化,而<code>
節點的內容、樣式、class
、事件 Handler等等也是這個時候存放進去的。
現在,整個 FiberNode Tree 如下:
經過多次迴圈處理,得出以下的 FiberNode Tree:
之後,回到紅色箭頭指向的 (HostComponent)FiberNode,可以分析一下之前省略掉的子節點處理流程。
在當前 DomElement 建立完畢後,進入appendAllChildren
方法把子節點 append 到當前 DomElement 。由上面的流程可以知道,可以通過 workInProgress.child -> workInProgress.child.sibling -> workInProgress.child.sibling.sibling ....
找到所有子節點,而每個節點的 stateNode 就是對應的 DomElement,所以通過這種方式的遍歷,就可以把所有的 DomElement 掛載到 父 DomElement中。
最終,和 DomElement 相關的 FiberNode 都被處理完,得出下面的FiberNode 全貌:
4.2 將當前節點的 effect 掛在到 returnFiber 的 effect 末尾
在前面講解基礎資料結構的時候描述過,每個 FiberNode 上都有 firstEffect、lastEffect ,指向一個Effect(副作用) FiberNode
連結串列。在處理完當前節點,即將返回父節點的時候,把當前的鏈條掛接到 returnFiber 上。最終,在(HostRoot)FiberNode.firstEffect
上掛載著一條擁有當前 FiberNode Tree 所有副作用的 FiberNode 連結串列。
5. 執行階段結束
經歷完之前的所有流程,最終 (HostRoot)FiberNode 也被處理完成,就把 (HostRoot)FiberNode 返回,最終作為finishedWork
返回到 performWorkOnRoot
,後續進入下一個階段。
六、渲染排程演算法 - 提交階段
所謂提交階段,就是實際執行一些周期函式、Dom 操作的階段。
這裡也是一個連結串列的遍歷,而遍歷的就是之前階段生成的 effect 連結串列。在遍歷之前,由於初始化的時候,由於 (HostRoot)FiberNode.effectTag
為Callback
(初始化回撥)),會先將 finishedWork 放到連結串列尾部。結構如下:
每個部分提交完成之後,都會把遍歷節點重置到finishedWork.firstEffect
。
1. 提交節點裝載( mount )前的操作
當前這個流程處理的只有屬於 ReactComponent 的 getSnapshotBeforeUpdate
方法。
2. 提交端原生節點( Host )的副作用(插入、修改、刪除)
遍歷到某個節點後,會根據節點的 effectTag 決定進行什麼操作,操作包括插入( Placement )
、修改( Update )
、刪除( Deletion )
。
由於當前是首次渲染,所以會進入插入( Placement )流程,其餘流程將在後面的《How React Works(三)更新流程》中講解。
2.1 插入流程( Placement )
要做插入操作,必先找到兩個要素:父親 DomElement ,子 DomElement。
2.1.1 找到相對於當前 FiberNode 最近的父親 DomElement
通過FiberNode.return
不斷往上找,找到最近的(HostComponent)FiberNode、(HostRoot)FiberNode、(HostPortal)FiberNode節點,然後通過(HostComponent)FiberNode.stateNode
、(HostRoot)FiberNode.stateNode.containerInfo
、(HostPortal)FiberNode.stateNode.containerInfo
就可以獲取到對應的 DomElement 例項。
2.1.2 找到相對於當前 FiberNode 最近的所有遊離子 DomElement
實際上,把目標是查詢當前 FiberNode底下所有鄰近的 (HostComponent)FiberNode、(HostText)FiberNode,然後通過 stateNode 屬性就可以獲取到待插入的 子DomElement 。
所謂所有鄰近的
,可以通過這幅圖來理解:
圖中紅框部分FiberNode.stateNode
,就是要被新增到父親 DomElement的 子 DomElement。
遍歷順序,和之前的生成 FiberNode Tree時順序大致相同:
a) 訪問child節點,直至找到 FiberNode.type
為 HostComponent 或者 HostRoot 的節點,獲取到對應的 stateNode ,append 到 父 DomElement中。
b) 尋找兄弟節點,如果有,就訪問兄弟節點,返回 a) 。
c) 如果沒有兄弟節點,則訪問 return 節點,如果 return 不是當前演算法入參的根節點,就返回a)。
d) 如果 return 到根節點,則退出。
3. 改變 workInProgress/alternate/finishedWork 的身份
雖然是短短的一行程式碼,但這個十分重要,所以單獨標記:
root.current = finishedWork;
複製程式碼
這意味著,在 DomElement 副作用處理完畢之後,意味著之前講的緩衝樹
已經完成任務,翻身當主人,成為下次修改過程的current
。再來看一個全貌:
4. 提交裝載、變更後的生命週期呼叫操作
在這個流程中,也是遍歷 effect 連結串列,對於每種型別的節點,會做不同的處理。
4.1 ClassComponent
如果當前節點的 effectTag 有 Update 的標誌位,則需要執行對應例項的生命週期方法。在初始化階段,由於當前的 Component 是第一次渲染,所以應該執行componentDidMount
,其他情況下應該執行componentDidUpdate
。
之前講到,updateQueue 裡面也有 effect 連結串列。裡面存放的就是之前各個 Update 的 callback,通常就來源於setState
的第二個引數,或者是ReactDom.render
的 callback
。在執行完上面的生命週期函式後,就開始遍歷這個 effect 連結串列,把 callback 都執行一次。
4.2 HostRoot
操作和 ClassComponent 處理的第二部分一致。
4.3 HostComponent
這部分主要是處理初次載入的 HostComponent 的獲取焦點問題,如果元件有autoFocus
這個 props ,就會獲取焦點。
七、小結
本文主要講述了ReactDom.render
的內部的工作流程,描述了 React 初次渲染的內在流程:
- 建立基礎物件: ReactRoot、FiberRoot、(HostRoot)FiberNode
- 建立 HostRoot 的映象,通過映象物件來做初始化
- 初始化過程,通過 ReactElement 引導 FiberNode Tree 的建立
- 父子 FiberNode 通過
child
、return
連線 - 兄弟 FiberNode 通過
sibling
連線 - FiberNode Tree 建立過程,深度優先,到底之後建立兄弟節點
- 一旦到達葉子節點,就開始建立 FiberNode 對應的 例項,例如對應的 DomElement 例項、ReactComponent 例項,並將例項通過
FiberNode.stateNode
建立關聯。 - 如果當前建立的是 ReactComponent 例項,則會呼叫呼叫
getDerivedStateFromProps
、componentWillMount
方法 - DomElement 建立之後,如果 FiberNode 子節點中有建立好的 DomElement,就馬上 append 到新建立的 DomElement 中
- 構建完成整個FiberNode Tree 後,對應的 DomElement Tree 也建立好了,後續進入提交過程
- 在建立 DomElement Tree 的過程中,同時會把當前的
副作用
不斷往上傳遞,在提交階段裡面,會找到這種標記,並把剛建立完的 DomElement Tree 裝載到容器 DomElement中 雙緩衝
的兩棵樹 FiberNode Tree 角色互換,原來的 workInProgress 轉正- 執行對應 ReactComponent 的裝載後生命週期方法
componentDidMount
- 其他回撥呼叫、autoFocus 處理
下一篇文章將會描述 React 的事件機制(但據說準備要重構),希望我不會斷耕。
寫完第一篇,React 版本已經到了 16.5.0 ……