React useMemo 存储代价:源码分析计算结果在 Fiber 节点内存布局中所占的字节权重

各位好,欢迎来到“React 内存深潜”研讨会。我是你们的主讲人,一个在内存泄漏边缘疯狂试探的资深工程师。

今天我们不聊业务逻辑,不聊组件复用,也不聊那个让你头秃的 key 值。今天,我们要聊的是更底层、更硬核的东西——内存

在 React 的世界里,我们每天都在制造大量的组件实例。每一个组件实例,在 React 内部都有一个对应的化身,我们称之为 Fiber 节点。它就像是组件在虚拟 DOM 世界里的肉身。

useMemo,这个 Hooks 里的“缓存大师”,它的核心功能就是把这个肉身里的一部分记忆——计算结果——给存起来。但是,各位,存储是有代价的。这个代价不仅仅是 CPU 的计算时间,更重要的是它在内存中占据了多少地盘。

今天,我们就拿着手术刀,切开 Fiber 节点的内存布局,看看 useMemo 到底在里面藏了多少字节,以及这些字节是如何影响我们应用的性能的。


第一部分:Fiber 节点——组件的“豪宅”

首先,我们要明白一个概念:在 React Fiber 架构下,组件实例并不直接存在于内存中。取而代之的,是 FiberNode 结构体。每一个 DOM 元素、每一个函数组件、每一个类组件,都有一个对应的 FiberNode

你可以把 FiberNode 想象成一座豪宅。这座豪宅里住了谁?有房子的主人(组件实例)、有房子的结构(tag, type)、有房子的装修(props)、有房子的债务(effect list),还有一个极其重要的储藏室——memoizedState

memoizedState 是什么?它是 React 存储组件“副作用”和“状态”的地方。当你调用 useStateuseReduceruseRef 或者 useMemo 时,你都在往这个 memoizedState 里塞东西。

代码示例:组件与 Fiber 的关系

function UserProfile({ id }) {
  const [name, setName] = useState("Alice");
  const role = useMemo(() => calculateRole(id), [id]);

  return <div>{name} is a {role}</div>;
}

当 React 渲染这个 UserProfile 组件时,它会创建一个 Fiber 节点。这个 Fiber 节点的 memoizedState 字段,就像一个挂钩,挂起了一串链表。

这个链表长什么样?我们来看看 React 源码中的定义(概念版):

// FiberNode 结构体(简化版)
struct FiberNode {
  // ... 其他字段:tag, type, return, child, sibling, stateNode, ...

  // 这是一个指针,指向该 Fiber 节点管理的 Hooks 链表
  memoizedState: FiberNode; 
};

// Hook 结构体(简化版)
struct FiberNode {
  // 当前 Hook 缓存的值(useState的值,useMemo的结果等)
  memoizedState: any;

  // 下一个 Hook,组成链表
  next: FiberNode;

  // ... 其他字段:queue, update, baseState, ...
};

所以,UserProfile 组件的 memoizedState,指向的是一个链表的头节点。这个链表上挂载了 useState 的值(name)、useRef 的值、以及 useMemo 的结果(role)。


第二部分:useMemo 的“挂载”过程——内存的诞生

当你的组件首次渲染时,React 会调用 mountWorkInProgressHook。这个函数就像是造房子的地基工头。它创建了一个新的 Hook 对象,并将其挂载到 Fiber 节点的 memoizedState 链表末尾。

对于 useMemo,React 会调用 mountMemo。让我们看看它的源码逻辑(React 18 源码逻辑):

function mountMemo(create, deps) {
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? EMPTY_ARRAY : deps;

  // 1. 调用创建函数,获取结果
  const nextValue = create();

  // 2. 将结果存入 memoizedState
  // 这里的 hook.memoizedState 被赋值为 nextValue
  // 注意:这里存的是引用,不是深拷贝!
  hook.memoizedState = nextValue;

  // 3. 将这个 Hook 对象挂载到链表上
  // 此时,memoizedState 指向的这个 Hook 对象,其 memoizedState 字段就是计算结果
  return nextValue;
}

内存分析时刻:

当你写下 useMemo(() => hugeObject, []) 时,React 做了什么?

  1. 创建 Hook 对象:在堆内存中分配一块空间,用于存储这个 Hook 的元数据。这块空间的大小取决于编译后的结构体定义。
  2. 计算值:执行 hugeObject 的创建逻辑。
  3. 存储引用:将 hugeObject 的内存地址赋值给 hook.memoizedState

