大家好,欢迎来到今天的“React 内存大讲堂”。
我是你们的老朋友,一个在 React 源码的迷宫里摸爬滚打多年,头发比我的代码还少的资深工程师。
今天我们不谈业务逻辑,不谈 React Router 怎么配,我们要聊聊 React 给我们提供的两个最迷人的“记忆大师”:useMemo 和 useCallback。
很多同学听到这两个名字,第一反应是:“哦,它们是优化性能的。”
没错,但这只是冰山一角。如果把 React 的渲染过程比作一场大型的建造工程,那么这两个 Hook 就像是两个不同的工种。一个负责“存档”,一个负责“记忆”。
今天,我们要钻进 React 的肚子里——也就是 Fiber 节点 的内存布局中,去看看这俩家伙到底是怎么在那堆乱七八糟的指针和引用里生活的。
准备好了吗?让我们把 React 源码的面包撕开,看看里面的馅儿是不是全是 Bug。
第一部分:Fiber 节点——React 的微型大脑
在深入 useMemo 和 useCallback 之前,我们必须先聊聊它们寄宿的“尸体”——不,是“载体”——Fiber 节点。
你可能知道,React 16 以后放弃了递归,改用了 Fiber 架构。Fiber 是什么?在宏观上,它是 React 调度任务的单元;在微观上,它就是一个复杂的 JavaScript 对象。
每一个函数组件,每一次渲染,都会对应一个 Fiber 节点。这个节点就像是一个孤独的工人在工地上干活,它身上挂着一个属性叫 memoizedState。
这个 memoizedState 是干嘛的?它是 React 的“记事本”。
当你写下 const count = useState(0),React 就把 count 的值写进了这个记事本。
当你写下 const user = useMemo(...),React 就把计算好的 user 对象写进了这个记事本。
当你写下 const handleClick = useCallback(...),React 就把那个函数的引用地址写进了这个记事本。
所以,今天的主题——计算结果(useMemo 返回的值)和 闭包函数(useCallback 返回的函数),它们的区别,本质上就是这两个东西在 memoizedState 这个记事本里,写下的字有什么不同。
第二部分:useCallback——那个永远在“回忆过去”的守财奴
让我们先来看看 useCallback。
1. 闭包函数的诞生与囚禁
想象一下,你有一个非常昂贵的计算逻辑,或者更常见的情况,你有一个子组件。
子组件 Child 是个“玻璃心”,它接收一个 prop。如果 prop 引用地址变了,它就痛哭流涕地重新渲染。
于是你写下了这样一段代码:
import React, { useState, useCallback } from 'react';
const Child = React.memo(({ onButtonClick }) => {
console.log("Child 渲染了!");
return <button onClick={onButtonClick}>点我</button>;
});
const Parent = () => {
const [count, setCount] = useState(0);
// 这是一个典型的优化写法
const handleClick = useCallback(() => {
console.log("Count is:", count);
setCount(c => c + 1);
}, []); // 依赖项为空数组
return (
<div>
<p>Parent Count: {count}</p>
<Child onButtonClick={handleClick} />
<button onClick={() => setCount(count + 1)}>Parent Increment</button>
</div>
);
};
这段代码看似完美,对吧?但是,我们要聊聊内存。
生命周期 1:初始化渲染
React 开始渲染 Parent。
它看到了 handleClick = useCallback(...)。
React 偷偷地在内存里构建了一个函数。假设这个函数的地址是 0x1a2b3c。
然后,React 检查了 useCallback 的依赖项 []。它说:“好家伙,依赖项没变,那我就把你这个函数的地址(0x1a2b3c)写进 memoizedState 里。”
生命周期 2:父组件更新
你点击了 Parent Increment。count 变成了 1。
React 重新渲染 Parent。
它又看到了 handleClick = useCallback(...)。
React 又偷偷地在内存里构建了一个新的函数。假设这个新函数的地址是 0x9f8e7d。
React 检查了 useCallback 的依赖项 []。它说:“好家伙,依赖项还是没变!所以我不能换掉 memoizedState 里的旧函数。”
于是,useCallback 返回了 0x1a2b3c。
内存布局揭秘:
在 Fiber 节点的 memoizedState 中,存储的是 函数对象的引用。
这个函数对象一旦被创建,就被锁死在 Fiber 节点里,直到组件卸载(Unmount)。
这就意味着,即使父组件渲染了 100 次,handleClick 这个函数对象也只被创建了一次!
对于 Child 组件来说,它每次拿到的 onButtonClick 的地址都是 0x1a2b3c,它一看:“咦?地址没变,我不用哭,不用渲染。”
闭包陷阱:
这就是 useCallback 的精髓,也是它的坑。
当你创建这个函数时,count 是 0。闭包机制把 count “吃”进去了。
如果你在函数里用了 count,它永远引用的是函数创建时的那个 count 值。
即使你点击了 100 次按钮,函数里的 count 可能还是 0。
这就是为什么在 useCallback 里更新状态要写成 setCount(c => c + 1),而不是 setCount(count + 1)。
React 每次都给你返回一个“过去的幽灵函数”,这个幽灵函数里藏着过去的数据。
2. useCallback 的内存开销
虽然它阻止了子组件重绘,但它自己也很累。因为它不仅要维护自己的状态,还要负责“记忆”它那个闭包环境。
在内存布局上,useCallback 维护的是一个 函数指针。
函数指针本身很小(就几个字节,指向堆内存里的函数体)。
但是,它指向的那个函数体里,可能包含大量的外部变量引用(闭包变量)。
这些闭包变量在内存布局上可能占据不小的空间,尤其是在闭包非常深的时候。
第三部分:useMemo——那个只存结果的“计算器”
现在轮到 useMemo 登场了。
useMemo(fn, deps) 和 useCallback(fn, deps) 看起来很像,但在内存布局上,它们是完全不同的物种。
1. 计算结果的存储
还是上面的例子,我们换个方式:
const Parent = () => {
const [count, setCount] = useState(0);
// 这是一个昂贵的对象
const expensiveObject = useMemo(() => {
console.log("计算 expensiveObject...");
return {
id: count,
timestamp: Date.now(),
deepValue: Array(10000).fill('x') // 模拟大对象
};
}, [count]);
return (
<div>
<p>Parent Count: {count}</p>
{/* Child 接收这个对象 */}
<Child data={expensiveObject} />
<button onClick={() => setCount(count + 1)}>Parent Increment</button>
</div>
);
};
生命周期 1:初始渲染
count 是 0。
useMemo 被执行。它创建了一个巨大的对象 { id: 0, timestamp: 12345, deepValue: [...] }。
这个对象在堆内存里拥有一个真实的内存地址,比如 0x999888。
React 把这个地址 0x999888 写进了 Fiber 节点的 memoizedState。
生命周期 2:点击按钮,count 变为 1
React 重新渲染。
useMemo 再次被触发。它准备创建一个新的对象 { id: 1, timestamp: 12346, deepValue: [...] }。
新对象的地址是 0x777666。
React 检查依赖项 [count]。发现 count 从 0 变成了 1。
React 说:“哟,依赖变了,之前的存档不行了,换新的!”
React 更新 Fiber 节点的 memoizedState,把它从 0x999888 变成 0x777666。
生命周期 3:再次点击,count 变为 2
React 再次渲染。
useMemo 被触发。准备创建 { id: 2, ... }。
检查依赖项。count 是 2。
React 说:“依赖又变了,再换新的!”
memoizedState 变成 0x555444。
内存布局揭秘:
在 Fiber 节点的 memoizedState 中,useMemo 存储的 是一个具体的值。
这个值可能是数字、字符串,也可能是对象、数组。
注意,这个值是 动态的。它不像 useCallback 那样是一个顽固的函数引用,useMemo 的结果是根据依赖项变化而“流动”的。
关键差异:对象引用的稳定性
如果你不使用 useMemo:
const Parent = () => {
const [count, setCount] = useState(0);
// 每次渲染都创建一个新对象
const data = { id: count };
return <Child data={data} />;
};
每次渲染,data 的地址都不同。Child 会一直重绘。
如果你使用了 useMemo(且依赖项不变):
const Parent = () => {
const [count, setCount] = useState(0);
const data = useMemo(() => ({ id: count }), [count]);
return <Child data={data} />;
};
如果 count 不变,useMemo 会直接返回上一次的地址。Child 就不会重绘。
第四部分:深度对决——useMemo vs useCallback 的内存博弈
现在,让我们把这两个家伙放在同一个赛场上,进行一场残酷的内存淘汰赛。
场景:我们既需要一个计算结果,又需要一个回调函数
假设我们在开发一个复杂的表单编辑器。
const FormEditor = ({ user }) => {
const [formData, setFormData] = useState({ name: '', email: '' });
// 场景 A:我们需要一个格式化后的用户信息给子组件展示
const formattedUser = useMemo(() => {
return {
displayName: user.name.toUpperCase(),
greeting: `Hello, ${user.name}`
};
}, [user.name]); // 依赖项是 user.name
// 场景 B:我们需要一个提交函数传给子组件
const handleSubmit = useCallback((e) => {
e.preventDefault();
console.log("Submitting:", formData);
setFormData({ name: '', email: '' });
}, [formData]); // 依赖项是 formData
return (
<div>
<ChildDisplay data={formattedUser} /> {/* 子组件只需要读 */}
<ChildSubmit onAction={handleSubmit} /> {/* 子组件需要传函数回去 */}
</div>
);
};
在这个例子中,我们的选择是合理的。但是,它们的内存生命周期完全不同。
1. 存储形式的差异
-
useMemo(formattedUser):- 存在 Fiber 的
memoizedState里。 - 内容是:一个普通对象的引用。
- 特性:只读。除非依赖项变了,否则它不会变。
- 如果依赖项没变,子组件
ChildDisplay比较formattedUser的引用,发现没变,就不渲染。 - 如果依赖项变了(比如
user.name变了),useMemo会重新计算,扔掉旧的formattedUser对象(等待垃圾回收 GC),并把新的对象地址存进去。
- 存在 Fiber 的
-
useCallback(handleSubmit):- 存在 Fiber 的
memoizedState里。 - 内容是:一个函数的引用。
- 特性:只读。除非依赖项变了,否则它不会变。
- 如果依赖项没变,子组件
ChildSubmit比较onAction的引用,发现没变,就不渲染。 - 如果依赖项变了(比如
formData变了),useCallback会创建一个新的函数,扔掉旧的函数,并把新函数的地址存进去。
- 存在 Fiber 的
2. 闭包带来的内存延迟
这是最容易被忽视,但也最致命的一点。
useCallback 的内存“僵尸”问题:
当我们调用 useCallback 时,React 会创建一个函数。这个函数在 JS 引擎的堆内存里,它“记住”了那一刻的执行环境。
const Parent = () => {
const [count, setCount] = useState(0);
// 每次 count 变化,这里都会创建一个新的函数
const increment = useCallback(() => {
setCount(count + 1);
}, [count]);
return <button onClick={increment}>Increment</button>;
};
当 count 是 0 时,increment 函数被创建。它闭包捕获了 count = 0。
当 count 变成 1 时,increment 函数被重新创建。它闭包捕获了 count = 1。
当 count 变成 2 时,increment 函数被重新创建。它闭包捕获了 count = 2。
注意到了吗?每一个旧的 increment 函数(以及它包含的旧 count 值),只要 Fiber 节点还活着,它们就不会立即消失。
这就是 React 的 Fiber 生命周期。只要组件没卸载,memoizedState 里的东西就一直占着内存。
但是,React 的 Commit 阶段和垃圾回收器(GC)会处理旧对象的清理。只是,在频繁渲染的情况下,useCallback 会产生大量的临时函数对象。
useMemo 的内存“缓存”特性:
相比之下,useMemo 如果依赖项没变,它就复用旧的结果。
const Parent = () => {
const [complexData, setComplexData] = useState(generateHugeData());
const processedData = useMemo(() => {
return process(complexData);
}, [complexData]);
return <Display data={processedData} />;
};
如果 complexData 没变,processedData 的地址一直是同一个。
这意味着,你省去了重复计算 process(complexData) 的时间(CPU 时间),也省去了创建新对象的内存开销(RAM 时间)。
3. 指针 vs. 数据的较量
从内存布局的底层来看:
-
useCallback的存储:- 类型:Function。
- 大小:固定(取决于函数本身的代码量,通常很小)。
- 外挂:闭包变量(可能很大,例如捕获了一个巨大的 state 数组)。
- 行为:由于函数对象本身大小固定,React 可以很容易地通过“引用相等性”来判断是否需要更新。
-
useMemo的存储:- 类型:任意(Object, Array, Number, String…)。
- 大小:可变(可能巨大,例如一个包含 10000 条数据的数组)。
- 行为:
useMemo的性能瓶颈通常不在于“存”,而在于“算”。如果对象太大,React 在比较引用相等性时可能需要遍历深层结构(虽然 React 只比较引用)。
第五部分:陷阱与玄学——那些让你掉进内存坑的用法
在讲座的最后,我想聊聊这两个 Hook 容易犯的错,以及它们在内存管理上的反直觉行为。
陷阱一:useCallback 的“虚假”记忆
很多新手认为,加了 useCallback 就万事大吉了,永远不会重绘。
错!大错特错!
const Parent = () => {
const [count, setCount] = useState(0);
const [keyword, setKeyword] = useState('');
// 依赖项是 [count]
const handleClick = useCallback(() => {
console.log(`Clicked with count: ${count}`);
}, [count]);
return (
<div>
<input value={keyword} onChange={e => setKeyword(e.target.value)} />
<button onClick={handleClick}>Log</button>
</div>
);
};
在这个例子中,handleClick 只依赖了 count。
当你修改 keyword 输入框时,Parent 组件会重新渲染(因为输入框变了)。
虽然 Parent 重绘了,但是 handleClick 的引用地址没变!
所以,如果你把 handleClick 传给一个子组件,这个子组件不会重绘。
这就导致了一个经典的 Bug:子组件里的 handleClick 永远拿不到最新的 keyword(因为它被闭包锁住了旧数据),而父组件渲染了,但子组件没反应。
内存上的教训:
依赖项数组里的每一个变量,都是“记忆开关”。少写一个,内存里的那个幽灵函数就会多保留一份“旧世界”的数据,直到组件卸载。
陷阱二:useMemo 的滥用
有些同学为了省事,把所有东西都用 useMemo 包起来:
const expensiveFunction = () => { /* ... */ };
const Parent = () => {
// ...
const result = useMemo(() => expensiveFunction(), []);
};
这看似没问题。但是,如果你把它放在一个频繁渲染的组件里,并且依赖项是空数组 [],那么这个计算只会在组件挂载时运行一次。
如果你在组件内部去修改这个 result 对象(例如 result.value = 100),你会破坏 React 的单一数据源原则,导致 memo 失效。
更重要的是,不要过度优化。
如果计算非常快(比如简单的加减乘除),或者计算频率极低(比如只在点击时运行),根本不需要 useMemo。
React 的 Fiber 架构本身就是为了在每次渲染时快速创建新的状态树。如果你把所有东西都 useMemo 了,反而增加了 React 获取数据的复杂度。
第六部分:并发模式下的生命周期演变
最后,我们稍微展望一下 React 18 的并发模式。
在并发模式下,渲染不再是线性的了。React 可能会在 useEffect 之后、useLayoutEffect 之前暂停渲染,去处理高优先级的更新。
这对 useCallback 和 useMemo 有什么影响?
useCallback:
它是基于渲染周期的。只要有渲染周期,它就会尝试复用旧引用。
但是,在并发渲染中,Fiber 节点可能会被“中断”然后“恢复”。
这意味着,虽然 React 试图保持 memoizedState 不变,但如果渲染过程被打断,闭包里捕获的状态可能已经是“过去式”了。
这也解释了为什么在 React 18 中,useEffect 里的回调经常拿到的是“陈旧”的状态。因为渲染被打断了,useCallback 返回的那个“旧幽灵函数”被重新使用了。
useMemo:
同样,计算如果被打断,memoizedState 里的值可能会在“计算前”和“计算后”之间闪烁。
更严重的是,如果依赖项发生变化,React 可能在并发阶段重新执行 useMemo,而旧的值可能还没有被清理掉。
这揭示了 React 内存模型中一个微妙的现实:“稳定”只是暂时的。
Fiber 节点只是当前渲染周期的快照。在并发的洪流中,函数引用和计算结果都在不断地被创建、更新、甚至被遗忘。
结语:做聪明的记忆管理者
好了,同学们,今天的讲座就要结束了。
我们回顾一下今天的干货:
- Fiber 节点 是 React 存储记忆的仓库,通过
memoizedState属性存储我们的数据。 useCallback存储的是 函数引用。它像一个顽固的守财奴,保存着过去的闭包环境,防止子组件因引用变化而重绘。useMemo存储的是 计算结果。它像一个聪明的计算器,只有当依赖项变化时才重新计算,避免重复创建大对象。- 内存布局 上,函数引用很小但闭包环境可能很大;计算结果可能很大但依赖变化时会被替换。
- 生命周期 上,它们都绑定在 Fiber 节点上,随组件存在而存在,但引用的稳定性取决于依赖数组。
记住,React 的内存优化不是要你把所有东西都记住,而是要你记住那些真正需要被记住的东西。
不要为了优化而优化,也不要因为害怕内存泄漏而过度使用 useCallback。
就像人生一样,我们要学会在 useCallback 里保留对过去的敬畏,在 useMemo 里保留对未来的期许,但不要把所有的包袱都死死地抓在手里。
好了,现在,去你的代码里,好好整理一下内存吧!
下课!