介紹
在實際項目中,useCallback、useMemo這兩個Hooks想必會很常見,可能我們會處于性能考慮避免組件重復刷新而使用類似useCallback、useMemo來進行緩存。接下來我們會從源碼和使用的角度來聊聊這兩個hooks。【源碼地址】
為什么要有這兩個Hooks
在開始介紹之前我們先來了解下為什么有這兩個hooks,其解決了什么問題?借用官網案例:
function ProductPage({ productId, referrer, theme }) {// 每當 theme 改變時,都會生成一個不同的函數function handleSubmit(orderDetails) {post('/product/' + productId + '/buy', {referrer,orderDetails,});}return (<div className={theme}>{/* 這將導致 ShippingForm props 永遠都不會是相同的,并且每次它都會重新渲染 */}<ShippingForm onSubmit={handleSubmit} /></div>);
}
每當切換主題theme,ProductPage就會重新渲染,而即使ShippingForm使用memo包裹并且沒有做任何更改也會重新渲染,這就是常說的父組件渲染導致子組件跟著渲染。
再看另一種情況:
function createOptions() {return {serverUrl: 'https://localhost:1234',roomId: roomId};}useEffect(() => {const options = createOptions();const connection = createConnection();connection.connect();}, [createOptions])
在useEffect中添加了createOptions作為依賴,但是createOptions函數每次執行都返回的不同函數導致useEffect會重新執行
所以為了解決類似上面兩種問題,利用緩存封裝了useCallback、useMemo等hooks。
useCallback
function useCallback<T extends (...args: any[]) => any>(callback: T, deps: DependencyList): T;
讓我們帶著上面這兩個問題來了解useCallback。用白話來說useCallback就是接收一個callback和依賴deps,只要依賴的deps沒有改變,通過useCallback返回的函數就是同一個,以此來避免重復刷新。如果deps改變則useCallback會返回新的callback并將其緩存,以便下次對比。
從源碼來看幾乎所有的Hooks都被拆分為了mount、upadte兩種(useContext除外),React內部會根據當前渲染階段來判斷調用那個來處理callback
// 首次掛載時
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,
};
以下會以useCallback為例,從源碼上一步一步了解。
調用流程
從上面流程圖能看出,當我們在組件內使用useCallback的時候,React會通過dispatcher根據渲染狀態來進行不同的處理。
export function useCallback<T>(callback: T,deps: Array<mixed> | void | null,
): T {return useCallbackImpl(callback, deps);
}function useCallbackImpl<T>(callback: T,deps: Array<mixed> | void | null,
): T {const dispatcher = resolveDispatcher();return dispatcher.useCallback(callback, deps);
}
這里的dispatcher 是一個對象,它會在不同的渲染階段指向不同的實現。在初次渲染時,它會指向 HooksDispatcherOnMount,在更新時,它會指向 HooksDispatcherOnUpdate。
mountCallback
當首次渲染時,會執行mountCallbac返回新的callback并將其和所依賴的deps緩存到memoizedState中
function mountCallback<T>(callback: T, deps: Array<mixed> | void | null): T {const hook = mountWorkInProgressHook();const nextDeps = deps === undefined ? null : deps;hook.memoizedState = [callback, nextDeps];return callback;
}
在首次渲染時候主要做了下列事情:
mountWorkInProgressHook
: 會創建一個hook并綁定到當前渲染的fiber中- 獲取依賴deps,并將callback和deps緩存到當前fiber的hook中
在Function Component中,每個fiber節點都有一個自己的副作用hook list,在協調器(Reconciler)的fiber構造的beginWork階段會將當然fiber節點的hook保存在hook list中,詳情可查看這篇文章:【React架構 - Fiber構造循環】
updateCallback
更新渲染時,會執行updateCallback函數,會根據依賴是否變化來判斷是否使用緩存
function updateCallback<T>(callback: T, deps: Array<mixed> | void | null): T {const hook = updateWorkInProgressHook();const nextDeps = deps === undefined ? null : deps;const prevState = hook.memoizedState;if (nextDeps !== null) {const prevDeps: Array<mixed> | null = prevState[1];if (areHookInputsEqual(nextDeps, prevDeps)) {return prevState[0];}}hook.memoizedState = [callback, nextDeps];return callback;
}
updateCallback主要做了下列事情:
- 通過
updateWorkInProgressHook
獲取當前fiber節點對應的hook,并通過hook.memoizedState獲取緩存的callback和deps - 當依賴存在時,通過
areHookInputsEqual
判斷deps是否變化,如果沒變則返回緩存中的callback,即prevState[0],否則緩存新的callback和deps,然后返回新的callback
在areHookInputsEqual中主要是通過Object.is來判斷deps是否變化
function areHookInputsEqual(nextDeps, prevDeps) {if (prevDeps === null) {return false;}// 簡單的長度檢查if (nextDeps.length !== prevDeps.length) {return false;}// 逐一比較每一個依賴項for (let i = 0; i < nextDeps.length; i++) {if (Object.is(nextDeps[i], prevDeps[i])) {continue;}return false;}return true;
}
Object.is() 與 == 運算符并不等價。== 運算符在測試相等性之前,會對兩個操作數進行類型轉換(如果它們不是相同的類型),這可能會導致一些非預期的行為,例如 “” == false 的結果是 true,但是 Object.is() 不會對其操作數進行類型轉換。
Object.is() 也不等價于 === 運算符。Object.is() 和 === 之間的唯一區別在于它們處理帶符號的 0 和 NaN 值的時候。=== 運算符(和 == 運算符)將數值 -0 和 +0 視為相等,但是會將 NaN 視為彼此不相等。詳細查看MDN
useMemo
useCallback、useMemo都是處于性能考慮通過緩存來避免重復執行的hook,同useCallback一樣,useMemo也接收兩個參數callback、deps。其區別主要是:useCallback是緩存以及返回函數,并不會調用函數,而useMemo會執行函數,緩存并換回函數的執行結果
同其他hooks一樣,useMemo也分為了mount和update兩個,下面一一介紹。
mountMemo
function mountMemo<T>(nextCreate: () => T,deps: Array<mixed> | void | null,
): T {// 創建一個添加到Fiber節點上的Hooks鏈表const hook = mountWorkInProgressHook();const nextDeps = deps === undefined ? null : deps;// 計算需要memo的值const nextValue = nextCreate();// hook數據對象上存的值hook.memoizedState = [nextValue, nextDeps];return nextValue;
}
初次渲染:
mountWorkInProgressHook
: 會創建一個hook鏈表并綁定到當前渲染的fiber中- 執行傳入的callback,并將其保存到memoizedState中
updateMemo
function updateMemo<T>(nextCreate: () => T,deps: Array<mixed> | void | null,
): T {// 找到該useMemo對應的hook數據對象const hook = updateWorkInProgressHook();const nextDeps = deps === undefined ? null : deps;// 之前存的[nextValue, nextDeps]const prevState = hook.memoizedState;if (prevState !== null) {if (nextDeps !== null) {const prevDeps: Array<mixed> | null = prevState[1];// 判斷依賴是否相等if (areHookInputsEqual(nextDeps, prevDeps)) {// 相等就返回上次的值return prevState[0];}}}// 不相等重新計算const nextValue = nextCreate();hook.memoizedState = [nextValue, nextDeps];return nextValue;
}
更新渲染:
- 通過
updateWorkInProgressHook
獲取當渲染fiber的hook鏈表 - 根據
areHookInputsEqual
判斷傳入的依賴deps是否變化,如果變化則返回新的結果并緩存,否則使用緩存
總結
總的來說useMemo和useCallback相對來說源碼比較簡單,大致就是在首次渲染時,調用mountHook將callback/結果緩存到當前fiber節點的hoos鏈表(通過mountWorkInProgressHook
創建)的memoizedState屬性中,然后在更新渲染中獲取當前fiber節點的hook信息(通過updateWorkInProgressHook
獲取),通過areHookInputsEqual
判斷是否使用緩存。
函數調用流程如下:
雖然useCallback、useMemo利用緩存避免了重復渲染,有利于性能優化,但是在實際項目中并不是所有的函數都需要用其包裹,大多情況下是沒有意義的。主要場景就是上面提到的子組件更新和作為其他函數的依賴時:
- 將其作為 props 傳遞給包裝在 [memo] 中的組件。如果 props 未更改,則希望跳過重新渲染。緩存允許組件僅在依賴項更改時重新渲染。
- 傳遞的函數可能作為某些 Hook 的依賴。比如,另一個包裹在 useCallback 中的函數依賴于它,或者依賴于 useEffect 中的函數。
當然如果能接受所有函數都被其包裹導致的代碼可讀性問題,這樣記憶化處理也不會有什么問題。