这里有一个非常关键的点:useMemo 存储的是引用,不是副本。如果 hugeObject 是一个包含 1000 个对象的数组,那么这个数组本身占用的内存并没有减少,反而因为被 Fiber 节点引用,而无法被垃圾回收(GC)。


第三部分:字节权重分析——到底占了多少地?

现在,让我们把显微镜调到最大倍数,来看看这些字节到底是怎么分布的。

1. FiberNode 自身的开销

一个标准的 FiberNode 在 64 位系统上,仅仅是结构体本身的字段占用,大约就在 200 到 300 字节之间。这包括了标签(tag)、类型(type)、父节点引用、子节点引用、兄弟节点引用、副作用列表指针等等。这些都是为了维护 React 的渲染树结构必须付出的代价。

2. memoizedState 指针的开销

FiberNode 有一个 memoizedState 字段。在 C++ 或 Go 等强类型语言中,这通常是一个指针变量。

  • 大小:在 64 位架构下,指针占 8 字节
  • 作用:它指向了 Hooks 链表的头节点

3. Hook 对象的开销

这是 useMemo 的直接宿主。hook 对象不仅仅包含 memoizedState(存储结果),它还包含 next(链表指针)、queue(更新队列)、baseState 等等。

以 React 源码中的 DispatcherFiberNode 为例,一个 Hook 对象的结构大致如下:

struct Hook {
  // 指向当前缓存的值(useMemo的结果,useState的值)
  memoizedState: any;  // 8 bytes (指针)

  // 指向下一个 Hook
  next: Hook;          // 8 bytes (指针)

  // 等等... 还有很多字段
};

如果不算对象头的开销(在 JS 引擎中,对象头通常占 16 字节左右),仅仅考虑上述核心指针字段,一个 Hook 对象就占据了 32 字节 左右。

4. useMemo 结果的存储

这是最容易被忽视,但也最危险的部分。

const heavyData = useMemo(() => {
  return new Array(10000).fill({ id: 1, name: "Test" });
}, []);

heavyData 被存入 hook.memoizedState 时,它只是一个 8 字节的指针。真正的数据(那个巨大的数组)在堆内存的另一个角落。

但是! 这并不意味着 Fiber 节点没有代价。代价在于:

  1. Hook 对象的 32 字节:无论你缓存什么,只要用了 useMemo,Fiber 节点就必须额外维护这个 Hook 对象。
  2. GC 压力:如果组件卸载,这个 Hook 对象会被回收。但如果 heavyData 被全局变量或者其他强引用持有,那么即使组件卸载,Fiber 节点被销毁,heavyData 依然霸占着内存。

总结一下存储代价:

组件 FiberNode memoizedState 指针 Hook 对象 缓存结果引用 总计(估算)
无 Hooks 240 B 0 B 0 B 0 B ~240 B
useState 240 B 8 B 32 B 8 B (值指针) ~288 B
useMemo 240 B 8 B 32 B 8 B (结果指针) ~288 B

结论useMemo 本身带来的额外开销(Hook 对象)与 useState 几乎持平。它并没有比 useState 占用更多的“容器”空间


第四部分:链表的“拥挤”效应

你可能会问:“32 个字节算什么?”

好问题。但在 React 的渲染树中,每个节点都有 memoizedState,每个节点都有 Hook 链表。

假设你有 1000 个组件实例同时存在于 Fiber 树上(这在一个中等规模的 React 应用中非常常见),并且每个组件都用了 useMemo

  • 1000 个 FiberNode:~240 KB
  • 1000 个 memoizedState 指针:~8 KB
  • 1000 个 Hook 对象:~32 KB
  • 1000 个引用:~8 KB

看起来不多对吧?但是! 如果这 1000 个组件中,有 500 个组件的 useMemo 缓存了巨大的对象呢?

比如,每个 useMemo 缓存了一个 1MB 的 JSON 数据。

  • Fiber 结构开销:~48 KB
  • 数据存储开销:500 * 1MB = 500 MB

这就是 useMemo存储代价。它不仅仅是 Hook 对象的 32 字节,而是Hook 对象的 32 字节 + 被缓存数据的实际大小

代码示例:内存占用对比

// 场景 A:不使用 useMemo,每次重新计算
function ExpensiveComponentA() {
  // 每次渲染都创建新数组
  const data = new Array(1000000).fill("data");
  return <div>{data.length}</div>;
}

