useMemo 和 useCallback 是 React 提供的两个性能优化 Hook,它们的核心机制都是缓存计算结果或函数引用,并在依赖项未变化时直接返回旧值,避免不必要的计算或引用变更。理解它们的原理,关键在于搞清楚三件事:缓存是什么、何时失效、对比逻辑如何。
7.3.3.1 缓存的目的
在 React 中,每次组件重新渲染,函数组件会完整执行一次。这意味着:
- 在组件内定义的变量、函数都会被重新创建。
- 如果这些值或函数被传递给子组件作为 Props,子组件可能会因为引用变更而触发不必要的重渲染(即使值逻辑上没变)。
useMemo 缓存计算结果,useCallback 缓存函数本身。它们共同的作用是:保持引用稳定,避免因渲染次数增加而导致的不必要计算和子组件重渲染。
// 每次渲染都会重新创建 handleClick,子组件 PureButton 会误判 Props 变化
function Parent() {
const handleClick = () => console.log('clicked');
return <PureButton onClick={handleClick} />; // PureButton 用了 React.memo
}
7.3.3.2 缓存的生命周期
useMemo 和 useCallback 的缓存不是全局的,它们依附于当前组件的 Fiber 节点,并存在 Hooks 链表中。同一个组件在同一个生命周期内的多次渲染,会复用这条链表,从而可以对比依赖。
- 首次渲染:创建缓存值/函数,存入 Hook 状态。
- 后续渲染:取出上一次的依赖数组和缓存值,与本次传入的依赖数组进行浅比较。
- 如果每个依赖项都相同 → 直接返回缓存值。
- 如果任一依赖项改变 → 重新计算并更新缓存,下次将使用新缓存。
组件卸载时,整个 Hooks 链表随着 Fiber 节点被回收,缓存自然释放。
7.3.3.3 依赖对比的原理:浅比较(Shallow Equality)
React 使用 Object.is 算法对依赖数组中的每一项进行对比,这是一种浅比较,而非深比较。具体行为:
- 遍历依赖数组,将本次依赖项
deps[i]与上次prevDeps[i]进行对比。 - 比较规则等同于
Object.is(deps[i], prevDeps[i]):
- 基本类型:值相等即相等。
- 引用类型:必须指向同一个对象才相等,哪怕内部属性相同也会判为不等。
- 如果任意一个依赖项不相等,缓存失效,执行新计算。
const obj = { name: 'Alice' };
// 每次渲染 { name: 'Alice' } 都是新对象,即使内容一样
useMemo(() => expensive(obj), [obj]);
// 如果 obj 每次重新创建,缓存实际上每次都失效
为什么不用深比较?
深比较成本高,而且可能掩盖依赖设计问题。如果你的依赖是对象,应该想办法保持它的引用稳定(例如通过 useMemo 包裹对象创建),而不是依赖深比较来救命。
7.3.3.4 useMemo 的实现简版
从原理上,useMemo 可以简化为如下逻辑(并非 React 源码完全一致,但反映核心流程):
let hookState = []; // 模拟组件 Fiber 上的 hooks 链表
let hookIndex = 0;
function useMemo(factory, deps) {
const currentIndex = hookIndex;
const prev = hookState[currentIndex]; // 取出上次存储的状态
if (prev) {
// 依赖对比
const [prevResult, prevDeps] = prev;
let depsChanged = false;
if (!deps || !prevDeps) {
depsChanged = true;
} else {
for (let i = 0; i < deps.length; i++) {
if (!Object.is(deps[i], prevDeps[i])) {
depsChanged = true;
break;
}
}
}
if (!depsChanged) {
// 依赖未变,返回旧结果
hookIndex++;
return prevResult;
}
}
// 需要重新计算
const newResult = factory();
hookState[currentIndex] = [newResult, deps];
hookIndex++;
return newResult;
}
useCallback 的实现几乎一模一样,只是它缓存的是函数本身:
function useCallback(callback, deps) {
return useMemo(() => callback, deps);
}
事实上,useCallback(fn, deps) 完全等价于 useMemo(() => fn, deps)。
7.3.3.5 依赖项传空数组与不传的区别
useMemo(fn, []):只在首次渲染计算,之后永远返回缓存值,更像componentDidMount中的一次性计算。useMemo(fn):不传依赖数组,每次渲染都会重新计算,缓存形同虚设(React 实际上会直接跳过缓存对比)。useMemo(fn, undefined)等同于不传,每次重新计算。
7.3.3.6 实际应用中的常见坑
1. 缓存失效陷阱:依赖了每次渲染都会变化的值
function Component({ items }) {
// ❌ items 每次渲染可能都是新数组,缓存无效
const total = useMemo(() => sum(items), [items]);
}
解决办法:确保依赖项引用稳定,或改用其他状态管理确保 items 只在真正变化时改变。
2. 过度使用导致代码臃肿
useMemo 和 useCallback 本身也有开销(依赖对比),对于简单计算或不会传给子组件的函数,不必包裹。没有性能瓶颈时,不要过早优化。
3. 依赖缺失导致闭包陈旧值
function Timer() {
const [count, setCount] = useState(0);
const log = useCallback(() => {
console.log(count); // 闭包可能引用了旧的 count
}, []); // 空依赖,log 永远不会更新引用的 count
}
正确做法:将 count 加入依赖数组,或使用 useRef 存储最新值。
7.3.3.7 总结
useMemo 和 useCallback 的缓存机制是基于依赖数组的浅比较,保持引用稳定是其核心用途。理解这个对比原理,可以帮助我们:
- 准确判断何时缓存有效。
- 避免因为错误的依赖导致缓存失效或闭包过期问题。
- 在性能优化与代码可读性之间做出合理权衡。
本质上,它们只是利用了 Hooks 链表的记忆能力,配合简单的依赖比对,实现了一种轻量级的惰性求值。