說明#
-
封面圖是我家的貓妹妹
-
React 版本 v18.1.0
-
我們稱 React.lazy 引入的組件為 lazyComponent , Suspense 上的 fallback 內的組件為 fallbackComponent
-
分析基於下述代碼
/** * src/components/suspense/OtherComponent.js */ import React from 'react'; function OtherComponent() { return ( <div> OtherComponent </div> ); } export default OtherComponent; /** * src/components/suspense/index.js */ import React, { Suspense } from 'react'; const OtherComponent = React.lazy(() => import('./OtherComponent')); function SuspenseTest() { return ( <div> <Suspense fallback={<div>Loading...</div>}> <OtherComponent /> </Suspense> </div> ); } export default SuspenseTest;
TLNR#
在 beginWork 創建 fiber 的過程中第一次遭遇 lazyComponent 時會 throw 一個 pending 狀態的 promise,該 promise 對象會被外置的 try...catch... (sync 模式下為 renderRootSync 內、concurrent 模式下為 renderRootConcurrent 內) 捕獲,隨後向上遍歷將途徑的所有 fiber 節點打上 Incomplete 標籤直到 Suspense 節點為止(completeUnitOfWork),再從該節點重新向下進行 beginWork 創建 fiber 而此時將創建 Suspense 的 fallback 節點,隨後在 mutation 階段 (commitMutationEffects) 對先前拋出的 promise 增加成功回調以支持 異步組件成功加載後重新渲染。
React.lazy#
React.lazy 在對 Suspense 的分析過程中並不是什麼重要的點,因此只需要參考其 ts 聲明了解出入參類型。
function lazy<T extends ComponentType<any>>(
factory: () => Promise<{ default: T }>
): LazyExoticComponent<T>;
如果對示例代碼中的 OtherComponent 打點,你可以更直觀的看到它的值,其中我們只需要關注 $$typeof 即可。
The first pass#
標題取自 updateSuspenseComponent 內的註釋,該函數是在 beginWork 運行至 Suspense 節點時被調用,其內部邏輯在 First pass 中與 Second pass 中分別有著不同的邏輯(在 The second pass 中其會創建 LazyComponent 上的 fallback 對應的節點),不過在 The first pass 中其會先創建 offScreen,文中的例子在經過 First pass 階段後,我們可以獲得下圖結構的 fiber 樹
其中 lazy 這一 fiber 對應的就是通過 React.lazy 引入的 OtherComponent,而此時它還只是個佔位符,不能被稱為一個組件。當 beginWork 運行至 lazy 處時才會調用引入組件的邏輯(代碼在 mountLazyComponent)。
function mountLazyComponent(
_current,
workInProgress,
elementType,
renderLanes,
) {
resetSuspendedCurrentOnMountInLegacyMode(_current, workInProgress);
const props = workInProgress.pendingProps;
const lazyComponent: LazyComponentType<any, any> = elementType;
const payload = lazyComponent._payload;
const init = lazyComponent._init;
let Component = init(payload);
...
// 底下的不用關注,init 函數內就會通過 throw 中斷,見下面的代碼
}
// mountLazyComponent 內調用的 init
function lazyInitializer<T>(payload: Payload<T>): T {
if (payload._status === Uninitialized) {
// 此時的 payload._result 為 import OtherComponent 部分,其調用將返回一個 pending 的 promise
const ctor = payload._result;
const thenable = ctor();
// 此處為 promise 實例增加了回調,但加載成功之後重新渲染的邏輯並不在此處
thenable.then(
moduleObject => {
if (payload._status === Pending || payload._status === Uninitialized) {
// Transition to the next state.
const resolved: ResolvedPayload<T> = (payload: any);
resolved._status = Resolved;
resolved._result = moduleObject;
}
},
error => {
if (payload._status === Pending || payload._status === Uninitialized) {
// Transition to the next state.
const rejected: RejectedPayload = (payload: any);
rejected._status = Rejected;
rejected._result = error;
}
},
);
if (payload._status === Uninitialized) {
// first pass 時會進入此邏輯,將狀態由 Uninitialized 修改為 Pending
const pending: PendingPayload = (payload: any);
pending._status = Pending;
pending._result = thenable;
}
}
if (payload._status === Resolved) {
const moduleObject = payload._result;
...dev 內容
return moduleObject.default;
} else {
// first pass 將會將 pending 狀態的 promise 丟出去
// 並被外置 try catch 捕獲
throw payload._result;
}
}
上面代碼塊 ctor 對應的函數
對應的調用棧
錯誤捕獲 —— throwException#
錯誤捕獲發生在 renderRoot 的 handleError 內,在該函數內的 throwException 會先將 lazy fiber 打上 Incomplete 的標記並找到最近的 Suspense 節點打上 shouldCapture 的標記(該標記將在 The second pass 內起作用)。 將會從 lazy fiber 處開始向上遍歷並將沿途的所有 fiber 節點打上 Incomplete 標記,直到遇到 Suspense 節點結束(Suspense 節點除了 Incomplete 外還會被標記 shouldCapture,)
如果將整段 handleError 貼上來分析會很長,因此下面我只截取關鍵步驟以證明
給 lazyComponent 標記 Incomplete#
查找最近的 SuspenseComponent 標記 shouldCapture#
throwException 內的一些小細節#
ConcurrentMode 下的 attachPingListener#
attachPingListener 只有在 ConcurrentMode 下才會被調用,因為異步組件的請求有可能在 react 進入 commit 階段時便返回,而在 ConcurrentMode 模式下是允許高優先級任務插隊的,如果返回請求所對應的優先級高於正在進行中的任務,那麼就會被插隊處理
function attachPingListener(root: FiberRoot, wakeable: Wakeable, lanes: Lanes) {
// 底下是維護 pingCache 的邏輯,並非閱讀重點
let pingCache = root.pingCache;
let threadIDs;
if (pingCache === null) {
pingCache = root.pingCache = new PossiblyWeakMap();
threadIDs = new Set();
pingCache.set(wakeable, threadIDs);
} else {
threadIDs = pingCache.get(wakeable);
if (threadIDs === undefined) {
threadIDs = new Set();
pingCache.set(wakeable, threadIDs);
}
}
if (!threadIDs.has(lanes)) {
// Memoize using the thread ID to prevent redundant listeners.
threadIDs.add(lanes);
// ping 函數內會通過對 ensureRootIsScheduled 的調用來觸發任務調度
// ensureRootIsScheduled 的解析可以看我的 [useState](https://github.com/IWSR/react-code-debug/issues/3) 的解析
const ping = pingSuspendedRoot.bind(null, root, wakeable, lanes);
if (enableUpdaterTracking) {
if (isDevToolsPresent) {
// If we have pending work still, restore the original updaters
restorePendingUpdaters(root, lanes);
}
}
// 此處的 wakeable 是之前 pending 的 promise,也就是說 ConcurrentMode 模式下異步組件的成功請求也可能觸發 ensureRootIsScheduled
wakeable.then(ping, ping);
}
}
attachRetryListener#
attachRetryListener 主要是將 pending 狀態的 promise 掛到 Suspense fiber 的 updateQueue 上,這與其他 fiber 有些不太類似(畢竟都是拿來裝 update 實例構成的鏈表的)。
function attachRetryListener(
suspenseBoundary: Fiber,
root: FiberRoot,
wakeable: Wakeable,
lanes: Lanes,
) {
// Retry listener
//
// If the fallback does commit, we need to attach a different type of
// listener. This one schedules an update on the Suspense boundary to turn
// the fallback state off.
//
// Stash the wakeable on the boundary fiber so we can access it in the
// commit phase.
//
// When the wakeable resolves, we'll attempt to render the boundary
// again ("retry").
const wakeables: Set<Wakeable> | null = (suspenseBoundary.updateQueue: any);
if (wakeables === null) {
const updateQueue = (new Set(): any);
updateQueue.add(wakeable);
suspenseBoundary.updateQueue = updateQueue;
} else {
wakeables.add(wakeable);
}
}
錯誤捕獲 —— completeUnitOfWork#
在 throwException 之後會調用 completeUnitOfWork,該函數在此處的作用是從 lazy Component 出發向上遍歷並將沿途的所有 fiber 節點都標記上 Incomplete 標記,但當其遇到帶有 ShouldCapture 標記的節點後便會將其上關於錯誤處理的標記消除(next.flags &= HostEffectMask)然後再從這個節點進入 beginWork。其也是 completeWork 的入口函數,是 react 中的一個關鍵函數。
function completeUnitOfWork(unitOfWork: Fiber): void {
let completedWork = unitOfWork;
do {
const current = completedWork.alternate; // WIP 對應的 current節點
const returnFiber = completedWork.return; // 父節點
if ((completedWork.flags & Incomplete) === NoFlags) {
// 如果當前節點沒有出錯(沒有標記 Incomplete ),進入該邏輯
...
let next;
... 刪掉了不重要的部分
// completeWork 的作用大致為生成DOM 更新props 綁定事件,詳細的建議看別的文章
next = completeWork(current, completedWork, subtreeRenderLanes);
if (next !== null) {
// Completing this fiber spawned new work. Work on that next.
workInProgress = next;
return;
}
} else {
// 運行至此代表節點帶有 Incomplete 標記
// 當 unwindWork 遇到帶有 ShouldCapture 標記的節點時會將該節點標記 DidCapture 後返回
// unwindWork 內的 (flags & ~ShouldCapture) | DidCapture 這段標記的 DidCapture
// 而帶有 ShouldCapture 標記的節點通常也被稱為錯誤邊界,可以說 unwindWork 這個函數的作用
const next = unwindWork(current, completedWork, subtreeRenderLanes);
// Because this fiber did not complete, don't reset its lanes.
if (next !== null) {
/**
* 在這個例子內 SuspenseComponent 帶有 ShouldCapture 因此在遇到 SuspenseComponent 後將其上與錯誤處理相關的標記清除
*
* 關於標記清除的邏輯可以看下面
*
* 0b0000000000001000000000000000 Incomplete
* 0b0000000000010000000000000000 ShouldCapture
* 0b0000000000011000000000000000 同時帶有 Incomplete 與 ShouldCapture 的標記的節點
* 0b0000000000000111111111111111 HostEffectMask
* 0b0000000000000000000000000000 如果將上面兩個標記進行按位與(=&)能得到的,很明顯能看到 Incomplete 與 ShouldCapture 標記位消失了
*/
next.flags &= HostEffectMask;
// 此處為將 suspense 節點賦值給 workInProgress
workInProgress = next;
// return 出去後會再次進入 workLoopSync/workLoopConcurrent,隨後再從 workInProgress 開始進行 beginWork
// 也就是說,從這裡開始我們將再此對 suspense 節點進行 beginWork,而這也代表著 The second pass 的開始
return;
}
... 省略不必要的邏輯
if (returnFiber !== null) {
// Mark the parent fiber as incomplete and clear its subtree flags.
// 給當前 fiber 的父節點標記 Incomplete
returnFiber.flags |= Incomplete;
returnFiber.subtreeFlags = NoFlags;
returnFiber.deletions = null;
} else {
// We've unwound all the way to the root.
workInProgressRootExitStatus = RootDidNotComplete;
workInProgress = null;
return;
}
}
const siblingFiber = completedWork.sibling;
if (siblingFiber !== null) {
// If there is more work to do in this returnFiber, do that next.
// 如果存在兄弟節點,對兄弟節點進行 beginWork,見 workLoopSync/workLoopConcurrent 內 while 的條件
workInProgress = siblingFiber;
return;
}
// Otherwise, return to the parent
// workInProgress 指向當前節點的父節點 然後繼續 do...while... 循環
completedWork = returnFiber;
// Update the next thing we're working on in case something throws.
workInProgress = completedWork;
} while (completedWork !== null);
// 已經到 root 節點了,標記 workInProgressRootExitStatus 為完成狀態
if (workInProgressRootExitStatus === RootInProgress) {
workInProgressRootExitStatus = RootCompleted;
}
}
The second pass#
在經過 completeUnitOfWork 的處理後,Suspense 節點上已經被標記了 DidCapture,而在 The second pass 中 beginWork 會再次從 Suspense 節點開始創建 fiber 節點(updateSuspenseComponent ),由於 DidCapture 的影響,updateSuspenseComponent 也會進入 first pass 跳過的邏輯去處理 fallback 的內容。
function updateSuspenseComponent(current, workInProgress, renderLanes) {
const nextProps = workInProgress.pendingProps;
let suspenseContext: SuspenseContext = suspenseStackCursor.current;
let showFallback = false;
// 判斷當前 fiber 上是否標記 DidCapture
const didSuspend = (workInProgress.flags & DidCapture) !== NoFlags;
if (
didSuspend ||
shouldRemainOnFallback(
suspenseContext,
current,
workInProgress,
renderLanes,
)
) {
// 存在 DidCapture 標記,因此需要 showFallback
showFallback = true;
// 將 DidCapture 從節點標記上清除
workInProgress.flags &= ~DidCapture;
} else {
... The second pass 不會進入就刪了
}
...
if (current === null) {
... 刪除 hydrate 相關代碼
// This could've been a dehydrated suspense component.
const suspenseState: null | SuspenseState = workInProgress.memoizedState;
...
const nextPrimaryChildren = nextProps.children;
const nextFallbackChildren = nextProps.fallback;
if (showFallback) {
// second pass 時 showFallback 為 true
// 這裡返回的 fallback 的 fiber,底下截圖有介紹內部的大致邏輯
const fallbackFragment = mountSuspenseFallbackChildren(
workInProgress,
nextPrimaryChildren,
nextFallbackChildren,
renderLanes,
);
const primaryChildFragment: Fiber = (workInProgress.child: any);
primaryChildFragment.memoizedState = mountSuspenseOffscreenState(
renderLanes,
);
workInProgress.memoizedState = SUSPENDED_MARKER;
...
// 接下來要創建 fallback 的節點了
return fallbackFragment;
} else if (
enableCPUSuspense &&
typeof nextProps.unstable_expectedLoadTime === 'number'
) {
...
} else {
...
}
} else {
...
}
mountSuspenseFallbackChildren 內的大致邏輯
在經過上述邏輯後,其結構如下
而在 The second pass 結束後,其 fiber 樹結構為下圖
關於 promise resolve 之後觸發的重新渲染#
當異步組件加載結束後,也就是之前那個 pending 的 promise 實例狀態變更後,需要添加一個對應的成功回調以觸發更新,而這個回調函數是在 commit 階段(commitRootImpl)內的 mutation 小階段(commitMutationEffects)內註冊進 promise 內的,具體位置存在於 attachSuspenseRetryListeners 內。
圖中的 retry 函數為 resolveRetryWakeable (位置在 ReactFiberWorkLoop.old.js 內)
ensureRootIsScheduled 相關的可以看我的 React Hooks: useState 分析 的解析