// 场景 B:使用 useMemo
function ExpensiveComponentB() {
  // 只创建一次,永久驻留内存
  const data = useMemo(() => new Array(1000000).fill("data"), []);
  return <div>{data.length}</div>;
}
  • 场景 A:组件卸载时,data 变量被回收,内存瞬间释放。组件重新挂载时,内存重新分配,然后释放。内存使用是一条平滑的波浪线。
  • 场景 Bdata 被锁死在 Fiber 节点的 memoizedState 中。组件卸载时,Fiber 节点被销毁,理论上 data 应该被回收。但是!如果 data 中包含了对组件实例的引用,或者被全局变量引用,那么这个巨大的数组就会成为内存泄漏的源头。

第五部分:源码深潜——Update 对象与双缓冲

为了更深入地理解,我们需要看看 useMemo 在更新时是如何工作的。在 React 的源码中,memoizedState 的更新实际上是通过 Update 对象 来实现的。

useMemo 的依赖项变化时,React 会创建一个 Update 对象,并将其推入 hook.queue

function updateMemo(nextDeps, prevDeps) {
  const hook = updateWorkInProgressHook();
  const prevState = hook.memoizedState;

  // 1. 检查依赖项是否变化
  if (prevDeps !== null && areEqual(prevDeps, nextDeps)) {
    // 依赖没变,直接返回旧的 memoizedState
    return prevState;
  }

  // 2. 依赖变了,重新计算
  const nextValue = create();

  // 3. 更新 memoizedState
  // 这里实际上是将新的结果挂载到链表上,并更新 hook.memoizedState
  hook.memoizedState = nextValue;

  return nextValue;
}

内存布局的演变:

在第一次渲染时:

Fiber.memoizedState -> Hook1 -> { memoizedState: result1 }

在依赖变化,触发更新时:
React 会创建一个新的 WorkInProgress Fiber(工作进程 Fiber)。它会复用 current Fiber 的结构,但会更新 memoizedState 指针。

  • 旧链表Hook1 -> { memoizedState: result1 }。如果组件卸载,这个链表会被回收。
  • 新链表Hook1 -> { memoizedState: result2 }

代价分析:

这里有一个微妙的点:双缓冲机制

React 在更新时,会维护两棵树:Current Tree 和 WorkInProgress Tree。这意味着,在更新的一瞬间,内存中可能同时存在两份 Hooks 链表。

  • Current Tree:持有 result1
  • WorkInProgress Tree:持有 result2

如果 result1 是一个巨大的对象,那么在更新的毫秒级时间内,你的内存占用会瞬间翻倍!这就是为什么在极端性能测试中,频繁更新 useMemo 的依赖项会导致内存峰值飙升的原因。


第六部分:垃圾回收(GC)的博弈

React 的 Fiber 节点本身是受 Fiber 树生命周期管理的。当组件卸载时,React 会调用 unmountFiberNode

function unmountFiberNode(fiber) {
  // ...
  // 清空 memoizedState
  fiber.memoizedState = null;
  // ...
}

理论上,当 memoizedState 被置为 null 后,对应的 Hook 对象以及 memoizedState 中存储的引用,都会变成垃圾,等待垃圾回收器(GC)来清理。

但是!现实往往是残酷的。

  1. 循环引用useMemo 的结果如果包含了对组件 props 或者 state 的引用,这就形成了一个闭环。JS 的 GC 算法(通常引用计数或标记清除)可能难以处理这种强引用循环,导致内存无法释放。
  2. 闭包陷阱:如果你在 useMemo 的依赖项中遗漏了一个变量,或者 useMemo 内部使用了外部的变量,那么结果对象可能会“意外地”捕获了大量的上下文数据,导致它比预期的要大得多。
  3. 第三方库:如果你缓存了一个庞大的第三方库实例(比如 D3 图表、Mapbox 地图),那么这个库内部的内存可能并不受 React Fiber 节点销毁的影响,依然在后台默默吞噬你的 RAM。

代码示例:糟糕的 useMemo 用法

function BadComponent() {
  const [state, setState] = useState("some data");

  // 问题:result 包含了 state,而 state 是变化的
  // 即使依赖项没变,这个 useMemo 的结果其实也包含了一部分“活”的内存
  const result = useMemo(() => {
    return {
      staticData: "I am static",
      capturedState: state // 这是一个引用!
    };
  }, []); // 依赖项是空的,但结果里却藏着 state 的副本引用

  return <div>{result.capturedState}</div>;
}

在这个例子中,result 对象虽然被缓存了,但它内部的 capturedState 引用时刻提醒着 GC:“嘿,别忘了那个 state 变量,它可能还在别的地方被用着。” 这增加了内存追踪的负担。


第七部分:实战策略——如何平衡计算与存储

