引言:
在我學習React原理的時候,一上來看的非常全而細節的書/博客(頭大),或者是看的教你實現一個簡單mini-react(還是一知半解),最終學的痛苦又效果不好。所以,寫了這篇博客,希望能幫助您入門React原理。此外,在我看來,這篇文章幫助你應付面試完全足夠了。
説明:
- 本文章主要圍繞Zachary Lee的 400行實現mini-react 項目進行分析,結合圖文詳細分析,帶你弄懂React原理。
- 這個項目的一些API命名會和React有些出入,我會盡量對齊這些命名概念,同時本項目為了減少代碼量會弱化很多細節,我根據實際React的實現做補充。
- 本文很多圖都出自7kms大佬的 圖解React,對理解React非常有幫助,強烈推薦大家學習。(P.S. 7kms大佬是基於React17進行分析的,有些地方(比如EffectList)和最新的React 18/19是有出入的,所以另外再推薦一本書:卡頌的《React設計原理》)
通過本文你能收穫什麼:
- 理解並實現Zachary Lee的mini-react。
- 更深刻理解React的原理,包括渲染流程、Diff算法、bailout策略和hooks等。
1、React基本概念
a.常用對象:ReactElement,FiberNode,虛擬DOM
1.JSX編譯:
我們都知道React支持JSX語法,類似html標籤的寫法,如下:
<div id="root">
<div>
<h1>hello</h1>
world
</div>
</div>
那麼實際上它會被轉換為JS代碼來創建ReactElement,每一個標籤和文本都可以視為ReactElement。
當我們import React from 'react'時就引入了React.createElement和React.render這些API,然後代碼會被babel編譯如下:
React.render(React.createElement('div', {},
React.createElement('h1', {}, 'hello'),
'world'),
document.getElementById('root'));
P.S.為什麼早期react項目要引入import React from 'react'這句話,就是因為編譯後需要引入React.createElement。
2.介紹ReactElement:
通過React.createElement創建出來的一個普通JS對象就是ReactElement類型(在本項目代碼中使用的VirtualElement,可認為是同一個東西)
// Class Component組件和Function組件組合定義
interface ComponentFunction {
new (props: Record<string, unknown>): Component; //能new出Component實例
(props: Record<string, unknown>): VirtualElement | string; //直接調用返回虛擬DOM VirtualElement
}
type VirtualElementType = ComponentFunction | string;
interface VirtualElementProps {
children?: VirtualElement[];
[propName: string]: unknown;
}
interface VirtualElement {
type: VirtualElementType;
props: VirtualElementProps;
}
// 判斷是否是VirtualElement(即ReactElement)
const isVirtualElement = (e: unknown): e is VirtualElement =>
typeof e === 'object';
// Text elements require special handling.
const createTextElement = (text: string): VirtualElement => ({
type: 'TEXT',
props: {
nodeValue: text,
},
});
// 創建一個VirtualElement(即ReactElement)
const createElement = (
type: VirtualElementType,
props: Record<string, unknown> = {},
...child: (unknown | VirtualElement)[]
): VirtualElement => {
const children = child.map((c) =>
isVirtualElement(c) ? c : createTextElement(String(c)),
);
return {
type,
props: {
...props,
children,
},
};
};
// Component組件定義
//(class MyComp extends Component, 自定義class組件都要繼承這個Component)
abstract class Component {
props: Record<string, unknown>;
abstract state: unknown;
abstract setState: (value: unknown) => void;
abstract render: () => VirtualElement;
constructor(props: Record<string, unknown>) {
this.props = props;
}
// Identify Component.
static REACT_COMPONENT = true;
}
簡單來看,VirtualElement(ReactElement)他包含了
- type:實際React中type指
ClassComponent/FunctionComponent/HostComponent(div/span/a這些原生標籤)/HostText/HostRoot(FiberTree根節點)等,本項目代碼的type做了簡化,並把ClassComponent和FunctionComponent定義在一起了成ComponentFunction,然後按REACT_COMPONENT來區分。 - props:就是使用React時傳入的props,包括
children.
P.S.下文我提到ReactElement,你可以認為就是VirtualElement
3.介紹FiberNode:
// 真實DOM
type FiberNodeDOM = Element | Text | null | undefined;
// 定義FiberNode(Fiber節點)
interface FiberNode<S = any> extends VirtualElement {
alternate: FiberNode<S> | null; //指向當前Fiber節點的舊版本,用於Diff算法比較
dom?: FiberNodeDOM; //指向真實DOM節點
effectTag?: string; //用於標記Fiber節點的副作用,如添加、刪除、更新等,實際react中是flags
child?: FiberNode; //指向第一個孩子Fiber節點
return?: FiberNode; //指向父Fiber節點
sibling?: FiberNode; //指向兄弟Fiber節點
hooks?: { //hooks數組(實際React是hooks鏈表)
state: S;
queue: S[];
}[];
}
- 一定程度上你可以認為ReactElement是虛擬DOM, 也可以認為FiberNode是虛擬DOM。FiberNode是在ReactElement基礎上進一步封裝,補充描述了狀態、dom、副作用標記、節點關係等等。
alternate:我們在下面的雙緩存-離屏FiberTree的概念中進一步説明作用,它指向對應的old FiberNode。effectTag:(實際React是flags)在下面的「渲染流程」會進一步分析,它標記了這個Fiber是否存在副作用要執行。dom:真實DOM。(實際React的FiberNode上有stateNode屬性 👈 與該 Fiber 對應的“實例”或 DOM 節點,本項目代碼這裏簡單用dom替代了。)hooks:用來表示組件的hooks狀態。(實際React中Fiber用memoizedState屬性表示,這個屬性用來是用來保存組件的局部狀態的。memoizedState對於FunctionComponent來説是一個hooks鏈表,對於ClassComponent則是普通對象{})
下表展示了stateNode對應的內容:
Fiber tag |
stateNode 內容 |
示例 |
|---|---|---|
HostComponent |
對應的 DOM 節點 | ` → stateNode 指向 HTMLDivElement` |
ClassComponent |
對應的 類組件實例 | new MyComponent() |
FunctionComponent |
null |
因為函數組件沒有實例 |
HostRoot |
對應的 root 容器實例 | ReactRoot(如 ReactDOM.createRoot(container)) |
HostText |
對應的 文本節點 | TextNode |
下圖展示了ReactElement和FiberTree分別在內存中的樣子:
(圖片來自圖解React)
b.雙緩存-離屏FiberTree
(圖片來自圖解React)
- React中會同時存在兩棵FiberTree,如圖,中間的是內存中的FiberTree,也叫
離屏FiberTree,是通過workInProgress指針(簡稱wip)進行移動來構建的;右邊的是頁面FiberTree,代表實際頁面(表示不會再發生變化,對應實際頁面DOM結構)。 - 為什麼要兩棵呢?
頁面FiberTree代表舊Fibers,離屏FiberTree代表新Fibers,需要根據ReactElement結構來創建新Fibers。創建過程中需要比較(Diff)新舊FiberTree進行「打標籤」來表示需要做哪些dom更新操作。 當我遍歷離屏FiberTree時,通過alternate指針找到舊Fiber,然後對它們的孩子節點進行Diff。 FiberRoot是React應用的輔助節點,用來管理FiberTree。它的current指針指向的那個FiberTree代表頁面FiberTree。==當內存中的FiberTree構建完成後,FiberRoot.current切換到內存的FiberTree,表示新舊頁面切換,完成更新==。HostRootFiber就是FiberTree的根。掛載/更新都是從HostRootFiber開始DFS的。
c.宏觀理解React 運行原理
(圖片來自圖解React)
1.這個workLoop就是一個函數,會被反覆執行的一個函數,在React渲染流程中「render階段」和「commit階段」會做不同的事情。
2.當一次渲染任務開始(由renderRootSync或renderRootConcurrent觸發):
- render階段,從上到下DFS,遞歸時調用
beginWork函數,回溯時調用completeWork。這階段核心工作是創建Fiber節點和打副作用標籤。(副作用的簡單理解:修改實際DOM就是副作用) - commit階段,從上到下DFS,根據Fiber上的副作用標籤(
flags)和父Fiber上的deletions標記進行實際DOM操作:新增/移動、修改和刪除。
3.如何讓workLoop反覆不斷執行呢?,本項目代碼使用了requestIdleCallback(React很早期也是這個)來調用workLoop,當瀏覽器空閒時,就會分配一個時間片給workLoop執行。
4.當requestIdleCallback存在下面的問題:
- 不可預測、觸發頻率太低。頁面在保持高幀率的時候(如動畫、滾動)時,瀏覽器幾乎沒有空閒時間,導致 requestIdleCallback 回調遲遲不能執行。
- 優先級機制太弱。只提供了一個 “空閒時執行” 的概念,沒有優先級控制。
- 這個API的兼容性
由於上述原因,React 自己實現了任務調度的算法(Scheduler):
MessageChannel(微任務方式,能精確控制調用時機);setTimeout(作為後備機制);- 可控的時間切片(每個任務執行 5ms 左右後中斷);
- 多級優先級(Immediate、UserBlocking、Normal、Low、Idle);
workLoop
當引入min-react時( import React from './mini-react';),就開始workLoop了,就開始工作循環了,按調度不斷執行workLoop這個函數
void (function main() {
window.requestIdleCallback(workLoop);
})();
在workLoop內,通過deadline.timeRemaining()判斷剩餘時間來決定是否繼續執行「任務單元」。 一個任務單元(unitOfWork)是一個執行時間比較短的JS任務,一次requestIdleCallback給的空閒時間內一般能執行多個「任務單元」,即使超過時間了也影響不大,因為單個「任務單元」很短。這就是時間分片。
const workLoop: IdleRequestCallback = (deadline) => {
while (nextUnitOfWork && deadline.timeRemaining() > 1) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}
if (!nextUnitOfWork && wipRoot) {
commitRoot();
}
window.requestIdleCallback(workLoop);
};
2、performUnitOfWork對應 「render階段」
「render階段」會從離屏FiberTree的根節點(HostRootFiber)開始向下DFS,當然這種DFS是基於迭代方式的(而不是遞歸),這樣才能做到前面提到的時間分配,一個個執行短的「任務單元」。
這個階段的主要任務就是Diff比較和對Fiber打flags標籤。每次執行performUnitOfWork(wip:FiberNode)就是處理一個Fiber節點(這裏的wip指 離屏FiberTree中的工作指針workInProgress,代表當前要處理的Fiber)一開始wip指向HostRootFiber。
在實際React中,「render」階段的流程可以分成兩種流程來看,一種是初次渲染流程,另一種是更新渲染流程。
a.初次渲染流程
如下面這組圖片所示,此時頁面FiberTree是空的,會根據一步步根據render函數產生的ReactElement來構建離屏FiberTree。
(下圖1,2,3,4 來自 圖解React)
1.向下遞歸beginWork的流程:
- 圖1、此時
wip指向HostRootFiber,執行performUnitOfWork(wip)時會調用updateHostRoot,拿HostRootFiber的children和HostRootFiber.alternate的children進行Diff比較,然後創建內存中的Fiber(App)(HostRootFiber的children只有一個,就是React.render(App, container)時傳入的App)。最後wip指向子Fibers的第一個,即Fiber(App); - 圖2、此時
wip指向Fiber(App),執行App組件的render方法,產生ReactElement('div')(這個就是App的children),執行performUnitOfWork(wip)時會那App的children和App.alternate的children進行Diff比較,然後創建內存中的Fiber(div)。最後wip指向子Fibers的第一個,即Fiber(div); - 圖3、此時
wip指向Fiber(div),執行performUnitOfWork(wip)時拿Fiber(div)的children和Fiber(div).alternate的children進行Diff比較(初次渲染實際上不會比較,更新渲染才會真正的比較),然創建內存中的Fiber(header)和Fiber(Content)。最後wip指向子Fibers的第一個,即Fiber(header);
2.向上回溯completeWork的流程:
- 構建dom節點,掛在Fiber上,同時把子dom節點通過
appendChild關聯上(注意:這個添加dom節點是從下往上的,所以此時回溯構建的真實DOM並沒有真的掛載到Document)
當掛載完成後,再經過「commit階段」後就變成下面這樣,此時原本離屏的FiberTree變成頁面FiberTree,而原來的頁面的FiberTree被清理成一棵空樹。
b.更新渲染流程
React有三種發起主動更新的方式:
Class組件中調用setState.Function組件中調用hook對象暴露出的dispatchAction(就是setState).- 在
container節點上重複調用render
更新渲染和初次掛載的區別:
- 有一個預處理的步驟——
markUpdateLaneFromFiberToRoot(fiber, lane),標記優先級,等進入「render」階段DFS時通過bailout策略可能跳過一些子樹的協調。 - 更新渲染的
beginWork是需要打副作用標籤flags的,completeWork是需要收集子Fiber的flags到父Fiber.subtreeFlags的,這是為了下一個階段「commit階段」準備的,這樣在commit時DFS可以跳過一些子樹。
markUpdateLaneFromFiberToRoot
初次掛載是React.render觸發,直接從根節點向下DFS了。而更新渲染流程中則有所不同,有一個預處理的步驟——markUpdateLaneFromFiberToRoot(fiber, lane),名字記不記無所謂,關鍵理解這一步做了什麼。
如上圖所示,在App組件內發生更新,先執行markUpdateLaneFromFiberToRoot動作:給Fiber(App)設置lanes,然後不斷回溯,父Fiber會收集所有子Fiber的lanes併入childLanes。markUpdateLaneFromFiberToRoot結束後再安排一個調度任務(就是進入workLoop),等待執行。
childLanes在渲染流程的「render階段」的優化起到作用。在命中優化條件情況下,如果Fiber的childLanes不包含了當前更新優先級,將跳過Fiber和它的整個子樹的協調/Diff(這個將在下面的bailout優化策略中介紹)- 注意:
childLanes是掛在old Fiber上的,在比較new FiberTree和old FiberTree後,發現某個Fiber命中優化才會去檢查對應old Fiber的childLanes。
bailout策略
bailout策略 講的是如何在beginWork中比較新舊Fiber的優化問題(命中bailout策略能減少render工作量)。如何命中:
- oldProps全等於newProps(但對於Memo純組件,條件會變寬鬆,只需要淺比較oldProps和newprops)
- legacy context不變(對於新版Context,只要所處的context的value變化就意味着更新,那麼就不會命中bailout)
- 沒有更新操作(哪怕狀態不變,但只要做了更新動作,比如
state:{}->setState({})也不會命中bailout) - FiberNode.type不變
這裏必須貼一下React的源碼幫我們理解這個邏輯:
function updateMemoComponent(wip: FiberNode, renderLane: Lane) {
// bailout四要素
// props淺比較
const current = wip.alternate;
const nextProps = wip.pendingProps;
const Component = wip.type.type;
if (current !== null) {
const prevProps = current.memoizedProps;
// state context
if (!checkScheduledUpdateOrContext(current, renderLane)) {
// 淺比較props
if (shallowEqual(prevProps, nextProps) && current.ref === wip.ref) {
didReceiveUpdate = false;
wip.pendingProps = prevProps;
// 滿足四要素
wip.lanes = current.lanes;
return bailoutOnAlreadyFinishedWork(wip, renderLane);
}
}
}
return updateFunctionComponent(wip, Component, renderLane);
}
當執行bailoutOnAlreadyFinishedWork時,你就會發現childLanes起作用了,childLanes決定了命中bailout後的優化程度。
function bailoutOnAlreadyFinishedWork(wip: FiberNode, renderLane: Lane) {
// 高程度優化,跳過子樹協調
if (!includeSomeLanes(wip.childLanes, renderLane)) {
return null;
}
//低程度優化,複用Fiber,繼續子樹協調
cloneChildFibers(wip);
return wip.child;
}
(圖片來自react性能優化|bailout策略)
- 如果
childLanes數組中包含本次更新優先級,則複用Fiber,繼續子樹的DFS/協調。 - 如果不包含,則跳過子樹DFS/協調。
完整更新流程
遞歸打flags,回溯收集flags
遞歸執行beginWork的順序和「初次渲染」流程一樣,不過不同的是:
- 做新舊Fiber的比較,打
flags - 會遇到
bailout策略,可能跳過子樹協調/Diff。
回溯執行completeWork的順序和「初次渲染」流程也一樣,不同的是:
- 收集子Fiber
flags,併入父Fiber的subtreeFlags
beginWork:
(下圖1,2,3,4 來自 圖解React)
其實這組圖已經很好説明了流程,我就不每一步説明了。重點關注:當wip指向Fiber(Header)時:
<Header>組件是PureComponent,滿足四要素(props淺比較相同,沒有更新,沒有context變化,type沒變),命中bailout- 然後
Fiber(Header)上childLanes沒有包含本次更新優先級,所以高程度優化,直接跳過了子樹的比較,返回的wip就指向了兄弟節點Fiber(button)。
completeWork:
7kms大佬的繪的圖關於completeWork是使用EffectList(React v18已經不用了)收集副作用,下面是7kms大佬的圖:
然後我自己畫了個圖,表示收集subtreeFlags,一些細節就沒畫出來,重點關注subtreeFlags和deletions數組(希望我的畫功沒讓你失望~)
這個subtreeFlags是收集子flags和子subtreeFlags合併得來的,在React中實際是一個二進制的數,但為了理解理論,這裏你可以把它當做是一個數組好了。
- 當
wip指向Fiber(div),遍歷所有子Fiber,收集有subtreeFlags=[Placement, Deletion],繼續冒泡。 - 當
wip指向Fiber(App),遍歷所有子Fiber,因為Fiber(App),Fiber(button)沒有flags和subtreeFlags,只有Fiber(div)有subtreeFlags,故收集有subtreeFlags=[Placement, Deletion],繼續冒泡。 - 當
wip指向Fiber(HostRootFiber),收集有subtreeFlags=[Placement, Deletion]。
結合項目代碼分析
項目代碼的performUnitOfWork比較簡化,沒有明顯區分初次掛載時和更新時兩個流程,都按更新流程來寫的,同時省略了completeWork回溯時該做的事(這個不影響功能,回溯收集subtreeFlags是為了跳過一些子Fiber的Diff,用於優化)。
// 執行「任務單元」,處理fiberNode(React中會把wip傳給fiberNode)
// 比較wip的props和oldFiberNode的props,記錄差異到effectTag
const performUnitOfWork = (fiberNode: FiberNode): FiberNode | null => {
const { type } = fiberNode;
switch (typeof type) {
// 1.處理函數組件和類組件
case 'function': {
wipFiber = fiberNode;
wipFiber.hooks = [];
hookIndex = 0;
let children: ReturnType<ComponentFunction>;
// 區分函數組件和類組件(實際React中是分成ClassComponent和FunctionComponent種類型)
// 這裏通過REACT_COMPONENT(Component上的靜態變量來區分)
if (Object.getPrototypeOf(type).REACT_COMPONENT) {
const C = type;
const component = new C(fiberNode.props);
const [state, setState] = useState(component.state);
component.props = fiberNode.props;
component.state = state;
component.setState = setState;
children = component.render.bind(component)(); //對於類組件,調用render方法獲取children
} else {
children = type(fiberNode.props); //對於函數組件,直接調用函數組件並傳入props獲取children
}
reconcileChildren(fiberNode, [
isVirtualElement(children)
? children
: createTextElement(String(children)),
]);
break;
}
// 2.處理文本節點和Fragment
case 'number':
case 'string':
if (!fiberNode.dom) {
fiberNode.dom = createDOM(fiberNode);
}
reconcileChildren(fiberNode, fiberNode.props.children);
break;
case 'symbol':
if (type === Fragment) {
reconcileChildren(fiberNode, fiberNode.props.children);
}
break;
default:
if (typeof fiberNode.props !== 'undefined') {
reconcileChildren(fiberNode, fiberNode.props.children);
}
break;
}
// 處理完成當前節點(wip)後,返回下一個要處理的節點(nextUnitOfWork)
// 找下一個待處理節點nextUnitOfWork,遵循DFS的順序
if (fiberNode.child) {
return fiberNode.child;
}
let nextFiberNode: FiberNode | undefined = fiberNode;
while (typeof nextFiberNode !== 'undefined') {
if (nextFiberNode.sibling) {
return nextFiberNode.sibling;
}
nextFiberNode = nextFiberNode.return;
}
return null; //null表示節點處理完畢
};
render階段又叫reconcile階段,原因就是因為這個階段的核心在於reconcileChildren函數,即Diff過程。下面就開始介紹React的Diff算法。
c.調和/Diff算法
Diff算法原理介紹
在React中Fiber節點的比較只做同層級的比較,按FiberTree從上到下的順序一級級比較。分為單節點和多節點比較。
- 單節點比較(ReactElement序列只有1個或0個)
- 多節點比較(ReactElement序列大於1個,構成數組)
單節點比較
多節點比較
初始的(新Fibers)ReactElement序列和 oldFibers序列如下:
第一次循環先遍歷公共序列,即新舊Fiber是一一對應,key和type相同,一旦key或type不同就中斷循環。前面這段公共序列的oldFibers是可以複用的(即複用dom)
第二次循環從第一次斷開的地方開始。
1) 先設置一個lastPlaceIndex的索引,為什麼叫lastPlaceIndex,因為它和最終的dom移動(是否打上Placement副作用標籤相關)。 初始設置lastPlaceIndex=0 。
2) 把oldFibers剩餘的節點放入Map,方便後續通過key找oldFiber。即Fiber(C),Fiber(D),Fiber(E)會被放入Map。
3) 遍歷ReactElement序列的剩餘序列
- 如果用當前ReactElement key能在Map中找到oldFiber,就複用Fiber(複用dom),oldFiber從Map中移除。
- 複用後,還要比較
lastPlaceIndex和oldFiber的index,如果index比lastPlaceIndex大or相等則只需要更新lastPlaceIndex=index,否則僅標記該新Fiberflags=Placement,lastPlaceIndex不動。 - 如果用當前ReactElement找不到oldFiber,則標記
flags=Placement(此時表示的是新增)
關於3)的第2點,以下圖為例説明: key=e找到Fiber(E)(oldFiber E的索引為4),此時lastPlaceIndex是0,只需更新lastPlaceIndex=4;: key=c找到Fiber(C)(oldFiber C的索引為2),此時對新Fiber C標記Placement(表示移動)lastPlaceIndex不變。
關於3)的第3點,就是 key=x和key=y找不到oldFiber,然後標記為Placement(表示新增)。
Diff算法思維導圖
結合項目代碼分析
這個項目代碼,沒有考慮key的設計(簡化了),主要考慮type的比較,也沒有區分開「單節點比較」和「多節點比較」,僅僅是簡單進行了「多節點比較」的粗略版比較。我們來一起看看吧,重點關注其中是如何打副作用標籤的(項目裏是effectTag字段,實際React中是flags字段)。
const reconcileChildren = (
fiberNode: FiberNode,
elements: VirtualElement[] = [],
) => {
let index = 0;
let oldFiberNode: FiberNode | undefined = void 0;
let prevSibling: FiberNode | undefined = void 0;
const virtualElements = elements.flat(Infinity);
//這裏的fiberNode是內存中FiberTree的「父Fiber」 alternate指針指向它對應的old FiberNode(即離屏FiberTree上的)
if (fiberNode.alternate?.child) {
oldFiberNode = fiberNode.alternate.child; // oldFiberNode表示了oldFibers序列
}
while (
index < virtualElements.length ||
typeof oldFiberNode !== 'undefined'
) {
// ReactElement通過index自增移動來獲取,而oldFiberNode通過sibling移動來獲取
const virtualElement = virtualElements[index];
let newFiber: FiberNode | undefined = void 0;
const isSameType = Boolean(
oldFiberNode &&
virtualElement &&
oldFiberNode.type === virtualElement.type,
);
// 1.type相同,標記為UPDATE(複用真實dom,僅修改dom的屬性)
if (isSameType && oldFiberNode) {
newFiber = {
type: oldFiberNode.type,
dom: oldFiberNode.dom,
alternate: oldFiberNode,
props: virtualElement.props,
return: fiberNode,
effectTag: 'UPDATE',
};
}
// 2.type不同並且有新的ReactElement,標記為REPLACEMENT,表示新增或移動dom
// 其實這裏可分為兩種情況:1)oldFiberNode不存在——新增,2)oldFiberNode存在但type不同——移動
if (!isSameType && Boolean(virtualElement)) {
newFiber = {
type: virtualElement.type,
dom: null,
alternate: null,
props: virtualElement.props,
return: fiberNode,
effectTag: 'REPLACEMENT',
};
}
// 3.type不同並且oldFiberNode存在(隱藏條件:ReactElement不存在),父FiberNode標記為DELETION,表示刪除dom
// 除了標記為DELETION,還會把oldFiber放到deletions數組中,用於後續commitRoot時刪除dom (實際React中這個deletions是掛在父fiberNode上的)
if (!isSameType && oldFiberNode) {
deletions.push(oldFiberNode);
}
if (oldFiberNode) { // oldFiberNode通過sibling移動來獲取
oldFiberNode = oldFiberNode.sibling;
}
// 構建新的fiber樹(即內存中的Fiber Tree)
if (index === 0) {
fiberNode.child = newFiber;
} else if (typeof prevSibling !== 'undefined') {
prevSibling.sibling = newFiber;
}
prevSibling = newFiber;
index += 1; // ReactElement通過index自增移動來獲取
}
};
3、commitRoot對應 「commit階段」
1.籠統的説,「commit階段」要做兩件事:
- 負責DOM節點的插入/移動、更新屬性和刪除,注意這裏説的是DOM,是對真實DOM操作。
- 執行副作用,
useEffect/useLayoutEffect的 destory和create函數。
2.「commit階段」的流程如下圖所示
(圖來自《React設計原理》)
這個“判斷是否有副作用”指判斷subtreeFlags是否有標記(不是noFlags)。有副作用標記,則DFS時對於每個Fiber節點,需要執行BeforeMutation、Mutation和Layout階段。
BeforeMutation:執行ClassComponent的getSnapshotBeforeUpdate方法,異步調用flushPassiveEffects(和useEffect有關).Mutation:插入/移動、更新屬性或刪除DOM。Layout:執行componentDidMount/Update或useLayoutEffect鈎子。
3.等上述的同步代碼執行完成後,我們看到的頁面就更新了! 到這裏我相信你也理解了為什麼 useLayout能獲取到更新的dom並且能在頁面繪製前操作dom了。
- 這裏有個有意思的問題:大家普遍的理解是react(v18版本後)的setState是異步的。那麼為什麼
useLayoutEffect中setState可以在頁面繪製前完成狀態更新呢? - 解釋:如果 layout effect 裏有
setState,React 立即標記這是一個 同步更新(SyncLane);並且立刻重新 render + commit;
useLayoutEffect(() => {
setXxState(); // 引起的更新優先級是同步的,優先級最高,更新在頁面繪製前執行。
}, []);
其實這裏會涉及到一個經典面試題“setState是同步還是異步?”,這個問題留到下一篇博客(React八股和場景題)討論吧,歡迎大家關注和訂閲專欄(😏)。
4.這裏你是不是好奇useEffect的執行時機又是怎麼樣的呢? 別急,後面的「hooks原理」小節會講到這個問題。
5.補充:
Fiber早期架構(v16)中還沒有subtreeFlags,使用的是Effects List,如下圖,HostRootFiber中保存了effectsList,通過遍歷這個鏈表來更新Fiber,就不用重新DFS整棵Fiber Tree了。
最新的Fiber架構,則採用了subtreeFlags(v17過渡版本就開始有這個字段了,需要開啓併發模式才會用到),大概原因是為了Suspense這個API的一些特性,採用了收集flags的方式。這樣就需要DFS 整棵Fiber Tree,通過subtreeFlags判斷是否需要繼續向下DFS。
結合項目代碼分析
本項目代碼沒有考慮副作用的處理了,重點關注DOM的更新。
// 根據Fiber節點的effectTag更新真實DOM
// 在commitRoot之前,已經完成了所有Fiber節點的比較
// 之前的Fiber比較流程是可以中斷的,但commitRoot不能中斷
const commitRoot = () => {
// 找到帶dom的父Fiber
const findParentFiber = (fiberNode?: FiberNode) => {
if (fiberNode) {
let parentFiber = fiberNode.return;
while (parentFiber && !parentFiber.dom) {
parentFiber = parentFiber.return;
}
return parentFiber;
}
return null;
};
const commitDeletion = (
parentDOM: FiberNodeDOM,
DOM: NonNullable<FiberNodeDOM>,
) => {
if (isDef(parentDOM)) {
parentDOM.removeChild(DOM);
}
};
const commitReplacement = (
parentDOM: FiberNodeDOM,
DOM: NonNullable<FiberNodeDOM>,
) => {
if (isDef(parentDOM)) {
parentDOM.appendChild(DOM);
}
};
const commitWork = (fiberNode?: FiberNode) => {
if (fiberNode) {
if (fiberNode.dom) {
const parentFiber = findParentFiber(fiberNode);
const parentDOM = parentFiber?.dom;
//根據副作用標籤,更新真實DOM(注意:這裏的effectTag和實際React的flags有差異,表示方式不同罷了)
switch (fiberNode.effectTag) {
case 'REPLACEMENT':
commitReplacement(parentDOM, fiberNode.dom);
break;
case 'UPDATE':
updateDOM(
fiberNode.dom,
fiberNode.alternate ? fiberNode.alternate.props : {},
fiberNode.props,
);
break;
default:
break;
}
}
//遞歸,先第一個孩子,再處理兄弟節點
commitWork(fiberNode.child);
commitWork(fiberNode.sibling);
}
};
// 這裏處理了所有的刪除工作。(實際React中是在commitWork(fiber)DFS時,遍歷父節點的deletions數組做刪除的)
for (const deletion of deletions) {
if (deletion.dom) {
const parentFiber = findParentFiber(deletion);
commitDeletion(parentFiber?.dom, deletion.dom);
}
}
// 執行插入/移動、更新工作。
if (wipRoot !== null) {
commitWork(wipRoot.child);
currentRoot = wipRoot;
}
wipRoot = null;
};
更新DOM和創建DOM代碼多一點,單獨寫成函數,如下:
// 更新DOM(屬性)
// 簡單起見,這裏是刪除之前所有屬性,添加新屬性
const updateDOM = (
DOM: NonNullable<FiberNodeDOM>,
prevProps: VirtualElementProps,
nextProps: VirtualElementProps,
) => {
const defaultPropKeys = 'children';
for (const [removePropKey, removePropValue] of Object.entries(prevProps)) {
if (removePropKey.startsWith('on')) {
DOM.removeEventListener(
removePropKey.slice(2).toLowerCase(),
removePropValue as EventListener,
);
} else if (removePropKey !== defaultPropKeys) {
// @ts-expect-error: Unreachable code error
DOM[removePropKey] = '';
}
}
for (const [addPropKey, addPropValue] of Object.entries(nextProps)) {
if (addPropKey.startsWith('on')) {
DOM.addEventListener(
addPropKey.slice(2).toLowerCase(),
addPropValue as EventListener,
);
} else if (addPropKey !== defaultPropKeys) {
// @ts-expect-error: Unreachable code error
DOM[addPropKey] = addPropValue;
}
}
};
// 基於Fiber的type創建 dom
const createDOM = (fiberNode: FiberNode): FiberNodeDOM => {
const { type, props } = fiberNode;
let DOM: FiberNodeDOM = null;
if (type === 'TEXT') {
DOM = document.createTextNode('');
} else if (typeof type === 'string') {
DOM = document.createElement(type);
}
// Update properties based on props after creation.
if (DOM !== null) {
updateDOM(DOM, {}, props);
}
return DOM;
};
4、Hooks原理
React官方將hooks分為兩類,一類是狀態hooks,另一類是副作用hooks。
- 狀態hooks:
useState,useReducer, (廣義上還有)useContext,useRef,useCallback,useMemo - 副作用hooks:
useEffect,useLayoutEffect
a.狀態hook
1.下面我們通過一個Fiber節點來看看hooks是如何工作的。
以Fiber(App)節點為例,對應的JSX代碼如下:
function App() {
const [count, setCount] = useState(0);
useEffect(()=>{
console.log('didMount')
},[])
const [show, setShow] = useState(true)
useEffect(()=>{
console.log(show, count)
},[show, count])
return (
<div>
<p>You clicked {show ? count : '*'} times</p>
<button onClick={() => setCount(count + 1)}>increase</button>
<button onClick={() => setCount(count - 1)}>decrease</button>
</div>
);
}
Fiber(App)上用了4個hook,那麼Fiber的結構如下,Fiber上的屬性memoizedState保存了一個鏈表結構:
注意:你寫的hooks順序和memoizedState保存的順序是一致的。當App Function的hooks按順序執行的同時,會通過一個全局變量currentHook移動來指向當前的hook。==如果hooks是條件裏執行的話,那麼hooks鏈表節點的查找實際是不可預測的,這也是為什麼hooks不能條件裏執行==。
2.下面我們來分析下hook節點(鏈表節點)上的queue和memoizedState。
- 當我們調用
setState方法,實際會生成一個Update對象放入queue隊列,如下圖所示。 - 當到了「Render階段」處理對應新Fiber時,會從舊Fiber把hooks鏈表copy一份過來,然後一個個執行hook,執行
currentHook時會從queue隊列中遍歷所有Update計算出最終的狀態,這個狀態是放在新Fiber的currentHook的memoizedState。 -
這個
memoizedState就是const [state, setState] = useState()中的state。
結合項目代碼分析
本項目中的變量命名和數據結構有些差異,下面我先説明“映射”關係。(本項目->實際React):
- Fiber的
hook數組 -> Fiber的memoizedState鏈表 - hook節點的
state-> hook節點的memoizedState - hook節點的
queue數組 -> hook節點的queue鏈表 - 全局變量
hookIndex-> 全局變量currentHook指針
本項目代碼如下:
// hooks: 找到當前hook節點,計算狀態。
function useState<S>(initState: S): [S, (value: S) => void] {
const fiberNode: FiberNode<S> = wipFiber;
// 每次更新需要重新構建Fiber, 運行useState時需要從alternate(old Fiber)中獲取上一次的hook
const hook: {
state: S;
queue: S[];
} = fiberNode?.alternate?.hooks
// 從hooks數組(實際是React是hooks鏈表)中獲取當前的hook節點! 這解釋了為什麼要按順序執行hooks
? fiberNode.alternate.hooks[hookIndex]
: {
state: initState,
queue: [],
};
// 從更新隊列(實際React中更新隊列是一個鏈表,這裏簡化為數組)中取出所有更新,合併到state中
while (hook.queue.length) {
let newState = hook.queue.shift();
if (isPlainObject(hook.state) && isPlainObject(newState)) {
newState = { ...hook.state, ...newState };
}
if (isDef(newState)) {
hook.state = newState; //這就是該hook的新狀態,根據這個新狀態渲染UI
}
}
if (typeof fiberNode.hooks === 'undefined') {
fiberNode.hooks = [];
}
// 組件內可能多次調用useState,每個useState對應一個hook節點(實際React中就是一個鏈表節點)
fiberNode.hooks.push(hook);
hookIndex += 1; //使用索引保證能按順序處理hooks數組
// setState就是一個閉包,裏面訪問了當前hook節點。
const setState = (value: S) => {
hook.queue.push(value);
if (currentRoot) { //注意這個currentRoot 指向舊Fiber Tree的根節點(即實際React中的FiberRoot.current)
// 創建新Fiber Tree 的 HostRootFiber
wipRoot = {
type: currentRoot.type,
dom: currentRoot.dom,
props: currentRoot.props,
alternate: currentRoot,
};
// Fiber工作指針有值了意味着新的render任務,requestIdleCallback會調用workLoop時會處理nextUnitOfWork
// 接下來就會進入performUnitOfWork,從根HostRootFiber往下DFS,構建新的Fiber Tree
nextUnitOfWork = wipRoot;
deletions = [];
currentRoot = null;
}
};
return [hook.state, setState];
}
b.副作用hook
我們先記住下面這個Effect對象定義
export type Effect = {
tag: HookFlags, // 副作用hook的類型
create: () => (() => void) | void, //useXxxEffect的創建,即第一個參數。
destroy: (() => void) | void, //useXxxEffect的銷燬函數,第一個參數的返回函數
deps: Array<mixed> | null, //useXxxEffect的依賴
next: Effect, //下一個useXxxEffect保存的Effect對象
};
一圖勝千言,對於副作用hook而言,hook節點上memoizedState保存的是Effect對象
初次調用
1.如上圖所示,初次調用useEffect,會創建Effect對象並形成effects鏈表。Effect.tag標記了Effect是Layout還是Passive(對於useLayoutEffect的就標記Layout,對於useEffect的就標記Passive)
2.在Commit的「BeforeMutation子階段」, 異步調用了flushPassiveEffects(宏任務)。在這期間帶有Passive標記的effect已經被添加到全局數組中。
接下來flushPassiveEffects就可以脱離fiber節點,遍歷全局數組,直接訪問effect,先執行effect.destroy,後執行effect.create函數。
3.解釋異步調用了flushPassiveEffects:這裏的異步調用,是React調度時給了NormalSchedulerPriority優先級,此時flushPassiveEffects被當做一個宏任務來執行。(到這裏咱就明白了useEffect和useLayoutEffect的區別:==useEffect是一個宏任務,在頁面繪製後執行;useLayoutEffect是微任務,在頁面繪製前執行==)
更新調用
1.當組件更新,就會重新執行useEffect/useLayoutEffect,創建新hook節點,然後對新hook會和舊hook的effect依賴deps比較:
- 如果有依賴項引用變化,創建新Effect並打上
tag |= HasEffect標記 - 如果沒有變化,僅創建新Effect(沒有
HasEffect標記)
2.新的hook以及新的effect創建完成之後, 餘下邏輯與初次渲染完全一致。處理 Effect 回調時也會根據effect.tag進行判斷: 只有effect.tag包含HookHasEffect時才會調用effect.destroy和effect.create()。
3.此時,你應該明白了==如果useXxEffect沒有正確依賴,會導致Effect回調不會被觸發==。
4.這部分,本項目沒有代碼哦~
5、總結
學數學我們講究數形結合,那麼學框架原理,我們也要「碼形結合」,這個“碼”就是指代碼,本文很好的碼形結合解釋了React原理。
渲染階段-思維導圖
更新流程-思維導圖
Diff算法-思維導圖
參考
mini-react github倉庫
圖解React
《React設計原理》- big-react github倉庫
react性能優化|bailout策略