TLNR#
我希望這篇文章可以幫助讀者理解以下幾點:
- use (layout) Effect 被調用時會創建 effect 對象,且該對象會以鏈表的形式被掛載到 fiber 的 updateQueue 上
- useEffect 由於會被 Scheduler 以 NormalSchedulerPriority 進行調度,因此它是一個異步操作
- useLayoutEffect 的 create 是在 layout 階段被同步處理的,其上一輪的 destroy 是在 mutation 階段被同步處理的,因此在該 hook 內調用耗時操作會阻塞頁面更新
閱前須知#
- useEffect 涉及到與 Scheduler 的互動,因此在理解其執行順序時需要對 react 的調度行為有一定的了解。簡單來說可以這樣理解(不嚴謹的說法會個意即可)
- 所有通過 scheduleCallback 註冊進 Scheduler 的任務都是一個異步任務,且該任務類型為 Task
- react 內存在高優先級插隊機制,對 useEffect 的調度可能被打斷,出現與預期不符合的情況
說明#
依然使用 React hooks: hooks 鏈表 內的示例代碼。
function UseEffectAnduseLayoutEffect() {
const [text, setText] = useState(0);
useEffect(() => {
console.log('useEffect create');
return () => {
console.log('useEffect destroy');
}
}, [text]);
useLayoutEffect(() => {
console.log('useLayoutEffect create');
return () => {
console.log('useLayoutEffect destroy');
}
}, [text]);
return (
<div onClick={ () => { setText(1) } }>{ text }</div>
)
}
根據之前的調試經驗我們知道,每一個 hook 被調用時都會調用 mountWorkInProgressHook 去創建一個 hook 對象,並根據調用順序構成一個鏈表結構,且該 hook 鏈表會被掛載到對應 fiber 對象的 memoizedState 屬性上。
而除此之外每個不同類型的 hook 的邏輯都不相同,這裡只對 useEffect 進行分析。但在此之前我們先對其結構的稱呼做出約定。
use(Layout)Effect(() => { // 約定其第一個參數為 create
return () => { // 約定 create 函數的返回值為 destroy
}
}, []); // 約定其第二個參數為 依賴
effect 鏈表#
構建 effect 鏈表發生在 beginWork 內。
hook 會根據組件是掛載還是更新執行不同的邏輯,對於 use (Layout) Effect 來說在掛載時會調用 mount (Layout) Effect,更新時會調用 update (Layout) Effect。
掛載時生成對應的 effect 鏈表#
我們先對例子中的 useEffect 打入斷點後會進入 mountEffect 內的 mountEffectImpl。
mountEffectImpl(
UpdateEffect | PassiveEffect, // react 中關於權限存在大量的位操作,此處的**或**操作常有賦予權限的意味
HookPassive,
create,
deps,
);
function mountEffectImpl(fiberFlags, hookFlags, create, deps): void {
/**
* hook 鏈表內有分析過因此跳過
*/
const hook = mountWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
/**
* A |= B => A = A | B
*/
currentlyRenderingFiber.flags |= fiberFlags;
/**
* 此處的邏輯是一處重點,涉及到 effect 對象的創建與鏈表的連接
* 還需要注意到的 useEffect 對應的 hook 對象上的 memoizedState 會掛載上 effect 鏈表
* useLayoutEffect 的邏輯和 useEffect 大致相同,只是會對 effect 對象打上不同的標籤而已
*/
hook.memoizedState = pushEffect(
HookHasEffect | hookFlags,
create,
undefined,
nextDeps,
);
}
pushEffect 是構成 effect 鏈表的關鍵,其內生成的 effect 環状鏈表會被掛載到當前組件的 fiber 上的 updateQueue 上,並且 updateQueue.lastEffect 會指向最新生成的 effect 的對象。
effect 對象結構如下
const effect: Effect = {
tag,
create, // use(Layout)Effect 的 create
destroy, // use(Layout)Effect 的 destroy
deps, // use(Layout)Effect 的 依賴
// Circular
next: (null: any), // 指向下一個effect
};
執行完 mountEffectImpl 後,生成的 effect 鏈表會被掛載到兩處,而 useEffect 此時也運行結束
- use (Layout) Effect 對應 hook 元素的 memoizedState
- fiber.updateQueue
而接下來運行的 useLayoutEffect 也與上述步驟一致,因此 effect 鏈表會變成下面這樣
事實上也確實如此
至此掛載階段生成 effect 鏈表的相關邏輯已經結束了。
更新時生成對應的 effect 鏈表#
保持原有的斷點,然後觸發點擊回調,此時會進入 updateEffect 的 updateEffectImpl
updateEffectImpl(
UpdateEffect | PassiveEffect,
HookPassive,
create,
deps,
)
function updateEffectImpl(fiberFlags, hookFlags, create, deps): void {
const hook = updateWorkInProgressHook(); // 更新情況下,會對 currentHook 進行賦值
const nextDeps = deps === undefined ? null : deps;
let destroy = undefined;
if (currentHook !== null) {
const prevEffect = currentHook.memoizedState; // 獲取 currentHook 對應的 effect 對象
destroy = prevEffect.destroy; // 將上一次的 destroy 函數賦值給 wip 並在這一次調用
if (nextDeps !== null) {
const prevDeps = prevEffect.deps;
if (areHookInputsEqual(nextDeps, prevDeps)) { // 一一比較新舊的dep,不過是淺對比
pushEffect(hookFlags, create, destroy, nextDeps);
return;
}
}
}
currentlyRenderingFiber.flags |= fiberFlags;
hook.memoizedState = pushEffect(
HookHasEffect | hookFlags, // HookHasEffect 標記更新
create,
destroy,
nextDeps,
);
}
和 mount 不同,update 的 pushEffect 會帶上上一次的 destroy 函數。
關於 useEffect 內 create 與 destroy 運行順序#
此時我們對 useEffect 的 create 函數打上斷點來查看它的調用棧
此時我們注意到 flushPassiveEffectsImpl 這一函數,該函數在 flushPassiveEffects 內以 NormalSchedulerPriority 級別的優先級被調度,因此 flushPassiveEffectsImpl 會是一個異步任務,且優先級不高。但當前例子不需要考慮被插隊的情況。
通過對 flushPassiveEffects 進行全局的查找(線索是 pendingPassiveHookEffectsMount 的賦值),最後定位調用位置為 commitBeforeMutationEffects
查詢異步操作的調用很麻煩,但最後還是定位在了 commitRoot ——> commitRootImpl ——> commitBeforeMutationEffects。該函數是 BeforeMutation 階段的入口。
收集 effect#
在執行 flushPassiveEffectsImpl 清空 effect 前,首先需要對 fiber 上的 effect 鏈表進行收集,這一操作發生在 schedulePassiveEffects 內。由於 schedulePassiveEffects 是在 commitLayoutEffects ——> commitLifeCycles 內被調用,因此可以視為在 layout 階段內被調用
/**
* 此處的 finishedWork 為 UseEffectAnduseLayoutEffect 的 fiber 對象
*/
function schedulePassiveEffects(finishedWork: Fiber) {
/**
* fiber 的 updateQueue上掛載了對應的 effect 鏈表
*/
const updateQueue: FunctionComponentUpdateQueue | null = (finishedWork.updateQueue: any);
const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
if (lastEffect !== null) {
// lastEffect這一指針是指向最後一個 effect 對象上的,因此通過 next 可以直接獲取到第一個元素
const firstEffect = lastEffect.next;
let effect = firstEffect;
do {
const {next, tag} = effect;
if (
(tag & HookPassive) !== NoHookEffect && // 過濾了非 use(Layout)Effect 創建的 effect 對象
(tag & HookHasEffect) !== NoHookEffect // 過濾了依賴未發生變動的 effect 對象
) {
enqueuePendingPassiveHookEffectUnmount(finishedWork, effect); // 給pendingPassiveHookEffectsUnmount賦值
enqueuePendingPassiveHookEffectMount(finishedWork, effect);// 給pendingPassiveHookEffectsMount賦值
// 目前執行完 pendingPassiveHookEffectsUnmount 與 pendingPassiveHookEffectsMount數據一致
}
effect = next;
} while (effect !== firstEffect); // 環状鏈表循環一圈停止
}
}
執行 effect#
直接對 flushPassiveEffectsImpl 進行調試,下面的代碼已經刪除了多餘的部分。該函數內部涉及到了對 effect 對象上的 create 與 destroy 執行的邏輯。
function flushPassiveEffectsImpl() {
...
/**
* 執行 effect 的 destroy 函數
* 在掛載時,由於在執行 pushEffect 時第三個參數為 undefined,effect 對象上的 destroy 屬性為空。因此 destroy 不會被執行
* 但是在更新時,會傳入由 create 返回的 destroy 函數,因此會執行。下面會提到。
*/
const unmountEffects = pendingPassiveHookEffectsUnmount;
pendingPassiveHookEffectsUnmount = [];
for (let i = 0; i < unmountEffects.length; i += 2) {
const effect = ((unmountEffects[i]: any): HookEffect);
const fiber = ((unmountEffects[i + 1]: any): Fiber);
const destroy = effect.destroy;
effect.destroy = undefined;
if (typeof destroy === 'function') {
if (
enableProfilerTimer &&
enableProfilerCommitHooks &&
fiber.mode & ProfileMode
) {
try {
startPassiveEffectTimer();
destroy();
} finally {
recordPassiveEffectDuration(fiber);
}
} else {
destroy();
}
}
}
/**
* 執行 effect 的 create 函數
*/
const mountEffects = pendingPassiveHookEffectsMount;
pendingPassiveHookEffectsMount = [];
for (let i = 0; i < mountEffects.length; i += 2) {
const effect = ((mountEffects[i]: any): HookEffect);
const fiber = ((mountEffects[i + 1]: any): Fiber);
const create = effect.create;
if (
enableProfilerTimer &&
enableProfilerCommitHooks &&
fiber.mode & ProfileMode
) {
try {
startPassiveEffectTimer();
/**
* create 的返回值是 destroy,如果這個 effect 對象因為 update 再次被收集後,
* 會在下一次flushPassiveEffectsImpl時被指向
*/
effect.destroy = create();
} finally {
recordPassiveEffectDuration(fiber);
}
} else {
effect.destroy = create();
}
}
...
return true;
}
結論#
- useEffect 是異步操作,清空副作用的操作(flushPassiveEffectsImpl)會被註冊為一個 Task
- layout 階段中 effect 會被收集進執行數組,但是調度發生在 before Mutation 調度
- 清空副作用的操作(flushPassiveEffectsImpl)的調度優先級不高,所以有可能在調度時有可能被更高優先級的任務插隊
- 掛載時,只會執行 create 的函數;更新時,會執行 currentHook 的 destroy,然後執行 newHook 的 create
關於 useLayoutEffect 的運行時機#
useLayoutEffect 除了觸發時機,其他表現與 useEffect 是一致的。
此時我們對 useLayoutEffect 的 create 函數打上斷點來查看它的調用棧
此時對 commitHookEffectListMount 打斷點,可發現都是同步操作。(commitLayoutEffects 是 layout 的入口)
function commitHookEffectListMount(tag: number, finishedWork: Fiber) {
const updateQueue: FunctionComponentUpdateQueue | null = (finishedWork.updateQueue: any);
const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
if (lastEffect !== null) {
const firstEffect = lastEffect.next;
let effect = firstEffect;
do {
// 傳入的 tag 是 HookLayout | HookHasEffect,因此可過濾出 useLayoutEffect 的 effect
if ((effect.tag & tag) === tag) {
// Mount
const create = effect.create;
effect.destroy = create();
}
effect = effect.next;
} while (effect !== firstEffect); // 循環一圈
}
}
如果對 useLayoutEffect 的 destroy 函數打上斷點
此時對 commitHookEffectListUnmount 打斷點,可發現都是同步操作。(commitMutationEffects 是 mutation 的入口)
function commitHookEffectListUnmount(tag: number, finishedWork: Fiber) {
const updateQueue: FunctionComponentUpdateQueue | null = (finishedWork.updateQueue: any);
const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
if (lastEffect !== null) {
const firstEffect = lastEffect.next;
let effect = firstEffect;
do {
if ((effect.tag & tag) === tag) {
// Unmount
const destroy = effect.destroy;
effect.destroy = undefined;
if (destroy !== undefined) {
destroy();
}
}
effect = effect.next;
} while (effect !== firstEffect);
}
}
結論#
- useLayoutEffect 是同步調用的,其 destroy 在 mutation 階段調用,其 create 在 layout 階段調用