介紹
在實際React Hooks項目中,我們需要在項目的不同階段進行一些處理,比如在頁面渲染之前進行dom操作、數據獲取、第三方加載等。在Class Component中存在很多生命周期能讓我們完成這個操作,但是在React Hooks沒有所謂的生命周期,但是它提供了useEffect、useLayoutEffect來讓我們進行不同階段處理,下面就從源碼角度來聊聊這兩個Hooks。【源碼地址】
前提了解
同其他Hooks一樣(useContext除外),在React18版本之后將其拆分為了mount、update兩個函數,并由Dispatcher在不同階段來執行不同函數。
// 掛載時
const HooksDispatcherOnMount: Dispatcher = {readContext,use,useCallback: mountCallback,useContext: readContext,useEffect: mountEffect,useImperativeHandle: mountImperativeHandle,useLayoutEffect: mountLayoutEffect,useInsertionEffect: mountInsertionEffect,useMemo: mountMemo,useReducer: mountReducer,useRef: mountRef,useState: mountState,useDebugValue: mountDebugValue,useDeferredValue: mountDeferredValue,useTransition: mountTransition,useSyncExternalStore: mountSyncExternalStore,useId: mountId,
};// 更新時
const HooksDispatcherOnUpdate: Dispatcher = {readContext,use,useCallback: updateCallback,useContext: readContext,useEffect: updateEffect,useImperativeHandle: updateImperativeHandle,useInsertionEffect: updateInsertionEffect,useLayoutEffect: updateLayoutEffect,useMemo: updateMemo,useReducer: updateReducer,useRef: updateRef,useState: updateState,useDebugValue: updateDebugValue,useDeferredValue: updateDeferredValue,useTransition: updateTransition,useSyncExternalStore: updateSyncExternalStore,useId: updateId,
};
下面介紹主要涉及到兩個文件中內容:
- react/src/ReactHooks.js
這個文件主要是定義暴露的給用戶實際使用的Hooks,即我們在組件中通過import { useXXX } from 'react'
引入的Hooks。 - react-reconciler/src/ReactFiberHooks.js
該文件主要是React內部真正執行的Hooks函數,內部將Hooks拆分為了mount、update兩個函數,并通過Dispatcher在不同階段進行分發如上所示
useEffect
由于React18對于Hooks進行了重新組織,將其拆分為了掛載時和更新時,所以我們也從這兩方面入手介紹。
mount掛載時
源代碼文件路徑:react/src/ReactHooks.js
export function useEffect(create: () => (() => void) | void,deps: Array<mixed> | void | null,
): void {const dispatcher = resolveDispatcher();return dispatcher.useEffect(create, deps);
}
正如我們使用的那樣,useEffect接受兩個參數create、deps。然后通過dispatcher在不同階段進行不同的處理即掛載時執行mountEffect,更新時執行updateEffect,通過上面的HooksDispatcherOnMount/HooksDispatcherOnUpdate
映射。
React內部實現的Hooks代碼都在react-reconciler/src/ReactFiberHooks.js文件下。(下面代碼皆省略了DEV環境下的代碼)
// react-reconciler/src/ReactFiberHooks.js
function mountEffect(create: () => (() => void) | void,deps: Array<mixed> | void | null,
): void {mountEffectImpl(PassiveEffect | PassiveStaticEffect, // 定義的常量用于標記常規的副作用HookPassive, // 表示是被動類型的Hook常量,不需要用戶主動調用create,deps,);
}function mountEffectImpl(fiberFlags: Flags,hookFlags: HookFlags,create: () => (() => void) | void,deps: Array<mixed> | void | null,
): void {const hook = mountWorkInProgressHook();const nextDeps = deps === undefined ? null : deps;currentlyRenderingFiber.flags |= fiberFlags;hook.memoizedState = pushEffect(HookHasEffect | hookFlags,create,createEffectInstance(),nextDeps,);
}function mountWorkInProgressHook(): Hook {const hook: Hook = {memoizedState: null,baseState: null,baseQueue: null,queue: null,next: null,};if (workInProgressHook === null) {// This is the first hook in the listcurrentlyRenderingFiber.memoizedState = workInProgressHook = hook;} else {// Append to the end of the listworkInProgressHook = workInProgressHook.next = hook;}return workInProgressHook;
}
從代碼能看出,當我們調用useEffect之后,如果是首次掛載,React會通過dispatcher觸發mountEffect函數,在其中調用了mountEffectImpl并傳遞了四個參數來對創建當前節點的hook。
- PassiveEffect | PassiveStaticEffect: 用于標記副作用的常量,用于區分特性和用途。
PassiveEffect
是用于標記常規的副作用,例如 useEffect 中定義的副作用。它表示這個副作用是在組件更新階段執行的,但是不會阻塞瀏覽器的渲染。PassiveStaticEffect
是用于標記靜態的副作用。表示這個副作用是靜態的,不會在組件的多次渲染中發生變化,通常與靜態數據相關。 - HookPassive:標記Hook的類型常量,在 React 內部,不同類型的 Hook 會根據不同的標記和調度器進行處理。HookPassive 表示這個 Hook 是一種被動的類型,適用于大多數常規的 Hook 使用情況。
- create:組件內使用useEffect包裹的函數
- deps:useEffect包裹函數所依賴的參數
在mountEffect中將創建useEffect所需要的數據傳遞mountEffectImpl之后,就進行Hook的創建。在mountEffectImpl函數中主要做了這些操作:
- 調用mountWorkInProgressHook函數,創建一個管理hooks的循環鏈表
- 獲取依賴nextDeps,以及設置該副作用的Flag
- 通過pushEffect創建一個副作用鏈表,并保存在hook.memoizedState中
function pushEffect(tag: HookFlags,create: () => (() => void) | void,inst: EffectInstance, // 組件實例deps: Array<mixed> | null,
): Effect {const effect: Effect = {tag,create,inst,deps,// Circularnext: (null: any),};let componentUpdateQueue: null | FunctionComponentUpdateQueue =(currentlyRenderingFiber.updateQueue: any);if (componentUpdateQueue === null) {componentUpdateQueue = createFunctionComponentUpdateQueue();currentlyRenderingFiber.updateQueue = (componentUpdateQueue: any);componentUpdateQueue.lastEffect = effect.next = effect;} else {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;
}
pushEffect
主要是創建一個副作用循環鏈表,并將其掛載在當前渲染fiber節點的狀態更新隊列中。所以fiber.updateQueue.lastEffect
指向的就是pushEffect創建的副作用鏈表。
因為effect list是環狀鏈表,updateQueue.lastEffect指向的最后元素,是因為這樣有利于遍歷時從起點開始,以及更好的插入effect
至此在掛載時,成功創建了hook鏈表和effect鏈表并掛載在當前渲染fiber節點的updateQueue中,后續通過在 Commit 階段,React 會遍歷 Effect list,執行相應的副作用操作。
update更新時
和掛載時類似,updateEffect會調用updateEffectImpl來進行更新處理。
function updateEffect(create: () => (() => void) | void,deps: Array<mixed> | void | null,
): void {updateEffectImpl(PassiveEffect, HookPassive, create, deps);
}function updateEffectImpl(fiberFlags: Flags,hookFlags: HookFlags,create: () => (() => void) | void,deps: Array<mixed> | void | null,
): void {const hook = updateWorkInProgressHook();const nextDeps = deps === undefined ? null : deps;const effect: Effect = hook.memoizedState;const inst = effect.inst;// currentHook is null on initial mount when rerendering after a render phase// state update or for strict mode.if (currentHook !== null) {if (nextDeps !== null) {const prevEffect: Effect = currentHook.memoizedState;const prevDeps = prevEffect.deps;if (areHookInputsEqual(nextDeps, prevDeps)) {hook.memoizedState = pushEffect(hookFlags, create, inst, nextDeps);return;}}}currentlyRenderingFiber.flags |= fiberFlags;hook.memoizedState = pushEffect(HookHasEffect | hookFlags,create,inst,nextDeps,);
}
由上面可以知道pushEffect主要就是創建一個effect然后將其添加到fiber的更新隊列中。而在更新時,通過areHookInputsEqual對比了前后渲染的依賴是否改變,然后通過pushEffect創建新的effect然后添加到更新隊列,區別是當依賴改變時會將當前創建的新的hook的flag設置為HookHasEffect,表示當前副作用需要重新執行。
cleanup清除函數
在useEffect中返回一個函數,該函數會在每次組件更新前以及組件卸載前會執行,該函數稱為清除函數。
useEffect(() => {console.log('useEffect');return () => {consoe.log('清除函數')}
}, [deps])
該函數會在commitHookEffectListMount
函數中掛載到effect副作用上,并且在commitHookEffectListUnmount
中執行,這兩個函數都是在commit階段進行的,文件路徑為:packages/react-reconciler/src/ReactFiberCommitWork.js
。
commitHookEffectListMount 負責在副作用更新后重新執行副作用(即deps更新后會觸發該函數執行):
function commitHookEffectListMount(tag: HookFlags,finishedWork: Fiber,
) {const updateQueue: FunctionComponentUpdateQueue | null = (finishedWork.updateQueue: any);let lastEffect: Effect | null = updateQueue !== null ? updateQueue.lastEffect : null;if (lastEffect !== null) {const firstEffect = lastEffect.next;let effect = firstEffect;do {if ((effect.tag & tag) === tag) {// 執行副作用創建函數const create = effect.create;effect.destroy = create();}effect = effect.next;} while (effect !== firstEffect);}
}
從代碼可以看出,在commitHookEffectListMount函數中,如果useEffect副作用中存在清除函數(即return的函數),則會掛載在副作用中,即 effect.destroy = create();
commitHookEffectListUnmount 負責在組件更新或卸載時清理副作用:
function commitHookEffectListUnmount(tag: HookFlags,finishedWork: Fiber,
) {const updateQueue: FunctionComponentUpdateQueue | null = (finishedWork.updateQueue: any);let lastEffect: Effect | null = updateQueue !== null ? updateQueue.lastEffect : null;if (lastEffect !== null) {const firstEffect = lastEffect.next;let effect = firstEffect;do {if ((effect.tag & tag) === tag) {// 執行清理函數const destroy = effect.destroy;if (destroy !== undefined) {destroy();}}effect = effect.next;} while (effect !== firstEffect);}
}
所以當組件卸載或者更新之前,會先執行清除函數然后在重新掛載新的清除函數。
useEffect的執行時機
上面說了在React Hooks中為了讓我們能擁有類似Class Component生命周期一樣對項目運行階段進行監聽并處理的功能,所以有了useEffect鉤子,下面列舉一下useEffect和Class Component生命周期的對應關系,幫助理解useEffect的執行時機。
- 不寫依賴數組: useEffect 會在每次渲染后執行,類似于 componentDidMount 和 componentDidUpdate 的結合。
- 空依賴數組: useEffect 只在組件掛載和卸載時執行一次,類似于 componentDidMount 和 componentWillUnmount 的組合。
- 帶依賴數組: useEffect 只會在組件掛載時和依賴項發生變化時執行,類似于 componentDidUpdate 針對特定依賴項的變化。
可能有的同學看到不寫依賴數組,會在每次渲染以及更新時都會執行,那這樣和不適應useEffect包裹,直接在組件內聲明有什么區別呢?下面也簡單列舉一下:
比較維度 | 直接在函數內的代碼 | useEffect 中的代碼 |
---|---|---|
執行時機 | 在 React 調用組件函數期間同步執行,這意味著它會在 React 準備和生成新的虛擬 DOM 樹時執行 | 在 React 完成更新后(渲染并提交真實 DOM 變更后)異步執行,適合處理副作用,如數據獲取、訂閱、DOM 操作等 |
副作用管理 | 不適合處理副作用,邏輯應該是純函數的,不應引起副作用(例如,不直接操作 DOM) | 專為處理副作用設計,適合處理那些在組件渲染后需要進行的操作,如數據獲取、DOM 更新或事件訂閱 |
清理機制 | 沒有自動的清理機制。 | 提供了一個清理函數,允許在組件卸載或下一次副作用執行之前進行清理工作。 |
性能優化 | 每次渲染都會執行。如果不需要每次都執行,會造成不必要的性能開銷。 | 通過依賴數組控制執行頻率,避免不必要的重新執行。 |
由表可以看出,主要區別在于副作用處理,和性能優化的區別。需要根據場景來決定如何使用,除非需要實時更新執行,否則一般不推薦在組件內直接寫函數。
useLayoutEffect
上面聊了useEffect,下面來談談它的同胞兄弟useLayoutEffect,畢竟我們經常看到說使用useLayoutEffect可以有效解決在useEffect中操作狀態/dom導致的屏幕閃縮問題。
同useEffect一樣,useLayoutEffect也分為了mount、update。所以我們之間步如主題,從mountLayoutEffect開始。
從代碼來看,在mount時useLayoutEffect和useEffect兩者在語法上是一樣的,都接受一個create函數(可包含cleanup函數),一個deps依賴數組,并都通過mountWorkInProgressHook來創建hook,然后通過pushEffect添加effect到hook中。更新時候也和useEffect大致一致,所以這里放在一起,重復代碼則不再冗余貼上了。
// mount
function mountLayoutEffect(create: () => (() => void) | void,deps: Array<mixed> | void | null,
): void {let fiberFlags: Flags = UpdateEffect | LayoutStaticEffect;return mountEffectImpl(fiberFlags, HookLayout, create, deps);
}// update
function updateLayoutEffect(create: () => (() => void) | void,deps: Array<mixed> | void | null,
): void {return updateEffectImpl(UpdateEffect, HookLayout, create, deps);
}
區別就是在調用mountEffectImpl
和updateEffectImpl
時傳入的Flags不一樣
// useEffect
const PassiveEffect = /* */ 0b000000001000;// useLayoutEffect
const UpdateEffect = /* */ 0b000000000100;
- PassiveEffect 表示這是一個被動的副作用,它會在瀏覽器完成布局和繪制后執行。
- UpdateEffect 表示這是一個同步的副作用,它會在所有 DOM 變更之后,瀏覽器繪制之前執行。
由此能看出useEffect是瀏覽器完成布局和繪制后異步執行,不影響渲染。而useLayoutEffect在所有 DOM 變更之后,瀏覽器繪制之前同步執行。
執行時機: DOM變更完成 -> useLayoutEffect(同步) -> 頁面繪制 -> useEffect(異步)
。 這也說明了為什么在useEffect中操作狀態或者DOM時候,屏幕會閃縮(因為頁面已經渲染,然后異步更新狀態之后,會導致頁面再次渲染時候存在時間差)。而useLayoutEffect能解決閃爍問題。(useLayoutEffect在頁面還未繪制之前同步執行,修改狀態之后再繪制到頁面,對用戶來說無感知,但是處理長任務時,會導致白屏問題)。
總結一下。兩種區別主要是執行時機不同:
- useEffect 使用 PassiveEffect 標志,確保副作用在瀏覽器完成繪制后異步執行。
- useLayoutEffect 使用 UpdateEffect 標志,確保副作用在 DOM 更新后,瀏覽器繪制前同步執行。
總結
useEffect和useLayoutEffect在語法和代碼組織上,邏輯大致相同。在mount階段通過mountWorkInProgressHook
創建hook,pushEffect
創建effect list并綁定在渲染fiber上。在update階段通過updateEffectImpl
調用updateWorkInProgressHook
更新hook 列表,并通過areHookInputsEqual
判斷依賴是否變化,然后設置不同的Flag交給pushEffect
創建新的effect,在執行時會根據設置的Flag來判斷是否需要重新執行。
當狀態更新時總的流程如下:
- count 狀態更新,組件重新渲染。
- React 計算新的虛擬 DOM 并將其變更應用到實際 DOM。
- useLayoutEffect 清除函數(如果存在)在 DOM 變更后立即同步執行。
- useLayoutEffect 的新副作用在 DOM 變更后立即同步執行。
- 瀏覽器繪制頁面。
- useEffect 清除函數(如果存在)在繪制完成后異步執行。
- useEffect 的新副作用在繪制完成后異步執行。