介绍
在实际项目中,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 中的函数。
当然如果能接受所有函数都被其包裹导致的代码可读性问题,这样记忆化处理也不会有什么问题。