1. 引言
在 React 的世界里,每一次组件的重新渲染都意味着其内部所有代码(包括函数定义和变量计算)都会被重新执行。在大多数情况下,这是无害的。但当遇到昂贵的计算或需要将稳定的函数引用传递给子组件时,这种重复执行就可能成为性能瓶颈。
useMemo 和 useCallback 是 React 提供的两个核心优化工具,它们允许我们"记住"一个值或一个函数,只有在依赖项发生变化时才重新计算或创建。这种技术被称为记忆化 (Memoization)。
理解它们的实现原理,能帮助我们更精确地运用这些工具,避免不必要的性能损耗,同时也能避免因误用而引入的 bug。
2. 核心数据结构:Fiber 上的 Hook 链表
要理解任何 Hook 的工作原理,首先必须知道 React 是如何"记住"一个组件的状态的。答案就在组件对应的 Fiber 节点上。
每个函数组件的 Fiber 节点内部都有一个名为 memoizedState 的属性。这个属性指向一个链表 ,链表中的每一个节点都是一个 Hook 对象 。当你每次调用 useState, useEffect, useMemo 等 Hook 时,React 就会按顺序读取或创建这个链表上的一个节点。
之前写过的useState 和 useEffect 实现,更完整的介绍了这一部分,这里不过多介绍。
一个简化的 Hook 对象结构如下:
typescript
interface Hook {
memoizedState: any; // 对于 useState,是 state;对于 useMemo,是记忆的值和依赖
next: Hook | null; // 指向下一个 Hook
}
useMemo 和 useCallback 正是利用了这个机制,将计算出的值(或函数)和它的依赖项数组存储在对应的 Hook 节点上,以便在下一次渲染时进行比较。
3. useMemo 的实现原理
useMemo 的作用是执行一个"创建"函数并将其返回值进行记忆化,只有在依赖项变化时才重新计算。
其签名为:const memoizedValue = useMemo(createFn, deps);
当一个组件渲染并调用 useMemo 时,React 内部会执行以下步骤(由 updateMemo 函数处理):
-
获取当前 Hook 节点 :React 从 Fiber 的
memoizedState链表中获取当前useMemo对应的 Hook 节点。 -
比较依赖项 :React 取出上一次渲染时存储在该 Hook 节点上的
deps数组,并与本次传入的deps数组进行浅比较。- 这个比较过程由
areHookInputsEqual函数完成,它会遍历两个数组,使用Object.is来比较每一个依赖项。
- 这个比较过程由
-
决策与返回值:
- 如果依赖项没有变化 (或者
deps未提供):React 会直接返回上一次存储在 Hook 节点memoizedState属性中的值,完全跳过createFn的执行。 - 如果依赖项发生变化 (或者这是第一次渲染):React 会执行
createFn函数,得到新的值。然后,它会将这个新值 和新的deps数组 一同存储到当前 Hook 节点的memoizedState中,以备下次渲染使用。最后,返回这个新计算出的值。
- 如果依赖项没有变化 (或者
源码简化逻辑
javascript
// ReactFiberHooks.new.js -> updateMemo
function updateMemo<T>(create: () => T, deps: Array<mixed> | void | null): T {
// 1. 获取当前组件正在处理的 Hook
const hook = updateWorkInProgressHook();
// 2. 获取上一次的依赖项
const nextDeps = deps === undefined ? null : deps;
const prevState = hook.memoizedState;
if (prevState !== null) {
if (nextDeps !== null) {
const prevDeps = prevState[1];
// 3. 比较依赖项
if (areHookInputsEqual(nextDeps, prevDeps)) {
// 依赖未变,直接返回旧值
return prevState[0];
}
}
}
// 4. 依赖变化或首次渲染,重新计算
const nextValue = create();
// 5. 存储新值和新依赖
hook.memoizedState = [nextValue, nextDeps];
return nextValue;
}
4. useCallback:useMemo 的语法糖
useCallback 的作用是记忆化一个回调函数本身,而不是它的返回值。
其签名为:const memoizedCallback = useCallback(callbackFn, deps);
理解 useCallback 最简单的方式就是:它完全等同于一个特殊用法下的 useMemo。
以下两行代码是完全等价的:
javascript
// 使用 useCallback
useCallback(callback, deps);
// 等价的 useMemo 实现
useMemo(() => callback, deps);
实现原理 : useCallback 内部的实现逻辑和 useMemo 几乎一模一样。它也遵循相同的步骤:获取 Hook 节点、比较 deps 依赖项。
- 如果依赖项不变,它返回上一次传入的
callbackFn函数的引用。 - 如果依赖项变化,它返回本次传入的新的
callbackFn函数,并将其存储起来以备后用。
它记忆的是函数本身,确保了只要依赖不变,传递给子组件的函数引用就是稳定、不变的。这对于依赖函数引用进行优化的子组件(例如被 React.memo 包裹的组件)至关重要,可以防止因为父组件渲染时创建了新的函数实例而导致的不必要的子组件重渲染。
5. 何时使用?避免过度优化
虽然 useMemo 和 useCallback 是强大的工具,但它们并非没有成本。每一次使用都意味着:
- 内存开销:需要额外存储记忆化的值和依赖数组。
- 计算开销:每次渲染都需要对依赖数组进行比较。
因此,滥用它们反而可能导致性能下降。以下是正确的使用场景:
useMemo的使用场景:
当组件中有计算成本高昂的操作时(例如,对大型数组进行循环、过滤、排序)。
当一个值的计算需要耗费明显的时间,并且不希望它在每次渲染时都重新计算。
useCallback的使用场景:
将回调函数传递给一个被 React.memo 优化的子组件时。这是最主要的用途。
js
const MemoizedButton = React.memo(Button);
function Parent() {
// 如果不用 useCallback,handleClick 在每次 Parent 重新渲染时都是新函数
const handleClick = () => {
console.log("Clicked!");
};
// 因为 handleClick 引用变了,导致 MemoizedButton 即使其他 props 没变也会重新渲染
return <MemoizedButton onClick={handleClick} />;
}
// 在这种情况下,React.memo 通过浅比较 props 来决定是否跳过渲染。
// 如果 onClick prop 每次都是一个新的函数引用,React.memo 的优化就会完全失效。
// 使用 useCallback 可以确保 handleClick 的引用保持稳定,从而让 React.memo 发挥作用。
当一个函数被用作 useEffect 的依赖项时,为了防止 useEffect 在每次渲染时都重新执行,需要用 useCallback 来稳定该函数的引用。
js
function MyComponent({someProp}) {
const fetchData = () => {
// ... uses someProp ...
};
useEffect(() => {
fetchData();
}, [fetchData]); // 问题在这里!
}
// 在这个例子中,因为 fetchData 在每次渲染时都是新函数,
// useEffect 的依赖项 [fetchData] 每次都会变化,导致 useEffect 在每次渲染后都会执行,
// 这可能不是你想要的。如果用 useCallback 包裹 fetchData ,
// useEffect 就只会在 fetchData 真正需要更新时(即它的依赖 someProp 变化时)才重新执行。
反模式:
- 简单的计算(如 a + b)不需要用
useMemo来记忆,因为 useMemo 本身的开销(内存和计算)要比 a + b 这种简单计算的开销大得多。 - 如果一个函数没有传递给优化的子组件,或者没有作为其他 Hook 的依赖,那么通常不需要用
useCallback来包裹它,因为在组件函数体内部定义的函数(如 function handleClick() {} 或 const handleClick = () => {}),在每次组件重新渲染时,都会被重新创建一个新的函数对象,这个创建过程非常快,其成本可以忽略不计。
6. 总结
useMemo 和 useCallback 是 React 性能优化工具箱中的两把"手术刀"。它们都基于相同的底层机制------在 Fiber 节点的 Hook 链表上存储数据,并通过比较依赖项来决定是返回缓存的结果还是重新计算。
useMemo记忆化一个值。useCallback是useMemo的一个特例,用于记忆化一个函数。