面試官:useEffect和useLayoutEffect有什么區別?你能說說嗎?
Effect數據結構
顧名思義,React底層在函數式組件的Fiber節點設計中帶入了hooks鏈表的概念(memorizedState),在此變量上專門存儲每一個函數式組件對應的鏈表。
而對于副作用(useEffect or useLayoutEffect)來說,對應其hook類型就是Effect。
單個的effect對象包括以下幾個屬性:
- create: 傳入useEffect or useLayoutEffect函數的第一個參數,即回調函數;
- destroy: 回調函數return的函數,在該effect銷毀的時候執行,渲染階段為undefined;
- deps: 依賴項,改變重新執行副作用;
- next: 指向下一個effect;
- tag: effect的類型,區分是useEffect還是useLayoutEffect;
單純看這些字段,和平時使用層面來聯想還是很通俗易懂的,這里還是補充一下hooks鏈表的概念,有如下的例子:
const Hello = () => {
const [ text, setText ] = useState('hello')
useEffect(() => {
console.log('effect1')
return () => {
console.log('destory1');
}
})
useLayoutEffect(() => {
console.log('effect2')
return () => {
console.log('destory2');
}
})
return <div>effect</div>
}
掛載到Hello組件fiber上memoizedState如下:
圖片
可以看到,打印出來結果和組件中聲明hook的順序是一樣的,不難看出這是一個鏈表,這也是為什么react hook要求hook的使用不能放在條件分支語句中的原因,如果第一次mount走的是A情況,第二次updateMount走的是B情況,就會出現hooks鏈表混亂的情況,保證官方范式是比較重要的原因。
Hook
從上圖的例子中可以看到,memorizedState的值會根據不同hook來決定。
- 使用useState時,memorizedState對應是string(hello);
- 使用useEffect和useLayoutEffect,對應的是Effect;
Hook類型如下:
export type Hook = {
memoizedState: any, // Hook 自身維護的狀態
baseQueue: any,
baseState: any,
queue: UpdateQueue<any, any> | null, // Hook 自身維護的更新隊列
next: Hook | null, // next 指向下一個 Hook
};
創建副作用流程
基于上面的數據結構,對于use(Layout)Effect來說,React做的事情就是
- render階段:函數組件開始渲染的時候,創建出對應的hook鏈表掛載到workInProgress的memoizedState上,并創建effect鏈表,也就是掛載到對應的fiber節點上,但是基于上次和本次依賴項的比較結果, 創建的effect是有差異的。這一點暫且可以理解為:依賴項有變化,effect可以被處理,否則不會被處理。
- commit階段:異步調度useEffect或者同步處理useLayoutEffect的effect。等到commit階段完成后,更新應用到頁面上之后,開始處理useEffect產生的effect,或是直接處理commit階段同步執行阻塞頁面更新的useLayoutEffect產生的effect。
第二點提到了一個重點,就是useEffect和useLayoutEffect的執行時機不一樣,前者被異步調度,當頁面渲染完成后再去執行,不會阻塞頁面渲染。 后者是在commit階段新的DOM準備完成,但還未渲染到屏幕之前,同步執行。
創建effect鏈表
useEffect的工作是在currentlyRenderingFiber加載當前的hook,具體流程就是判斷當前fiber是否已經存在hook(就是判斷fiber.memoizedState),存在的話則創建一個effect hook到鏈表的最后,也就是.next,沒有的話則創建一個memoizedState。
先看一下創建一個Effect的入口函數:
function mountEffect(
create: () => (() => void) | void,
deps: Array<mixed> | void | null
): void {
return mountEffectImpl(
UpdateEffect | PassiveEffect,
HookPassive,
create,
deps,
);
};
可以看到本質上是調用了mountEffectImpl函數,傳了上一節所說的Effect type中的字段,這里有個問題,為什么destroy沒傳呢?獲取上一次effect的destroy函數,也就是useEffect回調中return的函數,在創建階段是第一次,所以為undefined。
這里看一下創建階段調用的mountEffectImpl函數:
function mountEffectImpl(fiberFlags, hookFlags, create, deps): void {
// 創建hook對象
const hook = mountWorkInProgressHook();
// 獲取依賴
const nextDeps = deps === undefined ? null : deps;
// 為fiber打上副作用的effectTag
currentlyRenderingFiber.flags |= fiberFlags;
// 創建effect鏈表,掛載到hook的memoizedState上和fiber的updateQueue
hook.memoizedState = pushEffect(
HookHasEffect | hookFlags,
create,
undefined,
nextDeps,
);
}
接下來我們都知道,React或Vue都是狀態改變導致頁面重渲染,而useEffect or useLayoutEffect都會會根據deps變化重新執行,所以猜都猜得到,在更新時調用的updateEffectImpl函數,對比mountEffectImpl函數多出來的一部分內容其實就是對比上一次的Effect的依賴變化,以及執行上一次Effect中的destroy部分內容~代碼如下:
function updateEffectImpl(fiberFlags, hookFlags, create, deps): void {
const hook = updateWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
let destroy = undefined;
if (currentHook !== null) {
// 從currentHook中獲取上一次的effect
const prevEffect = currentHook.memoizedState;
// 獲取上一次effect的destory函數,也就是useEffect回調中return的函數
destroy = prevEffect.destroy;
if (nextDeps !== null) {
const prevDeps = prevEffect.deps;
// 比較前后依賴,push一個不帶HookHasEffect的effect
if (areHookInputsEqual(nextDeps, prevDeps)) {
pushEffect(hookFlags, create, destroy, nextDeps);
return;
}
}
}
currentlyRenderingFiber.flags |= fiberFlags;
// 如果前后依賴有變,在effect的tag中加入HookHasEffect
// 并將新的effect更新到hook.memoizedState上
hook.memoizedState = pushEffect(
HookHasEffect | hookFlags,
create,
destroy,
nextDeps,
);
}
可以看到在mountEffectImpl和updateEffectImpl中,最后的結果走向都是pushEffect函數,它的工作很純粹,就是創建出effect對象,把對象掛到鏈表中。
pushEffect代碼如下:
function pushEffect(tag, create, destroy, deps) {
// 創建effect對象
const effect: Effect = {
tag,
create,
destroy,
deps,
// Circular
next: (null: any),
};
// 從workInProgress節點上獲取到updateQueue,為構建鏈表做準備
let componentUpdateQueue: null | FunctionComponentUpdateQueue = (currentlyRenderingFiber.updateQueue: any);
if (componentUpdateQueue === null) {
// 如果updateQueue為空,把effect放到鏈表中,和它自己形成閉環
componentUpdateQueue = createFunctionComponentUpdateQueue();
// 將updateQueue賦值給WIP節點的updateQueue,實現effect鏈表的掛載
currentlyRenderingFiber.updateQueue = (componentUpdateQueue: any);
componentUpdateQueue.lastEffect = effect.next = effect;
} else {
// updateQueue不為空,將effect接到鏈表的后邊
const lastEffect = componentUpdateQueue.lastEffect;
if (lastEffect === null) {
componentUpdateQueue.lastEffect = effect.next = effect;
} else {
const firstEffect = lastEffect.next;
lastEffect.next = effect;
effect.next = firstEffect;
componentUpdateQueue.lastEffect = effect;
}
}
return effect;
}
這里的主要邏輯其實就是本節開頭所說的,區分兩種情況,鏈表為空或鏈表存在的情況,值得一提的是這里的updateQueue是一個環形鏈表。
以上,就是effect鏈表的構建過程。我們可以看到,effect對象創建出來最終會以兩種形式放到兩個地方:單個的effect,放到hook.memorizedState上;環狀的effect鏈表,放到fiber節點的updateQueue中。兩者各有用途,前者的effect會作為上次更新的effect,為本次創建effect對象提供參照(對比依賴項數組),后者的effect鏈表會作為最終被執行的主體,帶到commit階段處理。
提交階段
commitRoot
當我們完成更新,進入提交重渲染視圖時,主要在commitRoot函數中執行,而在這之前創建Effect以及插入到hooks鏈表中,useEffect和useLayoutEffect其實做的都是一樣的,也是共用的,在提交階段,我們會看出兩者執行時機不同的實現點。
// src/react-reconciler/src/ReactFiberWorkLoop.js
function commitRoot(root) {
// 已經完成構建的fiber,上面會包括hook信息
const { finishedWork } = root;
// 如果存在useEffect或者useLayoutEffect
if ((finishedWork.flags & Passive) !== NoFlags) {
if (!rootDoesHavePassiveEffect) {
rootDoesHavePassiveEffect = true;
// 開啟下一個宏任務
requestIdleCallback(flushPassiveEffect);
}
}
console.log('start commit.');
// 判斷自己身上有沒有副作用
const rootHasEffect = (finishedWork.flags & MutationMask) !== NoFlags;
// 如果自己的副作用或者子節點有副作用就進行DOM操作
if (rootHasEffect) {
console.log('DOM執行完畢');
commitMutationEffectsOnFiber(finishedWork, root);
// 執行layout Effect
console.log('開始執行layoutEffect');
commitLayoutEffects(finishedWork, root);
if (rootDoesHavePassiveEffect) {
rootDoesHavePassiveEffect = false;
rootWithPendingPassiveEffects = root;
}
}
// 等DOM變更之后,更改root中current的指向
root.current = finishedWork;
}
這里的rootDoesHavePassiveEffect是核心判斷點,還記得Effect類型中的tag參數嗎?就是依靠這個參數來標識區分useEffect和useLayoutEffect的。
rootDoesHavePassiveEffect === false,則執行宏任務,將Effect副作用推入宏任務執行棧中。我們可以簡單理解成useEffect的回調函數包裝在了requestIdleCallback中去異步執行,根據fiber的知識接下來會去走瀏覽器當前幀是否有空余時間來判斷副作用函數的執行時機。
繼續往下走,如果rootHasEffect === true,代表有副作用,如果是useEffect,副作用已經在上面進入宏任務隊列了,所以如果是useLayoutEffect,就會在這個條件中去執行,所以在這里我們可以理解到那一句"useEffect和useLayoutEffect的區別是,前者會異步執行副作用函數不會阻塞頁面更新,后者會立即執行副作用函數,會阻塞頁面更新,不適合寫入復雜邏輯。"的原因了。
結尾
useEffect與useLayoutEffect十分相似,就連簽名都一樣,不同之處就在于前者會在瀏覽器繪制后延遲執行,而后者會在所有DOM變更之后同步調用effect,希望你看到這里,可以對于這個結論的來源有一定的了解和學習,希望可以幫到你~