本文將從useEffect的‘閃爍’問題切入,通過devtools并結合源碼來分析useEffect與useLayoutEffect的執行細節,最后總結業務開發中二者的適用場景。
閃爍問題
示例demo:https://stackblitz.com/edit/react-tekbkz?file=index.js
當我們點擊div時,偶爾會看到視圖先變為0再變為隨機值的過程,這就是useEffect的閃爍問題,下面通過detools分析上述demo中瀏覽器的工作流程
可以看到,在點擊事件中setState,react進行一次render流程,視圖更新并觸發瀏覽器的布局和繪制。視圖變為0。同時觸發useEffect的執行再次setState修改視圖,又經歷一次render流程并觸發瀏覽器布局繪制,視圖變為隨機值。兩次連續的繪制產生閃動問題并增加了性能損耗。 因此我們可以總結此場景的觸發條件為:useEffect執行的上一幀中修改了視圖,且useEffect中再次修改視圖。接下來我們通過源碼分析下useEffect的執行細節。
源碼分析
react的一次狀態更新的流程簡單概括就是構造fiber樹(render),渲染fiber樹(commit),前文已有過介紹,我們暫不關注優先級調度的流程。commit階段的入口函數是commitRootImpl,不關心其他邏輯,只看effects的相關處理
function commitRootImpl(root, renderPriorityLevel) {
// 調度useEffect
scheduleCallback(NormalSchedulerPriority, () => {
flushPassiveEffects();
});
// 處理突變
// 處理前
commitBeforeMutationEffects(root, finishedWork);
// 處理
commitMutationEffects(root, finishedWork, lanes);
// 處理后,此時代表當前更新后界面的fiber樹已渲染完成
commitLayoutEffects(finishedWork, root, lanes);
// 檢測并執行同步任務
flushSyncCallbacks();
}
scheduleCallback 是react調度器(Scheduler)的一個api,它最終會以一個宏任務(MessageChannel)來異步調度傳入的回調函數,使得該回調在下一輪事件循環中執行,彼時瀏覽器已經繪制過一次。
...
const channel = new MessageChannel();
const port = channel.port2;
// performWorkUntilDeadline中將具體執行被調度的任務
channel.port1.onmessage = performWorkUntilDeadline
...
// 觸發
port.postMessage(null)
這里調度的函數是flushPassiveEffects,它執行后終會調用如下兩個函數:
commitHookEffectListUnmount(HookPassive | HookHasEffect, finishedWork, finishedWork.return);
commitHookEffectListMount(HookPassive | HookHasEffect, finishedWork)
拿其中一個分析:commitHookEffectListMount
function commitHookEffectListMount(flags: HookFlags, finishedWork: Fiber) {
// flags是副作用標識,HookPassive是useEffect的標識
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 & flags) === flags) {
const create = effect.create;
// 調用副作用的create函數,將返回的銷毀函數掛到destroy上
effect.destroy = create();
}
effect = effect.next;
} while (effect !== firstEffect);
}
}
相應的commitHookEffectListUnmount用于執行effect的destroy函數,即flushPassiveEffects的職責是執行useEffect上次調用產生的銷毀函數與本次的create函數。因此可以明確useEffect中指定的回調會在dom渲染結束且瀏覽器繪制后異步執行,先執行上次更新產生的destory函數,再執行本次的create函數。
那閃動問題如何解決呢?我們可以考慮另一個hook:useLayoutEffect。我們關注下layout階段的主處理函數commitLayoutEffects,他內部會對每個遍歷到的fiber執行commitLayoutEffectOnFiber
function commitLayoutEffectOnFiber(
finishedRoot: FiberRoot,
current: Fiber | null,
finishedWork: Fiber,
committedLanes: Lanes,
): void {
if ((finishedWork.flags & LayoutMask) !== NoFlags) {
switch (finishedWork.tag) {
case FunctionComponent:
case SimpleMemoComponent: {
// HookLayout是useLayoutEffect的標識
commitHookEffectListMount(
HookLayout | HookHasEffect,
finishedWork,
);
break;
}
}
}
}
我們發現useLayoutEffect的create函數在layout階段同步執行,我們已經知道commitRootImpl最后階段會執行flushSyncCallbacks檢測并執行同步任務,而useLayoutEffect中觸發的調度任務(setState)將是同步的優先級, 因此如果我們在useLayouteffect中setState將會直接重新發起render的流程而不是異步執行,即useLayoutEffect的create函數中觸發的任何動作都會在本輪事件循環中同步執行。
下面將demo中的hook改為useLayoutEffect:
https://stackblitz.com/edit/react-qnje3r?file=index.js
可以看到視圖不會出現0的中間狀態,通過devtools發現整個過程中瀏覽器只繪制了一次。因此可以總結:useLayoutEffect中觸發調度會立即進入同步調度邏輯, 相當于放棄本次渲染結果,不產生中間狀態,瀏覽器只進行一次繪制。
使用總結
相比useEffect,useLayoutEffect無論銷毀函數和回調函數的執行時機都要更早一些,且會在commit階段中同步執行。因此useLayoutEffects中適合進行一些可能影響dom的操作,因為在其create中可以獲取到最新的dom樹且由于此時瀏覽器未進行繪制(本輪事件循環尚未結束),因此不會有中間狀態的產生,可以有效的避免閃動問題。因此當業務中出現需要在effect中修改視圖,且執行的上一幀中視圖變更,就可以考慮是否將邏輯放入useLayoutEffect中處理。
當然,useLayoutEffect的使用也應當是謹慎的。由于js線程和渲染進程是互斥的,因此useLayoutEffects中不宜加入很耗時的計算,否則會導致瀏覽器沒有時間重繪而阻塞渲染,上述使用useLayoutEffect的demo中加入了200ms延遲,可以明顯的感受到每次點擊更新的延遲。除此之外的絕大部分場景下二者的行為都是一致的,因此業務開發中的大部分場景應優先使用useEffect。