前些天在看Dan Abramov個人部落格(推薦閱讀,站在React開發者的角度去解讀一些API的設計初衷和最佳實踐)裡的一篇文章,其重點部分的思想就是即使不使用Memo()
,也可以通過組合的方式來減少組不必要的渲染。
作者在放出程式碼講述結論的時候並沒有細說原理只是一筆帶過,所以筆者自己想著從React內部的更新渲染機制去思考其原理時,發現並不順暢,在一些關鍵位置的理解有些模稜兩可,遂意識到自己對於React的理解並沒有想象中的那麼深。 因此打算重新捋一捋React原始碼中涉及更新渲染時的整個流程並記錄下來。
在進入正文前,需要先宣告下該文章的讀者需要了解過一些基本的React原理知識。例如虛擬DOM(Virtual Dom),Diff演演算法、fiber架構等此類概念。我們大致過一下:
React官方檔案:Virtual DOM 是一種程式設計概念。在這個概念裡, UI 以一種理想化的,或者說「虛擬的」表現形式被儲存於記憶體中,並通過如 ReactDOM 等類庫使之與「真實的」 DOM 同步。這一過程叫做協調。
Virtual Dom是一棵與DOM類似的樹形結構。當我們建立或者更新元件時,通過協調(reconcile)過程構建新的Virtual Dom樹,並與老的樹比較計算出需要修改DOM的地方。
自16版本之後,React引入了全新的fiber架構。React在構建的Virtual Dom樹上的每個節點都被稱為fiber節點。這種將fiber節點作為最小的工作單元的組織方式,為React未來版本的可中斷的非同步更新提供了最底層的支援。本文的原始碼解析基於17版本。
在React完成初次渲染之後會同時存在兩棵fiber樹。當前已經渲染到頁面上的內容對應的fiber樹稱為current fiber樹
,正在記憶體中構建的被稱為workInProgress fiber樹
。
Diff演演算法就是發生在構建workInProgress fiber樹
的過程中,邊構建邊與current fiber樹
做比較並儘可能地複用節點。當一次更新渲染完成後,在根節點上直接通過將current
指標指向workInProgress fiber樹
來執行兩棵樹的切換。
考慮組織樹形資料結構的方式,最常見的一種方式就是通過children欄位:
{
"name": "A",
"children": [
{ "name": "B" },
{
"name": "C",
"children": [
{ "name": "D" }
]
}
]
}
但React並不是這種方式,而是採用了通過指標來關聯節點的方式:無論有多少個子節點,只儲存一個child
欄位來指向第一個子節點;多個子節點中通過sibling
指向下一個兄弟節點;所有的子節點都有return
欄位指向同一個父節點。
來個範例圖:
這種方式非常符合Fiber架構的需求。方便以各種方式遍歷整棵樹,並且在調整樹結構時,只需操作指標指向就可以了。
React對該樹的遍歷方式採取的是深度優先遍歷。深度優先遍歷有兩個不同的階段,分別是是「遞」和「歸」階段;對應的[原始碼]簡化後:
function workLoopSync() {
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
}
function performUnitOfWork(unitOfWork: Fiber): void {
const current = unitOfWork.alternate;
let next;
next = beginWork(current, unitOfWork, subtreeRenderLanes);
if (next === null) {
completeUnitOfWork(unitOfWork);
} else {
workInProgress = next;
}
}
workInProgress
指標指向當前遍歷到的節點。對於每個遍歷到的fiber節點,會呼叫beginWork
方法。該方法根據傳入的fiber節點建立第一個子fiber節點,並將這兩個節點通過child欄位連線起來。這是「遞」階段。
當遍歷到葉子節點 即沒有子節點的元件時,就進入到「歸」階段:對該fiber節點執行completeUnitOfWork
方法。執行完該節點的「歸」階段後,會檢查其是否存在兄弟節點 即sibling欄位。如果存在則進入到兄弟fiber節點的「遞」階段;如果沒有兄弟節點了,則向上開始父fiber節點的「歸」階段。
「遞」和「歸」階段不斷交錯執行直至「歸」到根節點結束。示意如下:
前面說了那麼多,終於要進入到正文啦。
觸發React更新的方式有許多中:
不管是何種方式更新狀態,都會建立一個用於儲存更新狀態相關資訊的物件,稱為Update
。並將其插入到對應fiber節點的updateQueue
上(enqueueUpdate(fiber, update)
函數中完成),並加入排程update(scheduleUpdateOnFiber(fiber, lane, eventTime)
)。
我們以ClassComponent的this.setState
為例,將其作為狀態更新的起點。
在[原始碼]中看下setState
方法的定義:
Component.prototype.setState = function(partialState, callback) {
invariant(
typeof partialState === 'object' ||
typeof partialState === 'function' ||
partialState == null,
'setState(...): takes an object of state variables to update or a ' +
'function which returns an object of state variables.',
);
this.updater.enqueueSetState(this, partialState, callback, 'setState');
}
先是做對引數的檢查,接著呼叫updater
上的enqueueSetState
。這裡的updater
並不是在react包中定義的,而是通過依賴注入的方式在元件的初始化構建時注入[原始碼]的。所以在ClassComponent的constructor()
中調this.setState
是非法的。
進入到enqueueSetState
的方法[原始碼]中:
enqueueSetState(inst, payload, callback) {
// 根據當前的元件範例獲取對應的fiber節點
const fiber = getInstance(inst);
// 優先順序相關的資料
const eventTime = requestEventTime();
const lane = requestUpdateLane(fiber);
// 建立update
const update = createUpdate(eventTime, lane);
update.payload = payload;
// update.tag = ForceUpdate; 如果是通過this.forceUpdate()更新的話,這裡還會加個標記為強制更新,防止元件在後續的優化手段中被跳過重渲染
// 處理回撥函數
if (callback !== undefined && callback !== null) {
update.callback = callback;
}
// 將update插入到updateQueue中
enqueueUpdate(fiber, update);
// 排程update
scheduleUpdateOnFiber(fiber, lane, eventTime);
}
無論是以何種方式進行更新,最後都會統一進入到scheduleUpdateOnFiber
函數[原始碼]中來:
function scheduleUpdateOnFiber(fiber, lane, eventTime) {
// 檢查是否陷入無限迴圈更新;例如在render()函數中呼叫setState()就會導致無限更新
checkForNestedUpdates();
// 自底向上收集fiber.childLanes
const root = markUpdateLaneFromFiberToRoot(fiber, lane);
if (root === null) {
return null;
}
if (lane === SyncLane) {
if(
(executionContext & LegacyUnbatchedContext) !== NoContext && // 當前處於unbatched的上下文環境中;元件在mount和解除安裝時會擁有該上下文
(executionContext & (RenderContext | CommitContext)) === NoContext // 當前不是處於render或commit階段
) {
performSynWorkOnRoot(root);
} else {
ensureRootIsScheduled(root, eventTime);
if(executionContext === NoContext) {
flushSyncCallbackQueue();
}
}
} else {
...
ensureRootIsScheduled(root, eventTime);
}
}
從上往下看:
因為該方法是所有狀態更新都必經的入口,所以最先做的是檢查死迴圈的可能。
markUpdateLaneFromFiberToRoot
的作用是從當前需要更新的fiber節點開始,根據父節點指標不斷向上遍歷直至root節點,並將fiber.lane存入這一向上路徑上所有祖先節點的childLanes中。childLanes欄位在後續的render階段遍歷fiber樹時用於判斷子樹中是否存在更新的依據。
接下來根據渲染方式分流
如果是傳統的同步渲染方式,則進入第一個if的邏輯中;老版本中最常見的ReactDOM.render
就是該模式。
判斷程式碼註釋中說明的條件,如果都滿足的話(通常就是第一次渲染的時候)直接執行同步更新performSynWorkOnRoot()
,開始render階段。
否則呼叫ensureRootIsScheduled(root, eventTime)
,排程此次更新。
在這之後會有一條if(executionContext === NoContext)
判斷當前是否沒有任何的執行上下文,如果為true
就表現此次更新並不是處在React的上下文中,則呼叫flushSyncCallbackQueue()
立即同步執行此次更新。 這裡涉及到一個常見的React問題:「this.setState
什麼時候同步更新,什麼時候是非同步(批次)更新」? 答:當處於React相關生命週期函數和事件處理回撥中時,程式碼層面上看就是擁有執行上下文executionContext
,這時候this.setState
是批次更新的;而在脫離這些環境(如fetch
網路請求返回或者setTimeout
延遲執行)時,if(executionContext === NoContext)
成立,就會同步執行this.setState
的更新。
非同步渲染的方式,也是呼叫ensureRootIsScheduled(root, eventTime)
。
ensureRootIsScheduled()
函數[原始碼]中涉及到對過期任務的立即同步執行,對舊任務的複用等邏輯;這一塊不是關注的重點先忽略掉(scheduler的排程的物件是任務(task),而不是指單個fiber節點的更新,這裡要注意下。任務是指根據current fiber樹構建新的fiber樹並diff的整個過程 即整個render階段。而React在ensureRootIsScheduled()
函數裡做的只是根據舊任務和此次更新的優先順序來決定是否要複用舊任務 亦或生成新任務,剩下的就丟給scheduler去排程了。這一塊的內容要拉出來細說的話得相當大的篇幅,之後再單獨寫一篇關於任務排程部分的文章 : ) 這裡我們只要知道scheduler是通過MessageChannel來實現task(宏任務),以此達到非同步排程執行任務的目的。
關注在流程上最核心的程式碼部分:
function ensureRootIsScheduled(root, current) {
...
if (existingCallbackNode !== null) {
const existingCallbackPriority = root.callbackPriority;
if (existingCallbackPriority === newCallbackPriority) {
// 優先順序不變,則直接複用已有任務
return;
}
// 優先順序改變了,取消已有任務,下面開始排程一個新任務
cancelCallback(existingCallbackNode);
}
// 排程一個新任務
let newCallbackNode;
if (newCallbackPriority === SyncLanePriority) {
newCallbackNode = scheduleSyncCallback(
performSyncWorkOnRoot.bind(null, root),
);
} else {
const schedulerPriorityLevel = lanePriorityToSchedulerPriority(
newCallbackPriority,
);
newCallbackNode = scheduleCallback(
schedulerPriorityLevel,
performConcurrentWorkOnRoot.bind(null, root),
);
}
root.callbackPriority = newCallbackPriority;
root.callbackNode = newCallbackNode;
}
其中scheduleSyncCallback
和scheduleCallback
是由scheduler包提供的方法,用於根據優先順序排程傳入的回撥函數。
被排程的函數performSyncWorkOnRoot
或performConcurrentWorkOnRoot
,取決於本次更新是同步更新還是非同步更新;這兩個函數即是render階段的入口。
梳理下從觸發更新為起始到進入渲染階段流程中的關鍵節點:
在performSyncWorkOnRoot
/performConcurrentWorkOnRoot
裡會呼叫renderRootSync(root, lanes)
/renderRootConcurrent(root, lanes)
和commitRoot(root)
,這兩者分別就是React更新中的render階段和cmooit階段了。
首先講解的是render階段的工作流程。進到renderRootSync(root, lanes)
/renderRootConcurrent(root, lanes)
中,看到關鍵函數workLoopSync
/workLoopConcurrent
:
function workLoopSync() {
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
}
function workLoopConcurrent() {
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}
它們的區別在於是否有呼叫shouldYield()
。shouldYield()
用於判斷當前瀏覽器幀的時間還足夠,不夠了就打斷此次fiber樹的構建,等到空閒時再次執行;這也是為什麼引入非同步更新後可能會導致元件會多次render的原因。
workInProgress
變數表示當前已建立的workInProgress fiber節點。performUnitOfWork
會根據當前的workInProgress fiber節點以前文所說的深度遍歷的方式決定下一個fiber節點是哪個並建立出來,然後將workInProgress
與新建立的子fiber節點連線起來,再將新建立的節點賦值給workInProgress
。
在while迴圈中不斷呼叫performUnitOfWork(workInProgress)
,重複上述工作 就構造出了一棵完整的fiber樹。現在看到performUnitOfWork
中的程式碼[原始碼]:
function performUnitOfWork(unitOfWork: Fiber): void {
const current = unitOfWork.alternate;
let next;
next = beginWork(current, unitOfWork, subtreeRenderLanes); // 「遞」階段
...
if(next === null) {
completeUnitOfWork(unitOfWork); // 「歸」階段
} else {
workInProgress = next;
}
}
前文說過,beginWork
和completeUnitOfWork
分別對應的就是fiber樹更新的「遞」和「歸」階段。
將beginWork
簡化處理後如下[原始碼]:
function beginWork(current: Fiber | null, workInProgress: Fiber, renderLanes: Lanes):Fiber | null {
const updateLanes = workInProgress.lanes;
if(current !== null) {
const oldProps = current.memoizedProps;
const newProps = workInProgress.pendingProps;
if(oldProps !== newProps || hasLegacyContextChanges()) {
didReceiveUpdate = true;
} else if(!includesSomeLane(renderLanes, updateLanes)) {
// 當前更新優先順序renderLanes不包括fiber.lanes
didReceiveUpdate = false;
...
return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
} else {
didReceiveUpdate = false;
}
} else {
didReceiveUpdate = false;
}
switch(workInProgress.tag) {
...
case FunctionComponent:
return updateFunctionComponent(...);
case ClassComponent:
return updateClassComponent(...);
case MemoComponent:
return updateMemoComponent(...);
...
}
}
我們已經知道React會有兩棵fiber樹,那麼除根節點rootFiber外,任何節點在初次渲染時其current都為空 即不存在workInProgress.alternate
。因此可以根據if(current !== null)
來區分當前是mount還會update。
這裡的didReceiveUpdate
從變數名就能看出來,是用於標記該節點是否有接收到更新;在後續的流程程式碼中我們會看到需要使用該變數來判斷優化邏輯的地方。
當current為空時很簡單,將didReceiveUpdate
置為false,就直接就進入最下面根據tag欄位區分節點型別,建立並返回新的子fiber節點。
當current !== null
即更新時,React會根據一定條件儘可能地去複用current節點:
props或者context有變動的;這種情況是無法複用current節點的;將didReceiveUpdate
置為true。這裡有兩個需要注意的點:
oldProps
和newProps
是由元件外部傳入的所有屬性建立而來的物件。即使我們在每次重渲染時向子元件傳遞的所有props屬性欄位值都是一樣的,但在子元件對應的fiber中總是會new一個新物件再將屬性存入其中。因此,這裡的oldProps !== newProps
總是為真。這也是為什麼當父元件重新render時,即使傳給子元件的props屬性都不變也會導致子元件都重渲染。Memo
包裹的元件;那麼在建立Memo型別的節點時會對oldProps
和newProps
做一次淺比較。如果淺比較發現所有欄位值都相同,則會將didReceiveUpdate
置回false。否則判斷 當前fiber的更新優先順序與此次fiber樹的更新優先順序判斷,如果存在更新且更新優先順序與fiber樹優先順序一致則includesSomeLane(renderLanes, updateLanes)
會返回true。當前述兩點不滿足時(說明不存在更新或優先順序不夠),則進入該分支執行bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes)
,複用fiber節點(後續會細說這個方法)。
前兩個分支都無法滿足,說明該fiber節點存在更新但無外部變化(props或是context改變),didReceiveUpdate
置為false。
在進一步講解beginWork
方法之前,先來看下一bailoutOnAlreadyFinishedWork
的實現[原始碼]:
function bailoutOnAlreadyFinishedWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes,
): Fiber | null {
// ...
if (!includesSomeLane(renderLanes, workInProgress.childLanes)) {
return null;
} else {
cloneChildFibers(current, workInProgress);
return workInProgress.child;
}
}
bailoutOnAlreadyFinishedWork
中最主要的就是這兩個判斷分支。在前面講解scheduleUpdateOnFiber
方法中說到,執行markUpdateLaneFromFiberToRoot(fiber, lane)
語句,對於產生更新的fiber節點,會將其更新優先順序的資訊lane
變數自底向上,賦值給所有祖先節點的childLanes變數中。那麼:
includesSomeLane(renderLanes, workInProgress.childLanes)
語句判斷當前fiber節點的childLanes是否在本次更新的優先順序資訊renderLanes中;如果不是,則說明workInProgress
的整棵子樹中都不存在更新,所以直接返回null,上文說到的performUnitOfWork
方法中,如果判斷返回的變數是null則表示「遞」階段完成,開始此fiber節點的「歸」階段。再來回顧下performUnitOfWork
的程式碼:
function performUnitOfWork(unitOfWork: Fiber): void {
const current = unitOfWork.alternate;
let next;
next = beginWork(current, unitOfWork, subtreeRenderLanes); // 「遞」階段
...
if(next === null) {
completeUnitOfWork(unitOfWork); // 「歸」階段
} else {
workInProgress = next;
}
}
否則的話說明子樹中存在更新,需要繼續往下「遞」。但是當前的fiber節點是可以複用的,執行cloneChildFibers(current, workInProgress)
克隆所有子節點;返回下一個需要更新的fiber節點 即第一個子節點。
beginWork
方法體的最下面就是根據當前fiber節點的型別呼叫各自的建立函數,並返回下一個要更新的fiber節點。在這一過程中,會執行各種優化措施和生命週期相關的勾點 例如:
對於Class元件,如果有重寫了componentWillReceiveProps
方法的會在此時呼叫;有重寫了shouldComponentUpdate
方法的話,會將執行結果加入到是否要跳過更新的判斷中去[updateClassComponent -> updateClassInstance]。如果最終判斷可以跳過更新,也是進入到bailoutOnAlreadyFinishedWork
函數[updateClassComponent -> finishClassComponent]。
如果是Function元件,進入到updateFunctionComponent
函數[原始碼]中:
function updateFunctionComponent(
current,
workInProgress,
Component,
nextProps: any,
renderLanes
) {
nextChildren = renderWithHooks(); // 返回函陣列件執行後return的JSX內容
if (current !== null && !didReceiveUpdate) {
bailoutHooks(current, workInProgress, renderLanes); // 移除副作用和更新標記
return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
}
// 建立並返回下一個fiber節點
reconcileChildren(current, workInProgress, nextChildren, renderLanes);
return workInProgress.child;
}
在這裡我們又看到了didReceiveUpdate
變數。在這一行程式碼中判斷如果是更新行為(current !== null
)且此前在beginWork
中設定了didReceiveUpdate
為false的話,則複用fiber節點;同樣也是進到bailoutOnAlreadyFinishedWork
方法。
如果是Memo元件,則會對新舊props做淺比較,相等的話則會複用[原始碼]:
const currentChild = ((current.child: any): Fiber); // 取出唯一的一個child,即我們寫在React.memo(...)中的元件
if (!includesSomeLane(updateLanes, renderLanes)) { // 當前fiber節點中是沒有更新的
const prevProps = currentChild.memoizedProps;
let compare = Component.compare;
compare = compare !== null ? compare : shallowEqual;
if (compare(prevProps, nextProps) && current.ref === workInProgress.ref) { // 淺比較新舊props
return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
}
}
其他元件等等......
對於常見的元件型別 如ClassComponent
/HostComponent
/FunctionComponent
/ForwardRef
等,如果沒有命中優化手段,最終都會進入都reconcileChildren
中。
reconcileChildren
函數的內容不多,如下[原始碼]:
function reconcileChildren(
current: Fiber | null,
workInProgress: Fiber,
nextChildren: any,
renderLanes: Lanes
) {
if (current === null) { // mount元件時
workInProgress.child = mountChildFibers(
workInProgress,
null,
nextChildren,
renderLanes
);
} else { // update元件時
workInProgress.child = reconcileChildFibers(
workInProgress,
current.child,
nextChildren,
renderLanes
);
}
}
和beginWork
中一樣,這裡也是根據current === null
來區分當前節點是第一次掛載還是更新的。mountChildFibers
/reconcileChildFibers
建立新的所有子fiber節點,並將第一個子節點賦值給workInProgress.child
以此將兩者相連起來。
mountChildFibers
和reconcileChildFibers
方法的邏輯差不多一樣。主要不同的地方在於mount時根據JSX(也就是上面的nextChildren
引數)建立子fiber節點。而update時會將JSX與上次更新時的fiber節點做比較(這個過程就是「眾所周知」的Diff演演算法),根據比較結果生成新的子fiber節點;此外還會在fiber節點上設定flags
,即副作用標記。flags
通過二進位制位的方式儲存了需要對fiber節點對應DOM節點執行的操作,例如這些[原始碼]:
// 插入DOM
export const Placement = /* */ 0b000000000000000010;
// 更新DOM
export const Update = /* */ 0b000000000000000100;
// 插入並更新DOM
export const PlacementAndUpdate = /* */ 0b000000000000000110;
// 刪除DOM
export const Deletion = /* */ 0b000000000000001000;
傳入reconcileChildFibers
方法的前三個引數,分別是workInProgress fiber節點
、current fiber節點
和我們寫的元件中render返回的JSX內容
。在這裡需要清楚一些概念,就是在頁面上的DOM節點,每次更新渲染時都有四種節點與之對應:
React的Diff操作要做的其實就是比較【2】和【3】,生成【4】。
進入到reconcileChildFibers
方法,也就是Diff操作的入口[原始碼],看下它的整體流程:
function reconcileChildFibers(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChild: any,
lanes: Lanes,
): Fiber | null {
// children是否為一個物件
const isObject = typeof newChild === 'object' && newChild !== null;
if(isObject) {
// ...呼叫reconcileSingleElement()
}
// 為文位元組點
if (typeof newChild === 'string' || typeof newChild === 'number') {
// ...呼叫reconcileSingleTextNode()
}
// 為陣列
if (isArray(newChild)) {
// ...呼叫reconcileChildrenArray()
}
// 上述情況都未命中,則說明需要刪除掉子節點
return deleteRemainingChildren(returnFiber, currentFirstChild);
}
整體思路就是根據children型別分為單節點和陣列節點兩種情況;最核心的就是根據節點的key
和type
判斷儘可能地複用子節點,陣列情況下會複雜一些因為涉及到了同級節點移動的情況。Diff演演算法的具體步驟原始碼在呼叫reconcileSingleElement
和reconcileChildrenArray
函數中,在這裡就不再展開了,講解React Diff演演算法的相關文章隨便搜尋下簡直不要太多:)。
一張流程圖總結下beginWork
的執行流程:
在上文的的performUnitOfWork
函數中我們看到了,作為「遞」階段的beginWork
每次執行都會返回next變數,作為下一個要處理的fiber節點。如果當前處理完的fiber節點是個葉子節點,其沒有子節點了,那麼返回的next則為空:
if(next === null) {
completeUnitOfWork(unitOfWork); // 內部會呼叫completeWork方法
}
於是就該執行該fiber節點的「歸」階段了,即completeUnitOfWork
函數[原始碼]:
function completeUnitOfWork(unitOfWork: Fiber): void {
let completedWork = unitOfWork;
do {
const current = completedWork.alternate;
const returnFiber = completedWork.return;
let next;
next = completeWork(current, completedWork, subtreeRenderLanes);
if (next !== null) { // next不為空的特殊情況
workInProgress = next;
return;
}
/* effectList相關的操作,放到最後講 */
if (returnFiber !== null && (returnFiber.flags & Incomplete) === NoFlags) {
// 將當前fiber節點的effectList拼接進父節點的effectList中
if (returnFiber.firstEffect === null) {
returnFiber.firstEffect = completedWork.firstEffect;
}
if (completedWork.lastEffect !== null) {
if (returnFiber.lastEffect !== null) {
returnFiber.lastEffect.nextEffect = completedWork.firstEffect;
}
returnFiber.lastEffect = completedWork.lastEffect;
}
// 如果當前fiber節點有副作用,將當前節點拼接進父節點的effectList中
const flags = completedWork.flags;
if (flags > PerformedWork) {
if (returnFiber.lastEffect !== null) {
returnFiber.lastEffect.nextEffect = completedWork;
} else {
returnFiber.firstEffect = completedWork;
}
returnFiber.lastEffect = completedWork;
}
}
/* --- */
const siblingFiber = completedWork.sibling; // 下一個兄弟節點
if (siblingFiber !== null) {
workInProgress = siblingFiber;
return;
}
completedWork = returnFiber; // 如果沒有兄弟節點,則「歸」到父節點
workInProgress = completedWork;
} while (completedWork !== null);
}
對fiber節點「歸」階段要執行的主要工作是在completeWork
方法中實現的。completeUnitOfWork
這裡只是負責對fiber節點呼叫completeWork
方法並返回next
變數。
絕大部分情況下next
都為null。因為根據深度優先遍歷的規則,當前節點遍歷完了說明其子樹中的節點一定也都處理完了,下一步應該是開始下一個兄弟節點的「遞」階段:
const siblingFiber = completedWork.sibling; // 下一個兄弟節點
if (siblingFiber !== null) {
workInProgress = siblingFiber;
return;
}
如果沒有兄弟節點了,按照規則應該是返回上一層的父節點並執行它的「歸」操作。這裡將父節點賦值給completedWork
變數:completedWork = returnFiber
,並在下一輪do while
迴圈中開始這個父節點的「歸」操作。
但凡事都有意外。如果返回的next
變數不為null:
if (next !== null) { // next不為空的特殊情況
workInProgress = next;
return;
}
將next
變數賦值給workInProgress
,開始進入到next
表示節點的「遞」階段。這裡的特殊情況就是指Suspense元件懶載入時的場景(在下一節的completeWork
方法中筆者會標記出其位置)。Suspense元件完成了「歸」階段的工作,但由於元件未載入完成因此需要重渲染Suspense.fallback的內容,所以要重新進入到節點的「遞」階段。
讓我們進入到completeWork
的實現中來[原始碼]:
function completeWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes,
): Fiber | null {
switch (workInProgress.tag) {
case LazyComponent:
case SimpleMemoComponent:
//...
case FunctionComponent:
case MemoComponent:
case ClassComponent:
return null;
case HostComponent: {
// ...此處先省略
}
case SuspenseComponent: {
if ((workInProgress.flags & DidCapture) !== NoFlags) { // Suspense下的元件未載入時會丟擲異常並被捕獲
workInProgress.lanes = renderLanes;
return workInProgress;
}
// ...
return null
}
// ...
}
}
對於常見的元件型別FunctionComponent、MemoComponent和ClassComponent等並沒有什麼特別的操作,都是直接返回null。
對於SuspenseComponent型別的元件,如果(workInProgress.flags & DidCapture) !== NoFlags
成立,說明懶載入未完成,返回當前的fiber節點;否則返回null。這裡便是與上節completeUnitOfWork
方法中next
變數不為空的特殊情況相照應的地方。
重點關注HostComponent型別(原生DOM對應的fiber節點型別)的處理:
case HostComponent: {
const rootContainerInstance = getRootHostContainer();
const type = workInProgress.type;
if (current !== null && workInProgress.stateNode != null) { // update時的情況
updateHostComponent(
current,
workInProgress,
type,
newProps,
rootContainerInstance,
);
} else { // mount時的情況
const currentHostContext = getHostContext();
// 建立DOM節點
const instance = createInstance(
type,
newProps,
rootContainerInstance,
currentHostContext,
workInProgress,
);
// 將子孫DOM節點插入到新的DOM節點下
appendAllChildren(instance, workInProgress, false, false);
workInProgress.stateNode = instance;
// 設定DOM節點上的屬性
if (
finalizeInitialChildren(
instance,
type,
newProps,
rootContainerInstance,
currentHostContext,
)
) {
markUpdate(workInProgress);
}
}
}
在這裡區分mount還是update時的條件和前面的不一樣了,不再只是判斷current === null ?
了,還多了一條workInProgress.stateNode != null
。因為我們當前處理的是HostComponent型別,因此判斷是否為更新除了要有fiber節點(current
),還得有對應的DOM節點(workInProgress.stateNode
)。
對照上述的程式碼捋下mount和update做的工作。
mount:
建立fiber節點對應的DOM節點。
將子孫DOM節點插入到剛剛新建立的DOM節點中。我們知道作為「歸」階段的complete
是自底向上完成的,所以在處理當前fiber節點workInProgress
時,workInProgress
的所有子fiber節點所對應的DOM節點一定是都已經建立好了的。
建立好的DOM節點賦值給workInProgress.stateNode
。
執行finalizeInitialChildren
函數,最終進入到setInitialDOMProperties
中(路徑[finalizeInitialChildren -> setInitialProperties -> setInitialDOMProperties])。該函數將節點上的屬性prop設定到DOM上,如style
樣式屬性、children
屬性描述的內部文字/數值內容、各種自定義屬性以及註冊事件處理等。
update:
update時的工作相對就簡單多了,就是執行updateHostComponent
[原始碼]方法:
updateHostComponent = function(
current: Fiber,
workInProgress: Fiber,
type: Type,
newProps: Props,
rootContainerInstance: Container
) {
const oldProps = current.memoizedProps;
if (oldProps === newProps) {
return;
}
const instance: Instance = workInProgress.stateNode;
const currentHostContext = getHostContext();
const updatePayload = prepareUpdate(
instance,
type,
oldProps,
newProps,
rootContainerInstance,
currentHostContext,
);
workInProgress.updateQueue = (updatePayload: any);
}
與mount時的第4步類似,也是負責處理DOM節點上的屬性。但不同的地方在於,updateHostComponent
中只是做了事件監聽的註冊和屬性的預處理:const updatePayload = prepareUpdate(...)
;然後將需要更新的props內容賦值到fiber節點的updateQueue
上:workInProgress.updateQueue = updatePayload
。最終在React渲染的commit階段才會將prop更新真正應用到DOM節點上。
在completeUnitOfWork
函數體的中間有一大段關於effectList
的操作。此處做的操作就是將當前fiber節點以及子樹中有更改的節點(即effectList
)「拼接」進父節點的effectList
。
我們從前文的beginWork
執行過程中知道,對整棵fiber樹進行完「遞」階段後,所有存在變更的fiber都被標記上flags
表明是何種變更。在之後的commit
階段React需要對所有存在變更的fiber節點執行對應的DOM操作的話,如果再完全遍歷一次fiber樹的話是不太可取的。
因此React做法是將所有存在flags
的fiber節點都存放在單向連結串列結構的effectList
中。對照程式碼,來看下實際的實現:
當前fiber節點有兩個變數,firstEffect
指向連結串列的第一個元素,lastEffect
指向連結串列的最後一個元素。這條連結串列上包含了子樹中所有標記了flags
的子孫節點。這一條連結串列就是effectList
。
在completeUnitOfWork
函數中處理當前fiber節點,通過操作父節點的firstEffect
和lastEffect
變數,將當前fiber節點的effectList
拼接進父節點的effectList
。示意圖如下:
如果當前fiber節點也存在變更,則還需要將當前節點放到父節點effectList
的末尾:
如果對整棵fiber樹自底向上執行完completeUnitOfWork
後,root節點的effectList
就是一條擁有整棵fiber樹上所有帶有變更的fiber節點所組成的連結串列了。在之後的commit階段需要將所有fiber節點的變更應用到頁面DOM上時,只需要遍歷root節點的effectList
即可。
在後續的commit階段有許多操作是需要遍歷effectList
。我們由上述生成effectList
的過程可以知道,遍歷effectList
中fiber節點的順序 對應fiber樹中的結構是自底向上的,從子孫節點到父節點,再到祖先節點直至root根節點。在後面的解析內容中會有地方再次提到該知識點。
至此,對於整棵fiber樹的遞(beginWork
)和歸(completeUnitOfWork
)操作都已完成。現在把目光再撥回到fiber樹更新的起點,performSyncWorkOnRoot
/performConcurrentWorkOnRoot
函數中:在執行完render階段的入口renderRootSync
/renderRootConcurrent
後,如果render階段正常完成,最終它們都會呼叫commitRoot(root)
,開啟commit階段。
看進commitRoot
的實現[commitRoot -> commitRootImpl]中,函數體非常的長。在React原始碼的註釋中,是將commit階段劃分為以下三個階段並命名的:
筆者也按這三個階段來分別講解,並儘可能地簡化程式碼,只保留比較重要的程式碼部分,標記出其所在commitRootImpl
方法中對應哪些行數。
在正式開始commit階段之前,還有一些額外的工作要做。主要是清理上一次渲染產生的回撥任務以及獲取完整的effectList
[commitRootImpl中1889-1988行]:
do {
flushPassiveEffects(); // 觸發useEffect回撥和其他同步任務;由於這些任務中可能再觸發新的重渲染,因此需要在while迴圈中執行至沒有任務為止
} while (rootWithPendingPassiveEffects !== null);
const finishedWork = root.finishedWork;
const lanes = root.finishedLanes;
// 獲取所有存在更改的fiber節點 - effectList
let firstEffect;
if (finishedWork.flags > PerformedWork) {
if (finishedWork.lastEffect !== null) {
// effectList的連結串列操作,將有flags的根節點加入到末尾
finishedWork.lastEffect.nextEffect = finishedWork;
firstEffect = finishedWork.firstEffect;
} else {
firstEffect = finishedWork;
}
} else { // 根節點沒有副作用
firstEffect = finishedWork.firstEffect;
}
if (firstEffect !== null) {
/*
...
此處有三個while迴圈分別對應三個階段的工作
...
*/
}
最開始的程式碼是在while迴圈中重複執行flushPassiveEffects
方法,直至effect任務佇列為空。之所以要這樣設計,是因為會有可能兩次更新渲染是同步執行的。例如 第一次更新渲染後會產生各個元件中的useEffect
回撥任務,這些任務將會作為」宏任務「(React內部是通過MessageChannel
[MDN]實現的)放在下一輪事件迴圈中執行。 接著同步執行第二次更新渲染,這個時機是在第一次更新產生的那些「宏任務」執行之前的,所以需要在commit階段開始前就把上一輪更新產生的副作用先給執行完了。
這個設計對深入理解useEffect
的執行時機非常重要,來看下這段程式碼:
let counter = 0
function App(props: any) {
const [name, setName] = useState('')
useEffect(() => {
console.log(counter)
})
const click = () => {
Promise.resolve().then(() => {
++counter
setName('one')
})
Promise.resolve().then(() => {
++counter
setName('two')
})
}
return <div onClick={click}>{name}</div>
}
觸發點選事件後,執行的兩個微任務中都有更新操作。useEffect
的回撥方法中會列印counter
變數;最終的輸出結果是:2, 2
。
在第一個微任務中的更新完成後,counter
為1,併產生一個useEffect
的回撥在任務佇列中。
接著開始第二個微任務(useEffect
回撥是宏任務,所以肯定在它之前),counter
為2。在完成第二次更新的render階段但在開始commit階段之前,會進入前面說的flushPassiveEffects()迴圈
部分。
在flushPassiveEffects
方法中提前執行了第1步中產生的useEffect
回撥,列印counter: 2
第二次更新產生的useEffect
回撥放入任務佇列,在下一次事件迴圈的宏任務中執行列印counter: 2
if (firstEffect !== null) {
// 給執行上下文加上CommitContext,標記當前處於commit階段
const prevExecutionContext = executionContext;
executionContext |= CommitContext;
nextEffect = firstEffect;
do {
try {
commitBeforeMutationEffects(); // before mutation階段做的工作都在該函數中
} catch (error) {
// ...處理異常...
nextEffect = nextEffect.nextEffect;
}
} while (nextEffect !== null);
// ...mutation階段
// ... layout階段
}
before mutation階段的程式碼很短,就是在while迴圈中執行commitBeforeMutationEffects
方法。正常情況下commitBeforeMutationEffects
只需要執行一次就可以了,這裡將其放在while迴圈中是為了在執行過程中發生異常時可以在try catch
塊中捕獲到,並執行nextEffect = nextEffect.nextEffect
語句,跳過effectList
中發生異常的這個節點到下一個,然後再次執行commitBeforeMutationEffects
方法。
進入到commitBeforeMutationEffects
函數中的實現[原始碼]:
function commitBeforeMutationEffects() {
while (nextEffect !== null) {
const current = nextEffect.alternate;
if (!shouldFireAfterActiveInstanceBlur && focusedInstanceHandle !== null) {
// 處理DOM的focus、blur相關的操作
}
const flags = nextEffect.flags;
// 該fiber節點需要呼叫getSnapshotBeforeUpdate生命週期勾點
if ((flags & Snapshot) !== NoFlags) {
// 該方法內會呼叫ClassComponent範例的getSnapshotBeforeUpdate方法
commitBeforeMutationEffectOnFiber(current, nextEffect);
}
/* 存在「passive effects」,翻譯過來就是「被動」的副作用,意指那些通過監聽狀態(依賴陣列)變化產生的作用,如最常見的useEffect */
if ((flags & Passive) !== NoFlags) {
if (!rootDoesHavePassiveEffects) {
// 下面flushPassiveEffects會呼叫所有的useEffect回撥,因此只要執行一次排程即可。用該變數標記是否排程過
rootDoesHavePassiveEffects = true;
// 排程flushPassiveEffects
scheduleCallback(NormalSchedulerPriority, () => {
flushPassiveEffects();
return null;
});
}
}
nextEffect = nextEffect.nextEffect;
}
}
commitBeforeMutationEffects
函數會遍歷effectList
,對每個fiber節點按順序下來做了這麼三件事:
處理頁面DOM節點在更新渲染或刪除後的聚焦(focus)/失焦(blur)狀態。
從React 16版本之後,由於componentWillXXX
系列的生命週期勾點會在更新渲染中觸發多次,這對於開發來說不安全,因此提供了一個新的生命週期勾點:getSnapshotBeforeUpdate
。類元件ClassComponents發生更新後,會在完成render階段後但在commit階段執行DOM操作之前呼叫這個生命週期勾點。
排程flushPassiveEffects
方法,該方法使得在瀏覽器完成繪製後(layout階段之後)再呼叫useEffect
回撥。
flushPassiveEffects
[原始碼]的實現中,會先執行完所有的上一次更新渲染中useEffect
返回的銷燬函數後,再開始執行所有的本次更新後產生的useEffect
回撥函數對於頁面上DOM的實際操作都是發生在mutation階段。
mutation階段的外層程式碼[commitRootImpl中2045-2070行]和before mutation階段類似:
nextEffect = firstEffect;
do {
try {
commitMutationEffects(root, renderPriorityLevel); // mutation階段做的工作都在該函數中
} catch (error) {
// ...處理異常...
nextEffect = nextEffect.nextEffect;
}
} while (nextEffect !== null);
進入到commitMutationEffects
函數中的實現[原始碼]:
function commitMutationEffects(root: FiberRoot, renderPriorityLevel: ReactPriorityLevel) {
while (nextEffect !== null) {
const flags = nextEffect.flags;
// 重置對應DOM節點的文字內容
if (flags & ContentReset) {
commitResetTextContent(nextEffect);
}
// 處理ref
if (flags & Ref) {
const current = nextEffect.alternate;
if (current !== null) {
commitDetachRef(current);
}
}
// 根據flags型別分別做處理
const primaryFlags = flags & (Placement | Update | Deletion | Hydrating);
switch (primaryFlags) {
case Placement: {
commitPlacement(nextEffect);
nextEffect.flags &= ~Placement;
break;
}
case PlacementAndUpdate: {
commitPlacement(nextEffect);
nextEffect.flags &= ~Placement;
const current = nextEffect.alternate;
commitWork(current, nextEffect);
break;
}
/* 伺服器端渲染相關
case Hydrating: {...}
case HydratingAndUpdate: {...}
*/
case Update: {
const current = nextEffect.alternate;
commitWork(current, nextEffect);
break;
}
case Deletion: {
commitDeletion(root, nextEffect, renderPriorityLevel);
break;
}
}
nextEffect = nextEffect.nextEffect;
}
}
commitMutationEffects
函數遍歷effectList
,對每個fiber節點按順序下來做以下三件事情:
fiber節點存在有ContentReset
標記的話,需要將對應DOM節點中的文字置空。發生這種情況的判斷邏輯在beginWork
階段對HostComponent型別的處理常式updateHostComponent中。
如果此次更新渲染需要掛載ref且上一次渲染時也掛載了ref,則需要執行commitDetachRef
。該函數做的事情就是先將上一次渲染時掛載的ref(current.ref.current
)置為null。至於此次渲染需要掛載的ref,是在後續的layout階段
完成的。
根據flags
分類做不同的操作:
插入操作Placement
。呼叫commitPlacement
函數[原始碼]完成DOM的插入。
更新操作Update
。呼叫commitWork
函數[原始碼]。這裡有一些需要關注的地方。
function commitWork(current: Fiber | null, finishedWork: Fiber): void {
switch(finishedWork.tag) {
case FunctionComponent:
case MemoComponent:
// ...
{
// ...
commitHookEffectListUnmount(HookLayout | HookHasEffect, finishedWork);
return;
}
case ClassComponent: {
return;
}
case HostComponent: {
const instance: Instance = finishedWork.stateNode;
if (instance != null) {
const newProps = finishedWork.memoizedProps;
const oldProps = current !== null ? current.memoizedProps : newProps;
const type = finishedWork.type;
const updatePayload: null | UpdatePayload = (finishedWork.updateQueue: any);
finishedWork.updateQueue = null;
if (updatePayload !== null) {
commitUpdate(
instance,
updatePayload,
type,
oldProps,
newProps,
finishedWork,
);
}
}
return;
}
}
}
對於函陣列件FunctionComponent
型別的fiber節點,會呼叫commitHookEffectListUnmount
函數。該函數[原始碼]會遍歷fiber節點的updateQueue
,執行完所有useLayoutEffect
的銷燬函數。由effectList
中的fiber節點順序可知,當子孫節點的mutation 階段完成後才會輪到父節點;因此,在執行函陣列件中的useLayoutEffect銷燬函數時,該函陣列件下對應的DOM的更新操作是已經完成了的。因此,在銷燬函數中可以存取到更新後的這部分DOM內容,但不推薦這麼做。
對於DOM元素HostComponent
型別的fiber節點,取出節點上的updateQueue
來執行commitUpdate
函數。我們在前文講解completeWork
函數中對HostComponent
處理時就說到,對DOM節點的style
、文字子節點、自定義屬性等的更新內容都儲存在了fiber節點的updateQueue
欄位上。而commitUpdate
函數要做的就是將updateQueue
中的更新實際應用到DOM節點上面。
刪除操作Deletion
,需要將fiber節點對應的DOM移除掉。呼叫commitDeletion
函數[原始碼]:
function commitDeletion(
finishedRoot: FiberRoot,
current: Fiber,
renderPriorityLevel: ReactPriorityLevel
): void {
// Recursively delete all host nodes from the parent.
// Detach refs and call componentWillUnmount() on the whole subtree.
unmountHostComponents(finishedRoot, current, renderPriorityLevel);
// 利用detachFiberMutation函數將fiber節點中的屬性清空
const alternate = current.alternate;
detachFiberMutation(current);
if (alternate !== null) {
detachFiberMutation(alternate);
}
}
呼叫unmountHostComponents
函數,然後清空fiber節點屬性。unmountHostComponents
的函數體比較長,就不羅列出來了,其工作就是利用while模擬遞回來迴圈呼叫fiber節點及其子孫節點 並執行以下操作[原始碼]:
對於HostComponent
,直接將DOM節點從父節點中移除掉[unmountHostComponents中1316-1331行]
對於其他型別,則對其呼叫 commitUnmount
函數[原始碼]:
FunctionComponent
型別的fiber節點,排程useEffect
的銷燬函數[commitUnmount中881-908行]ClassComponent
型別的fiber節點,需要解除安裝ref
和呼叫componentWillUnmount
方法[commitUnmount中912-916行]layout階段的外層程式碼同上面兩個階段一樣[commitRootImpl中2081-2105行]。在該階段觸發的生命週期勾點和hook都可以安全地存取到所有更新後頁面上的DOM(在mutation階段中提到過 在當時執行的useLayoutEffect
銷燬函數中可以存取到更新後的DOM,但只是其fiber節點對應的那一部分DOM更新後的內容,並不能確保整個頁面上的DOM是最新的)。
// 切換current fiber樹和workInProgress fiber樹
root.current = finishedWork;
nextEffect = firstEffect;
do {
try {
commitLayoutEffects(root, lanes); // layout階段做的工作都在該函數中
} catch (error) {
// ...處理異常...
nextEffect = nextEffect.nextEffect;
}
} while (nextEffect !== null);
在文章開頭的雙緩衝fiber樹中講過,每當完成一次更新渲染後,就會交換current fiber樹
和workInProgress fiber樹
,執行這項工作的語句正是上面程式碼的第一行:root.current = finishedWork
。時機是mutation階段之後,layout階段開始之前。
進入到commitLayoutEffects
函數中的實現[原始碼]:
function commitLayoutEffects(root: FiberRoot, committedLanes: Lanes) {
while (nextEffect !== null) {
const flags = nextEffect.flags;
// 觸發生命週期勾點和hook
if (flags & (Update | Callback)) {
const current = nextEffect.alternate;
commitLayoutEffectOnFiber(root, current, nextEffect, committedLanes);
}
// 掛載ref
if (flags & Ref) {
commitAttachRef(nextEffect);
}
nextEffect = nextEffect.nextEffect;
}
}
commitLayoutEffects
函數遍歷effectList
,對每個fiber節點做兩件事情:
commitLayoutEffectOnFiber
函數重點關注commitLayoutEffectOnFiber
函數[原始碼]:
function commitLifeCycles(
finishedRoot: FiberRoot,
current: Fiber | null,
finishedWork: Fiber,
committedLanes: Lanes,
): void {
switch(finishedWork.tag) {
case FunctionComponent:
case ForwardRef:
case SimpleMemoComponent:
case Block: {
// 執行useLayoutEffect的回撥函數
commitHookEffectListMount(HookLayout | HookHasEffect, finishedWork);
// 分別收集useEffect的銷燬函數和回撥函數,並再次開啟排程
schedulePassiveEffects(finishedWork);
return;
}
case ClassComponent: {
// 呼叫Class元件的生命週期勾點
const instance = finishedWork.stateNode;
if (finishedWork.flags & Update) {
if (current === null) {
instance.componentDidMount();
} else {
const prevProps =
finishedWork.elementType === finishedWork.type
? current.memoizedProps
: resolveDefaultProps(finishedWork.type, current.memoizedProps);
const prevState = current.memoizedState;
instance.componentDidUpdate(
prevProps,
prevState,
instance.__reactInternalSnapshotBeforeUpdate,
);
}
}
// 執行setState中的第二個引數回撥函數
const updateQueue = finishedWork.updateQueue;
if (updateQueue !== null) {
commitUpdateQueue(finishedWork, updateQueue, instance);
}
}
}
}
對於函陣列件FunctionComponent
,執行所有的useLayoutEffect
的回撥函數。
然後會分別收集useEffect
的銷燬和回撥函數到pendingPassiveHookEffectsUnmount
和pendingPassiveHookEffectsMount
變數中[原始碼]。並且 還會再次開啟useEffect
的排程,這裡的程式碼同前面before mutation階段開啟useEffect
排程的方式一樣:
if (!rootDoesHavePassiveEffects) {
rootDoesHavePassiveEffects = true;
scheduleCallback(NormalSchedulerPriority, () => {
flushPassiveEffects();
return null;
});
}
都是通過判斷rootDoesHavePassiveEffects
全域性變數來判斷是否要開啟排程;因此這兩個階段的開啟操作是互斥的。
正是因為useLayoutEffect
回撥函數是在mutation階段(DOM操作)之後同步執行而useEffect
是排程後非同步執行的,所以可以用useLayoutEffect
這個來解決useEffect
回撥中操作DOM會有閃屏的問題。
對於類元件ClassComponent
,需要區分是mount
還是update
,然後執行生命週期勾點componentDidMount
或componentDidUpdate
。
然後取出fiber節點上的updateQueue
,呼叫其中的回撥函數如setState
的第二個引數中傳入的函數。
此外還有一些其他型別的處理,如對於HostComponent
型別如果是mount
情況會處理其自動聚焦的狀態、對於HostRoot
,會執行根節點渲染的回撥函數 即ReactDOM.render(<App/>, container, () => { ... })
中的第三個引數。
至此,layout階段完結了,整個React從觸發更新至更新渲染完成的流程也結束了~