既然我们知道了 useMemo 的存储代价包括 Fiber/Hook 结构开销被缓存数据的实际开销,我们该如何使用它呢?

1. “存什么”比“怎么存”更重要

useMemo 的内存代价大头在于被缓存的数据,而不是那个小小的 Hook 对象。

  • 好的用法:缓存计算成本高但数据量小的结果。

    // 缓存一个复杂的计算结果,但结果只是一个数字或字符串
    const expensiveValue = useMemo(() => heavyCalculation(), [deps]);

    代价:~32 字节(Hook对象) + 8 字节(指针) = 几乎可以忽略不计。

  • 坏的用法:缓存一个巨大的对象/数组。

    // 缓存一个巨大的图表数据或配置文件
    const hugeConfig = useMemo(() => loadHugeConfig(), [deps]);

    代价:32 字节(Hook对象) + 数组大小(MB级)。如果这个组件在路由切换中频繁挂载/卸载,这就是内存泄漏的温床。

2. 避免“过度缓存”

不要为了“优化”而使用 useMemo。如果你每次渲染都需要重新计算,那就让它重新计算。现代 V8 引擎的 JIT 编译器对短生命周期对象的优化非常好。

// 不要这样做
const computed = useMemo(() => {
  return 1 + 1; // 这种计算,V8 瞬间就能算完,存起来反而占内存
}, []);

3. 监控内存泄漏

如果你发现 useMemo 缓存了大量数据,并且组件卸载后内存没有下降,请检查:

  1. 是否有全局变量引用了缓存的数据?
  2. 缓存的数据中是否包含了组件的 props 或 state?
  3. 是否有事件监听器没有清理?

4. 使用 useCallback 的同理心

useCallback 本质上也是 useMemo(() => fn, [])。它的存储代价与 useMemo 完全一致。如果你缓存了一个函数,这个函数的闭包捕获了组件的大量变量,那么它也会占用内存。通常,我们缓存函数是为了传递给子组件以避免子组件重渲染。如果子组件没有使用 React.memo,那么传递一个新函数并不会减少子组件的重渲染,反而白白浪费了内存。


第八部分:源码视角的终极拷问

让我们最后回到源码,看看 React 团队是如何处理这个问题的。

ReactFiberHooks.js 中,mountMemoupdateMemo 的实现非常精简。它们没有做任何深拷贝,也没有做任何序列化。它们只是简单地存储了引用

这意味着,useMemo 是零拷贝的。它没有在内存中复制数据的副本,它只是把数据的地址贴在了 Fiber 节点的墙上。

这既是它的优势(性能高),也是它的风险(内存占用高)。

React 团队深知这一点,所以 useMemo 的默认行为是保守的。它只在依赖项变化时才更新。如果你没有提供依赖项数组([]),它会跳过依赖检查,每次渲染都更新。这实际上是在强制你进行内存分配。

// 这种写法,每次渲染都会重新分配内存
useMemo(() => expensiveCalculation());

Fiber 节点内存布局的最终图景:

想象一下,你的 React 应用就像一座巨大的图书馆。

  • FiberNode 是图书馆的书架
  • memoizedState 是书架上的挂钩
  • Hook 对象 是挂钩旁边的标签纸(记录着类型、队列等元数据)。
  • useMemo 的结果 是挂在标签纸上的

当你把一本书(大对象)挂在书架上时,书架(FiberNode)并没有变宽,标签纸(Hook对象)也没有变厚,但是书(数据)占用的空间变大了

如果书架被撤走(组件卸载),书架被清空,书应该被放回仓库。但如果书被粘住了,或者被别的书架引用了,图书馆的容量就会爆满。


结语:做内存的明智管家

通过这次源码分析,我们揭开了 useMemo 的神秘面纱。我们看到的不是一个魔法咒语,而是一个精确的内存分配过程。

存储代价 = Fiber/Hook 结构开销(固定,约 48 字节) + 被缓存数据的实际开销(变量)。

作为开发者,我们的任务不是完全避免使用 useMemo,而是要像精明的管家一样,了解每一分钱的去向。不要为了那微不足道的 48 字节 Hook 开销而纠结,但要警惕那些动辄几 MB 的缓存数据。

记住,计算是为了速度,存储是为了持久。只有当计算速度慢到影响用户体验,或者数据大小大到影响内存健康时,才是我们打开 useMemo 这把“锁”的正确时机。

好了,今天的讲座就到这里。现在,去检查你的代码,看看有没有哪个 useMemo 正在偷偷霸占你宝贵的内存吧!

发表回复

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