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