React 源码解剖课:useMemo 与 useCallback 的“灵魂”对谈
各位同学,下午好!
欢迎来到今天的“React 内部解剖室”。我是你们的带教老师。今天我们要做的,不是给你们画一个简单的饼图,也不是教你们怎么写一个 useCallback 来骗过 ESLint,而是我们要拿起手术刀,切开 React 的胸膛,看看它的心脏——也就是那些 Hooks——里面到底装的是什么。
我们今天的主角是两位老朋友:useMemo 和 useCallback。在平时的开发中,你们可能觉得它们是“防抖神器”或者是“性能优化工具”。但在源码层面,它们其实是两个性格迥异的室友,住在一个叫做 memoizedState 的狭窄公寓里。
别眨眼,我们开始。
第一部分:Fiber——Hook 的“户籍登记处”
在深入这两个 Hook 之前,我们必须先搞清楚它们住在哪里。在 React 的世界里,每一个组件实例都有一个对应的 Fiber 节点。你可以把 Fiber 节点想象成 React 组件的“物理身体”。
在这个身体里,有一个非常关键的属性,叫作 memoizedState。这不仅仅是一个变量,它是一个单链表。
是的,你没听错。memoizedState 是一个链表。这意味着什么呢?这意味着在一个组件里,如果你写了 useState、useEffect、useMemo、useCallback,它们不是乱七八糟地堆在一起的,而是像一条长龙一样排成一列。
- 第一个 Hook(比如
useState)住在memoizedState。 - 第二个 Hook(比如
useEffect)住在memoizedState的next指针指向的节点里。 - 第三个 Hook(比如
useMemo)住在再下一个节点里。
所以,useMemo 和 useCallback 本质上都是这个链表上的一个“节点”。它们并不独立存在,它们依附于组件的渲染周期。
那么,这个节点里存了什么元数据呢?让我们来看看源码里的逻辑(这里是简化后的伪代码逻辑):
function basicStateReducer(state, action) {
return typeof action === 'function' ? action(state) : action;
}
function mountState(initialState) {
const hook = mountWorkInProgressHook(); // 获取一个新的节点
if (typeof initialState === 'function') {
initialState = initialState();
}
hook.memoizedState = hook.baseState = initialState;
const queue = { pending: null, dispatch: null, lastRenderedReducer: basicStateReducer, lastRenderedState: initialState };
hook.queue = queue;
return [hook.memoizedState, dispatch];
}
// useMemo 和 useCallback 的挂载逻辑大致如下
function mountMemo(create, deps) {
const hook = mountWorkInProgressHook();
// 1. 获取当前的依赖项
const nextDeps = deps !== undefined ? deps : EMPTY_DEPENDENCIES;
// 2. 这里有个关键点:先运行 create,得到一个临时的值
// 注意:这里还没有做对比,所以第一次一定会跑
const nextValue = create();
// 3. 把这个值和依赖项存到 hook 节点里
hook.memoizedState = nextValue;
hook.dependencies = nextDeps;
return nextValue;
}
看到了吗?这就是它们的数据结构雏形。它们都是 WorkInProgressHook(正在工作的钩子)。这个 Hook 对象里,至少包含两个核心数据:
memoizedState: 存储计算结果(useMemo)或者函数引用(useCallback)。dependencies: 存储依赖数组。
第二部分:useMemo——那个“精打细算”的计算器
让我们先聊聊 useMemo。它的名字翻译过来就是“记忆化计算”。听起来很高大上,其实它的核心逻辑非常朴实:“如果我上次算过的东西没变,我就把上次的结果拿出来给你,别让我再算一遍。”
1. 数据结构里的“身份证”
useMemo 存储的 memoizedState 是一个值。这个值可能是数字、对象、数组,或者是一个计算极其复杂的表达式。
const expensiveValue = useMemo(() => {
console.log("我在计算一个巨大的数组...");
return [1, 2, 3, 4, 5].map(n => n * n);
}, []);
在这个例子中,useMemo 的节点里存了 [1, 4, 9, 16, 25]。同时,它还存了一个 dependencies 数组,也就是 []。
2. 核心算法:checkNoChangedDependencies
当组件下一次渲染时,React 会再次调用 useMemo。这时候,它不会直接执行 create 函数,而是先做一件事:比对身份证。
源码里的逻辑大概是这样的(再次强调是简化逻辑):
function updateMemo(create, deps) {
const hook = updateWorkInProgressHook();
const nextDeps = deps !== undefined ? deps : EMPTY_DEPENDENCIES;
// 获取上一次存储的依赖项
const prevState = hook.dependencies;
// 核心比对逻辑
if (checkNoChangedDependencies(prevState, nextDeps)) {
// 如果没变,直接把上次的结果吐出来
return hook.memoizedState;
} else {
// 如果变了,重新计算
const nextValue = create();
hook.memoizedState = nextValue;
hook.dependencies = nextDeps;
return nextValue;
}
}
这里用到了一个辅助函数 checkNoChangedDependencies。它的逻辑很简单,就是遍历两个数组,看看元素是否完全相等(引用相等)。
这里有一个巨大的坑,也是面试常考点!
假设你写了一个 useMemo,依赖项里没有包含 state:
function MyComponent({ data }) {
const [count, setCount] = useState(0);
// 错误示范:依赖项是空数组
const heavyValue = useMemo(() => {
return data.map(item => item * 2);
}, []);
return <div>{heavyValue}</div>;
}
在这种情况下,heavyValue 永远不会更新,即使 data 变了。因为 React 在比对 dependencies 时,发现你提供的 [] 和上一次的 [] 完全一样。React 会想:“既然你自己说依赖项没变,那我就信你的邪,继续用旧数据。”
这就是 useMemo 的元数据陷阱: 它的 memoizedState 是“死”的,除非你手动更新 dependencies。它不关心外部世界发生了什么,除非你在数组里显式地指出来。
3. useMemo 的“副作用”问题
很多人以为 useMemo 只是用来优化性能的。但实际上,它在源码里被归类到“Effect”体系下。为什么?
因为在某些极端情况下(比如 SSR 渲染),useMemo 的计算可能会产生副作用,或者它依赖于某些在渲染阶段才能获取的数据。React 必须确保 useMemo 的计算是在渲染周期的特定阶段进行的,以保持一致性。
第三部分:useCallback——那个“死不悔改”的函数保镖
接下来是 useCallback。如果说 useMemo 是个计算器,那 useCallback 就是个函数复制品机。
它的数据结构看起来和 useMemo 几乎一模一样,也是 memoizedState 链表上的一个节点。但是,它存储的 memoizedState 是一个函数。
const handleClick = useCallback(() => {
console.log("我被点击了!");
}, []);
1. 核心逻辑:闭包的囚徒
useCallback(fn, deps) 本质上等于 useMemo(() => fn, deps)。
但是,React 在处理函数时,有一个特殊的逻辑。当 useMemo 的 create 函数执行时,它会拿到最新的 state 和 props。所以,你返回的那个 fn,是闭包住最新状态的。
那么,useCallback 到底在缓存什么?
它缓存的是函数定义本身。
假设你在父组件里有一个函数传给子组件:
function Parent() {
const [count, setCount] = useState(0);
const handleChildClick = () => {
console.log("Parent count:", count);
};
return (
<Child onClick={handleChildClick} />
);
}
没有 useCallback 的情况:
每次 Parent 重新渲染,handleChildClick 都会被重新创建。这是一个全新的函数对象,内存地址变了。React 会认为:“哎呀,父组件传给我的 onClick 变了!虽然内容一样,但我不认识你了!” 于是,子组件也会重新渲染。
有 useCallback 的情况:
const handleChildClick = useCallback(() => {
console.log("Parent count:", count);
}, [count]);
- 初始渲染:
count是 0。handleChildClick创建,存入memoizedState。 count变成 1,父组件重新渲染。useCallback检查deps。发现deps是[count]。- React 拿
count(1) 和上一次存入的count(0) 比较。 - 发现变了!
- React 重新创建函数。
memoizedState更新为新函数。 - 子组件接收到新函数引用,重新渲染。
等等,这不还是渲染了吗?
是的!不要被 useCallback 的名字骗了!
useCallback 的核心目的不是“防止渲染”,而是防止函数对象的频繁重建。
它在源码层面做了什么?它把函数的创建过程“记忆化”了。它确保了只要 deps 没变,你就拿不到那个新函数。只有当 deps 变了,它才会创建一个新函数。
为什么这很重要?
这很重要,是因为函数的引用稳定性。
当你把 useCallback 返回的函数传给一个使用了 React.memo 的子组件时,它的作用才真正显现出来。
const MemoizedChild = React.memo(Child);
function Parent() {
// ...
const handleChildClick = useCallback(() => { ... }, [count]);
// ...
return <MemoizedChild onClick={handleChildClick} />;
}
count变化,useCallback检测到依赖变化 -> 创建新函数 -> 子组件渲染。otherState变化,但count没变 ->useCallback检测到依赖不变 -> 返回旧函数 -> 子组件不渲染。
这就是 useCallback 的魔法: 它是父组件和 React.memo 子组件之间的“握手协议”。它保证只有当真正需要的时候,才会更新这个协议。
2. 源码层面的“Ref”错觉
很多同学会问:“React 源码里,useCallback 是不是用了 ref 来保存函数?”
不是的。useCallback 的 memoizedState 就是那个函数本身。它不像 useRef 那样存一个指向外部对象的引用。
但是,React 内部确实有 ref 机制用于调度。在源码的 scheduleUpdateOnFiber 流程中,React 会检查 update 是否会导致 Hook 的 memoizedState 发生变化。如果 useCallback 的依赖项没变,memoizedState(那个函数)没变,那么 React 可能会跳过某些更新流程,或者标记不同的 Effect 阶段。
第四部分:深度对比——它们到底有什么“血统”关系?
好了,现在我们站在解剖室的高处,俯瞰这两个兄弟。
1. 相同点:同根同源
- 宿主: 它们都住在
memoizedState链表里。 - 依赖检查: 它们都使用
dependencies数组来决定是否重新计算。 - 调度机制: 它们都遵循 React 的调度机制。如果依赖项没变,它们可能根本不会触发
create函数的执行,从而节省了 CPU 开销。 - 闭包陷阱: 它们都会捕获当前的
state和props。如果你在useMemo或useCallback里依赖了外部变量(且没放进去),你会得到旧值。
2. 不同点:性格迥异
这是我们要讲的重点。
-
存储的数据类型:
useMemo: 存的是计算结果(Value)。useCallback: 存的是函数引用(Function)。
-
触发机制:
useMemo: 只要依赖项变了,它就重新执行create函数,并把结果存回去。useCallback: 只要依赖项变了,它就重新创建函数,并把新函数存回去。
-
使用场景与副作用:
useMemo: 通常用于昂贵计算(比如过滤大数组、复杂对象转换)。如果你把一个不需要计算的东西放进去,那就是浪费内存。useCallback: 通常用于传递给子组件的回调函数,或者依赖函数引用的其他 Hook(如useEffect的依赖数组)。
3. 一个反直觉的源码细节
让我们看一段 React 源码中非常经典的逻辑(简化版):
function updateWorkInProgressHook() {
// ...
const existingHook = workInProgress.memoizedState;
if (existingHook !== null) {
// 如果这个 Hook 之前存在
// ...
return existingHook;
}
// ...
}
// useMemo 的实现
function useMemo(create, deps) {
const hook = updateWorkInProgressHook();
const nextDeps = deps !== undefined ? deps : EMPTY;
// 1. 检查依赖是否变化
if (checkDepsChanged(hook.dependencies, nextDeps)) {
// 2. 如果变了,创建新值
const nextValue = create();
hook.memoizedState = nextValue;
hook.dependencies = nextDeps;
return nextValue;
} else {
// 3. 如果没变,返回旧值
return hook.memoizedState;
}
}
注意看第 3 步。当依赖没变时,它直接 return hook.memoizedState。
这意味着什么?这意味着useMemo 的计算是惰性的,且是缓存导向的。
但是,对于 useCallback 呢?useCallback 只是 useMemo 的一个特例:
function useCallback(callback, deps) {
// 这里的 create 函数只是返回传入的 callback
return useMemo(() => callback, deps);
}
等等,这不对!如果 useCallback 只是 useMemo 的封装,那它岂不是每次都要执行 () => callback 这个箭头函数?
是的!这就是为什么 useCallback 不能包裹一个动态的回调。
// 错误示范
function Parent() {
const [count, setCount] = useState(0);
// 这里的 create 每次都会执行
const handleClick = useCallback(() => {
console.log(count);
}, [count]);
}
每次 count 变,useCallback 内部的箭头函数 () => callback 就会执行一次。虽然它很快,但如果你在 useCallback 里面做了极其复杂的初始化逻辑,那它就会拖慢渲染速度。
所以,useCallback 的最佳实践是:
它应该包裹一个静态的函数定义,或者是一个纯函数。
// 正确示范
function Parent() {
const [count, setCount] = useState(0);
// 这里的 create 只执行一次
const handleClick = useCallback(() => {
console.log(count);
}, [count]);
}
第五部分:实战中的“元数据”与陷阱
在实际的源码调试中,你会看到很多奇怪的数据。让我们来解读一下这些“元数据”。
1. Dependency List 的构建
当你写 useMemo(() => fn(a, b), [a, b]) 时,React 不会真的去执行 fn 来分析它的依赖,那是不可行的。React 使用的是静态分析(Static Analysis)。
在编译阶段(或者构建阶段),Babel 插件会把 fn 的参数提取出来,生成一个依赖数组。
在运行时,React 会把这个数组保存在 Hook 节点里。
2. 闭包的“时间胶囊”
这是 React Hooks 最难理解的地方。
当你调用 useMemo 或 useCallback 时,你处于组件函数的执行上下文中。此时,你拿到的 a 和 b 是最新的。
function Component() {
const [a, setA] = useState(1);
const [b, setB] = useState(2);
const memoizedFn = useCallback(() => {
console.log(a, b); // 这里的 a, b 是闭包
}, [a, b]);
return <button onClick={() => setA(10)}>Change A</button>;
}
当你点击按钮,a 变成了 10。useCallback 检测到依赖变化,重新创建了函数。新的函数再次捕获了最新的 a (10) 和 b (2)。
如果你忘记写 b 在依赖数组里:
const memoizedFn = useCallback(() => {
console.log(a, b);
}, [a]); // b 丢了!
当 a 变化时,memoizedFn 会更新。但是,它捕获的 b 依然是上一次渲染时的 b。
这就是所谓的“过时的闭包”。在 React 源码层面,这是因为 memoizedState 里的 memoizedValue(也就是函数)更新了,但函数内部引用的 b 变量并没有更新,因为它被“锁”在了旧的作用域里。
第六部分:性能优化的“玄学”与真相
很多同学问:“老师,我用了 useCallback,性能就起飞了吗?”
不一定。甚至可能更慢。
让我们来算一笔账。
场景一:没有 useCallback
- 父组件渲染 -> 创建函数 -> 传递给子组件 -> 子组件渲染 -> 销毁函数。
场景二:有 useCallback
- 父组件渲染 -> 检查依赖 -> 依赖没变 -> 复用旧函数 -> 子组件不渲染。
看起来场景二胜出。但是,如果依赖项总是变呢?
场景三:依赖项总是变
- 父组件渲染 -> 检查依赖 -> 依赖变了 -> 创建新函数 -> 传递给子组件 -> 子组件渲染。
这时候,useCallback 增加了一层 checkDeps 的计算开销。而且,创建新函数本身也是需要时间的(虽然微乎其微)。
那么,到底什么时候用?
- 传给
React.memo的子组件: 这是标准答案。因为React.memo默认比较 props 的引用。如果不缓存函数引用,子组件每次都重新渲染,这是性能杀手。 - 放在
useEffect的依赖数组里: 很多时候我们想在副作用里用最新的 state,但又不想让 effect 触发重渲染。这时候useCallback就很有用,它可以稳定函数引用,防止 effect 无限循环。
1. useMemo 的真相
useMemo 的坑更多。
如果你把它用在普通变量上:
const value = useMemo(() => complexCalculation(data), [data]);
如果 data 没变,它确实不计算。但如果 data 变了,它计算完,然后这个计算结果被用在了 JSX 里。
但是! React 渲染是同步的。useMemo 的计算也是同步的。如果在计算过程中抛出异常,或者计算时间过长,整个渲染就会卡死,甚至导致页面白屏。
所以,useMemo 适合用来缓存计算结果,而不是用来做异步逻辑(那得用 useEffect)。
2. 源码里的“记忆化”实现
React 为了实现 useMemo,内部其实维护了一个“记忆池”。当依赖没变时,它直接从池子里取数据。当依赖变了,它把新数据放进去。
这种机制在 React 18 的并发模式下变得更加重要。因为渲染现在是可中断的。useMemo 的计算过程必须非常短,不能被打断,否则会导致状态不一致。
第七部分:总结与升华
好了,同学们,解剖课接近尾声。
我们来看看 useMemo 和 useCallback 的本质区别:
- useMemo 是一个数据缓存器。它存的是“计算后的结果”。它的核心是计算。如果你不做任何计算,只存一个变量,那就是在浪费 CPU。
- useCallback 是一个函数复用器。它存的是“函数定义”。它的核心是引用稳定。如果你只是为了防止函数重建,而它每次都在重建,那它就是摆设。
在源码层面,它们共享着同一个 memoizedState 链表结构,共享着 checkNoChangedDependencies 的比对逻辑。
最后的忠告:
不要为了优化而优化。
如果你不确定 useMemo 和 useCallback 是否必要,先别写。让 React 默认的渲染流程跑起来。React 已经非常快了,除非你遇到了明显的性能瓶颈(比如渲染了 10000 行数据),否则不要提前加这些“紧箍咒”。
当你在源码里看到 memoizedState 指向一个函数,或者指向一个对象时,你要知道:那不是魔法,那是一个被精心管理的数据节点。 它在依赖项的变化中起舞,在闭包的牢笼里坚守,只为给你提供一个稳定、高效的渲染体验。
记住,代码是写给人看的,顺便给机器运行。 如果你的 Hook 逻辑复杂到连你自己都看不懂(比如为了省那一点点内存把逻辑拆得支离破碎),那它就是失败的设计。
今天的课就到这里。下课!记得去检查一下你的代码,是不是写了一堆没用的 useCallback?
(下课铃响……)