React useMemo 与 useCallback:这两者在源码层面的数据结构有何异同?它们各自存储了哪些元数据?

React 源码解剖课:useMemo 与 useCallback 的“灵魂”对谈

各位同学,下午好!

欢迎来到今天的“React 内部解剖室”。我是你们的带教老师。今天我们要做的,不是给你们画一个简单的饼图,也不是教你们怎么写一个 useCallback 来骗过 ESLint,而是我们要拿起手术刀,切开 React 的胸膛,看看它的心脏——也就是那些 Hooks——里面到底装的是什么。

我们今天的主角是两位老朋友:useMemouseCallback。在平时的开发中,你们可能觉得它们是“防抖神器”或者是“性能优化工具”。但在源码层面,它们其实是两个性格迥异的室友,住在一个叫做 memoizedState 的狭窄公寓里。

别眨眼,我们开始。


第一部分:Fiber——Hook 的“户籍登记处”

在深入这两个 Hook 之前,我们必须先搞清楚它们住在哪里。在 React 的世界里,每一个组件实例都有一个对应的 Fiber 节点。你可以把 Fiber 节点想象成 React 组件的“物理身体”。

在这个身体里,有一个非常关键的属性,叫作 memoizedState。这不仅仅是一个变量,它是一个单链表

是的,你没听错。memoizedState 是一个链表。这意味着什么呢?这意味着在一个组件里,如果你写了 useStateuseEffectuseMemouseCallback,它们不是乱七八糟地堆在一起的,而是像一条长龙一样排成一列。

  1. 第一个 Hook(比如 useState)住在 memoizedState
  2. 第二个 Hook(比如 useEffect)住在 memoizedStatenext 指针指向的节点里。
  3. 第三个 Hook(比如 useMemo)住在再下一个节点里。

所以,useMemouseCallback 本质上都是这个链表上的一个“节点”。它们并不独立存在,它们依附于组件的渲染周期。

那么,这个节点里存了什么元数据呢?让我们来看看源码里的逻辑(这里是简化后的伪代码逻辑):

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 对象里,至少包含两个核心数据:

  1. memoizedState: 存储计算结果(useMemo)或者函数引用(useCallback)。
  2. 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 在处理函数时,有一个特殊的逻辑。当 useMemocreate 函数执行时,它会拿到最新的 stateprops。所以,你返回的那个 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]);
  1. 初始渲染:count 是 0。handleChildClick 创建,存入 memoizedState
  2. count 变成 1,父组件重新渲染。
  3. useCallback 检查 deps。发现 deps[count]
  4. React 拿 count (1) 和上一次存入的 count (0) 比较。
  5. 发现变了!
  6. React 重新创建函数。memoizedState 更新为新函数。
  7. 子组件接收到新函数引用,重新渲染。

等等,这不还是渲染了吗?

是的!不要被 useCallback 的名字骗了!

useCallback 的核心目的不是“防止渲染”,而是防止函数对象的频繁重建

它在源码层面做了什么?它把函数的创建过程“记忆化”了。它确保了只要 deps 没变,你就拿不到那个新函数。只有当 deps 变了,它才会创建一个新函数。

为什么这很重要?

这很重要,是因为函数的引用稳定性

当你把 useCallback 返回的函数传给一个使用了 React.memo 的子组件时,它的作用才真正显现出来。

const MemoizedChild = React.memo(Child);

function Parent() {
  // ...
  const handleChildClick = useCallback(() => { ... }, [count]);
  // ...
  return <MemoizedChild onClick={handleChildClick} />;
}
  1. count 变化,useCallback 检测到依赖变化 -> 创建新函数 -> 子组件渲染。
  2. otherState 变化,但 count 没变 -> useCallback 检测到依赖不变 -> 返回旧函数 -> 子组件不渲染

这就是 useCallback 的魔法: 它是父组件和 React.memo 子组件之间的“握手协议”。它保证只有当真正需要的时候,才会更新这个协议。

2. 源码层面的“Ref”错觉

很多同学会问:“React 源码里,useCallback 是不是用了 ref 来保存函数?”

不是的。useCallbackmemoizedState 就是那个函数本身。它不像 useRef 那样存一个指向外部对象的引用。

但是,React 内部确实有 ref 机制用于调度。在源码的 scheduleUpdateOnFiber 流程中,React 会检查 update 是否会导致 Hook 的 memoizedState 发生变化。如果 useCallback 的依赖项没变,memoizedState(那个函数)没变,那么 React 可能会跳过某些更新流程,或者标记不同的 Effect 阶段。


第四部分:深度对比——它们到底有什么“血统”关系?

好了,现在我们站在解剖室的高处,俯瞰这两个兄弟。

1. 相同点:同根同源

  • 宿主: 它们都住在 memoizedState 链表里。
  • 依赖检查: 它们都使用 dependencies 数组来决定是否重新计算。
  • 调度机制: 它们都遵循 React 的调度机制。如果依赖项没变,它们可能根本不会触发 create 函数的执行,从而节省了 CPU 开销。
  • 闭包陷阱: 它们都会捕获当前的 stateprops。如果你在 useMemouseCallback 里依赖了外部变量(且没放进去),你会得到旧值。

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 最难理解的地方。

当你调用 useMemouseCallback 时,你处于组件函数的执行上下文中。此时,你拿到的 ab 是最新的。

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 的计算开销。而且,创建新函数本身也是需要时间的(虽然微乎其微)。

那么,到底什么时候用?

  1. 传给 React.memo 的子组件: 这是标准答案。因为 React.memo 默认比较 props 的引用。如果不缓存函数引用,子组件每次都重新渲染,这是性能杀手。
  2. 放在 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 的计算过程必须非常短,不能被打断,否则会导致状态不一致。


第七部分:总结与升华

好了,同学们,解剖课接近尾声。

我们来看看 useMemouseCallback 的本质区别:

  • useMemo 是一个数据缓存器。它存的是“计算后的结果”。它的核心是计算。如果你不做任何计算,只存一个变量,那就是在浪费 CPU。
  • useCallback 是一个函数复用器。它存的是“函数定义”。它的核心是引用稳定。如果你只是为了防止函数重建,而它每次都在重建,那它就是摆设。

在源码层面,它们共享着同一个 memoizedState 链表结构,共享着 checkNoChangedDependencies 的比对逻辑。

最后的忠告:

不要为了优化而优化。

如果你不确定 useMemouseCallback 是否必要,先别写。让 React 默认的渲染流程跑起来。React 已经非常快了,除非你遇到了明显的性能瓶颈(比如渲染了 10000 行数据),否则不要提前加这些“紧箍咒”。

当你在源码里看到 memoizedState 指向一个函数,或者指向一个对象时,你要知道:那不是魔法,那是一个被精心管理的数据节点。 它在依赖项的变化中起舞,在闭包的牢笼里坚守,只为给你提供一个稳定、高效的渲染体验。

记住,代码是写给人看的,顺便给机器运行。 如果你的 Hook 逻辑复杂到连你自己都看不懂(比如为了省那一点点内存把逻辑拆得支离破碎),那它就是失败的设计。

今天的课就到这里。下课!记得去检查一下你的代码,是不是写了一堆没用的 useCallback

(下课铃响……)

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注