「從 0 實現 React18 系列」Fiber 架構的實現原理
Reconciler 是什麼
Reconciler
是 React 核心邏輯所在的模塊,中文名叫協調器
。
Reconciler 架構介紹
在 React 中,Reconciler
(協調器)是負責管理虛擬 DOM 樹更新的關鍵部分。當組件狀態或屬性發生更改時,Reconciler
的任務是確定如何有效地更新 DOM 來反映這些更改。這個過程通常被稱爲 "協調"(Reconciliation)。
Reconciler
的核心思想是通過將新的虛擬 DOM 樹與舊的虛擬 DOM 樹進行比較,找出需要實際更新的部分,然後最小化實際 DOM 操作的數量。這個過程被稱爲 "diffing
" 算法。
傳統庫與現代框架的工作原理
在傳統的庫(jQuery)工作原理(過程驅動)
在傳統的前端開發中使用的jQuery
庫的工作原理主要是通過一個簡化和統一的 API,使得開發者能夠更容易地操作 DOM、處理事件、創建動畫以及發起 AJAX 請求等。而不是描述 UI 的狀態。所以jQuery
的工作原理是過程驅動的。
現代的前端框架結構與工作原理(狀態驅動)
現代的前端框架結構與工作原理
-
在現代的前端框架中,開發者使用描述 UI 的方法(template 或 JSX)來定義組件和它們之間的關係。
-
運行時核心模塊根據描述的 UI,管理組件的創建、更新和銷燬,處理數據狀態變更,並通過虛擬 DOM 或響應式系統來優化 UI 更新性能。
-
當需要與宿主環境交互時(如操作 DOM、處理事件或發起網絡請求),運行時核心模塊會調用宿主環境 API。前端框架通常會封裝這些 API,提供統一的跨平臺接口。
AOT 預編譯 與 JIT 即時編譯
現代框架都需要 “編譯” 這一步驟,用於:
-
將 “框架中描述的 UI” 轉換爲宿主環境可識別的代碼;
-
代碼轉化,比如將 ts 編譯成 js,實現 polyfill 等;
-
執行一些編譯時優化
“編譯” 可以選擇兩個時機執行:
-
代碼在構建時,被稱爲 AOT(Ahead Of Time,提前編譯或預編譯),宿主環境獲得是編譯後的代碼;
-
代碼在宿主環境執行時,被稱爲 JIT(Just In Time,即時編譯),代碼在宿主環境中編譯並執行;
大部分採用模板語法描述 UI 的前端框架都會進行AOT
優化,例如:Vue3、Angular、Svelte。
其本質原因在於模板語法時固定的,固定意味着 “可分析”,“可分析” 意味着在編譯時可以標記模板語法中的靜態部分(不變的部分)與動態部分(包含自變量、可變的部分)。
但採用 JSX 語法描述 UI 的前端框架很難從 AOT
中受益,因爲 JSX 是 ES 的語法糖,ES 語句的靈活性使其很難進行靜態分析。
拓展 那麼 Template 語法是如何從中受益的呢?
-
解析:將模板字符串解析成抽象語法樹(AST)。AST 是一種樹形結構,用於表示模板中的元素、屬性、文本節點等。
-
優化:遍歷 AST,對其中的靜態內容(如純文本節點、靜態屬性等)進行標記。這些標記在後續的渲染過程中有助於避免不必要的計算和更新,從而提高性能。
-
代碼生成:將優化後的 AST 轉換成可執行的 JavaScript 代碼。這通常包括生成渲染函數(render function)和虛擬 DOM 節點。渲染函數用於創建和更新實際的 DOM 結構。
模板語法由於在構建時已經被編譯成可執行的 JavaScript 代碼,運行時無需再進行解析和編譯,從而減少了性能開銷。
ReactElement 數據結構的不足
迴歸主題,根據前面的學習,我們知道了 JSX 方法執行後會返回一個新的 React 元素(ReactElement)。React 元素是一個輕量級的對象,描述了要渲染的 UI 組件的類型(type)、屬性(props),和子元素(children)等信息。
這裏可以給自己個問題,如果ReactElement
作爲reconciler
核心模塊操作的數據結構,會存在哪些問題:
-
無法表達
ReactElement
節點與另一個ReactElement
節點之間的關係(因爲它只記錄了自身的數據,比如組件的類型、屬性和子元素等),一般把ReactElement
稱爲 React 的數據存儲單元; -
字段有限,不好拓展(比如:無法表達狀態);
從下圖中可以看到,ReactELement
這種數據結構很有限,在節點屬性關聯方面也只有 children,並沒有保存兄弟節點以及父節點之間的關係:
當然在 React 16 版本之前,React 使用的是名爲Stack Reconciler
的舊調和算法。Stack Reconciler 的核心是遞歸遍歷組件樹,把數據保存在遞歸調用棧中。它使用的深層遞歸遍歷方法。
但是使用遞歸遍歷組件樹時,會導致一些問題:
-
阻塞主線程:在 JavaScript 中,遞歸調用可能會阻塞主線程,因爲 JavaScript 是單線程的。如果組件樹很大或者更新很頻繁,遞歸調用可能會導致 UI 變得不流暢,影響用戶體驗。
-
沒有優先級調度:
Stack Reconciler
無法對不同的更新任務進行優先級調度,所有的更新任務都會被視爲相同的優先級。這意味着對於高優先級的任務(如動畫或用戶交互),React 無法優先處理,從而可能導致性能下降。
爲了解決這些問題,React 引入了 Fiber Reconciler
。Fiber Reconciler
使用了一種名爲 "Fiber"
的新數據結構來表示組件樹。
它的特點:
-
介於
ReactElement
與真實 UI 節點之間; -
能夠表達節點之間的關係;
-
方便拓展,不僅作爲數據存儲單元,也能作爲工作單元;
FiberNode 是虛擬 DOM 在 React 中的實現
FiberNode Tree 的數據結構如圖所示:
FiberNode 上有很多屬性,包括和自身相關的屬性 ref,節點之間的關係 return、silbing 還有工作單元上的屬性,比如 pendingProps 等等,後面會詳細介紹。
Fiber 出現的意義
Fiber
最主要的兩層含義:
-
作爲靜態的數據結構來說,每個
Fiber節點
對應一個React element
,保存了該組件的類型(函數組件 / 類組件 / 原生組件...)、對應的 DOM 節點等信息。 -
作爲動態的工作單元來說,每個
Fiber節點
保存了本次更新中該組件改變的狀態、要執行的工作(需要被刪除 / 被插入頁面中 / 被更新...)。
Fiber
的出現也爲 React 帶來了很多意義:
從優化層面來說,Fiber
是一種新的調和算法(reconciliation algorithm)。
-
增量渲染:在早期的 React 版本(Stack Reconciler)中,當有組件更新時,React 會一次性完成整個組件樹的調和過程。這會導致長時間的 JavaScript 執行阻塞,從而影響用戶界面的響應性。Fiber 引入了增量渲染的概念,允許將調和過程分成多個小任務,這些任務可以在瀏覽器的空閒時間內執行。這樣,即使在複雜的應用程序中,React 也能實現更平滑的用戶界面更新。
-
任務調度:Fiber 引入了任務優先級的概念,使得 React 可以根據任務的優先級來調度它們的執行。這意味着較高優先級的任務(如用戶交互事件)可以打斷較低優先級的任務(如數據加載),從而實現更靈活的任務調度。這有助於提高應用程序的響應性和性能。
這兩個概念會在後面的章節詳細講解。
Fiber 是什麼?
Fiber
是 React 的最小的工作單元。在 React 的世界中,一切都可以是組件。在普通的 HTML 頁面上,開發者們可以將多個 DOM 元素整合在一起組成一個組件。
普通的 DOM 元素(HostComponent)可以是組件,普通的文本節點(HostText)也可以是組件。還有通過ReactDom.render
方法創建的根元素(RootElement)也可以是組件,還有經常在 React 中使用的函數組件(FunctionComponent)。
在 React 源碼中,每個FiberNode
都有一個WorkTag
屬性,用於標識當前節點的類型。
// pagkages/react-reconciler/src/ReactWorkTags.ts
export type WorkTag =
| typeof FunctionComponent
| typeof ClassComponent
| typeof HostRoot
| typeof HostComponent
| typeof HostText
| typeof Fragment;
export const FunctionComponent = 0;
export const ClassComponent = 1;
export const HostRoot = 3; // 通過ReactDom.render()產生的根元素
export const HostComponent = 5; // dom元素 比如 <div></div>
export const HostText = 6; // 文本類型 比如:<div>123</div>
export const Fragment = 7; // <Fragment />
ReactWorkTags.ts
文件中定義了所有可能的節點類型,每個類型都應一個 number 類型的值。
這樣做的好處是可以通過比較兩個節點的 WorkTag 屬性來判斷它們是否是同一類型的節點,而不需要通過字符串比較等方式,這樣可以提高比較的效率,也可以減少出錯的可能性。
每一個組件都對應着一個FiberNode
,許多個FiberNode
互相嵌套、關聯就組成了FiberNode Tree
。正如下面表示的FiberNode Tree
和 DOM 樹的關係一樣:
Fiber樹 DOM樹
div#root div#root
| |
<App/> div
| / \
div p a
/ ↖
/ ↖
p ----> <Child/>
|
a
一個 DOM 節點一定對應着一個 FiberNode,但每一個 Fiber 節點缺不一定有對應的 DOM 節點。
因爲 React 支持不同類型的組件,因此每個 FiberNode 並不一定具有對應的 DOM 節點。
-
函數組件:函數組件是一個簡單的函數,它接收屬性(props)並返回 JSX。這個 JSX 可能包含 DOM 節點,但這個 DOM 節點並不是真實的 DOM 節點,而是當 React 渲染組件時,它會將函數組件返回的 JSX 轉換成真實的 DOM 節點。所以函數組件本身並不會直接映射到一個 DOM 節點。
-
Fragment:React Fragment 是一種特殊的組件,用於在不添加額外 DOM 節點的情況下返回多個子元素。當遍歷組件樹時,React 會將 Fragment 的子元素視爲直接子元素,而不會爲 Fragment 本身創建 DOM 節點。
-
還有很多,不一一舉例了。
Fiber 工作單元的結構
Fiber 作爲工作單元,它有很多屬性:
-
Fiber 實例屬性: tag、key、type、stateNode 等
-
與其它節點關係的鏈表屬性:return、child、sibling、index
-
Ref 相關的屬性:ref
-
Fiber 更新相關的屬性:pendingProps、memoizedProps、memoizedState、updateQueue、alternate
-
Fiber Effect:flags、subtreeFlags、deletions
// pagkages/react-reconciler/src/ReactFiber.js
function FiberNode(
tag: WorkTag,
pendingProps: mixed,
key: null | string,
) {
// Instance
this.tag = tag;
this.key = key;
this.type = null; // fiber對應的DOM元素的標籤類型,div、p...
this.stateNode = null; // fiber的實例,類組件場景下,是組件的類,HostComponent場景,是dom元素
this.ref = null; // ref相關
// Fiber 除了有自身實例上的屬性,還需要有表示和其它節點的關係
this.return = null; // 指向父級fiber
this.child = null; // 指向子fiber
this.sibling = null; // 同級兄弟fiber
this.index = 0;
// 作爲工作單元與Fiber更新相關
this.pendingProps = pendingProps; // 剛開始工作階段的 props
this.memoizedProps = null; // 工作結束時確定下來的 props
this.memoizedState = null; // 更新完成後的新 state
this.updateQueue = null; // Fiber產生的更新操作都會放在更新隊列中
// Effects
this.flags = NoFlags; // 比如插入 更改 刪除dom等)初始狀態時表示沒有任何標記(因爲還沒進行fiberNode對比)
this.subtreeFlags = NoFlags; // 子節點副作用標識
this.deletions = null; // 用於存放被刪除的子節點
/*
* 可以看成是workInProgress(或current)樹中的和它一樣的節點,
* 可以通過這個字段是否爲null判斷當前這個fiber處在更新還是創建過程
* */
this.alternate = null; // 用於 current Fiber樹和 workInProgress Fiber樹的切換(如果當時fiberNode樹是current樹,則alternate指向的是workInProgress樹)
}
這裏Fiber節點
的屬性沒有寫完全,可以去 react 源碼裏看,地址在代碼塊首行。
雖然屬性很多,但可以按三層含義將它們分類來看:
作爲架構來說
每個 Fiber 節點有個對應的React element
,多個Fiber節點
是如何連接形成樹呢?靠如下三個屬性:
// 指向父級Fiber節點
this.return = null;
// 指向子Fiber節點
this.child = null;
// 指向右邊第一個兄弟Fiber節點
this.sibling = null;
舉個例子,比如下面的組件結構:
function App() {
return (
<div>
i am
<span>時光屋小豪</span>
<span>fighting</span>
</div>
)
}
對應的FiberNode Tree
結構:
作爲靜態的數據結構
作爲靜態的數據結構,需要保存組件的相關的信息:
// Fiber對應組件的類型 Function/Class/Host...
this.tag = tag;
// key屬性
this.key = key;
// 大部分情況同type,某些情況不同,比如FunctionComponent使用React.memo包裹
this.elementType = null;
// 對於 FunctionComponent,指函數本身,對於ClassComponent,指class,對於HostComponent,指DOM節點tagName
this.type = null;
// Fiber對應的真實DOM節點
this.stateNode = null;
作爲動態的工作單元
作爲動態的工作單元,Fiber 中如下參數保存了本次更新相關的信息,會在後續的更新流程章節中使用到具體屬性時再詳細介紹
// 保存本次更新造成的狀態改變相關信息
this.pendingProps = pendingProps;
this.memoizedProps = null;
this.updateQueue = null;
this.memoizedState = null;
this.dependencies = null;
this.mode = mode;
// 保存本次更新會造成的DOM操作
this.effectTag = NoEffect;
this.nextEffect = null;
this.firstEffect = null;
this.lastEffect = null;
如下兩個字段保存調度優先級相關的信息,會在講解 Scheduler 時介紹
// 調度優先級相關
this.lanes = NoLanes;
this.childLanes = NoLanes;
總結
在本節我們對 Reconciler 的架構有了大概的認知,瞭解了傳統的庫與現代框架的工作原理,也掌握了預編譯和即時編譯的區別,以及它們在現代框架中的應用。
在上一節中,我們實現了JSX
的轉換,知道了React Element
這種數據,但是它也有一定的缺陷,爲了解決這個缺陷,React 引入了Fiber
架構,介紹了Fiber
出現的意義,以及它的結構是什麼樣的,通過FiberNode
組成的FiberNode Tree
的結構。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/1FlP86bLChoWdjZ2cMGoGw