React 内部 Map 与 Set 的使用权衡:探究在处理 Fiber 树节点检索时对线性查找与哈希查找的性能抉择

各位前端工程师、React 深度爱好者,以及所有对浏览器渲染机制感到好奇的朋友们,大家好!

我是你们的特邀讲师,今天咱们不聊那些花里胡哨的 Hooks,也不谈 Next.js 的配置文件,咱们要钻进 React 的核心腹地,去看看它肚子里到底长了个什么“零件”。

今天的话题有点硬核,甚至可以说有点“枯燥”,但它是理解 React 性能优化的基石。我们要探讨的是:React 内部在处理 Fiber 树节点检索时,到底是在用“链表”这种线性查找的方式硬抗,还是在用“Map/Set”这种哈希查找来偷懒?

这就好比在问:是每次找东西都把整个抽屉里的衣服翻一遍(线性),还是给每件衣服都贴个标签放在显眼的位置(哈希)?

准备好了吗?咱们这就开讲。


第一部分:Fiber 的身体构造——它是个“链表”狂魔

首先,咱们得明白 Fiber 是个啥。如果你看过 React 的源码,你会发现 FiberNode 类的构造函数长得像这样(简化版):

class FiberNode {
  constructor(tag, pendingProps, key) {
    this.tag = tag; // 类型:FunctionComponent, HostComponent 等
    this.key = key; // 唯一标识
    this.pendingProps = pendingProps;

    // 核心来了:三个指针
    this.return = null; // 父节点
    this.child = null;  // 第一个子节点
    this.sibling = null; // 下一个兄弟节点
  }
}

看到这三个指针了吗?returnchildsibling。这可不是随便画着玩的。这构成了 React 的单链表结构

为什么 React 要这么设计?因为它要模拟 DOM 树,但又不能完全照搬 DOM 树。DOM 树是嵌套的树结构,而 Fiber 树在 React 内部被“扁平化”了。

想象一下,你的 React 组件结构是这样的:

function App() {
  return (
    <div>
      <h1>Title</h1>
      <p>Content</p>
    </div>
  );
}

在 Fiber 层面,它长这样:

// Root Fiber
const root = new FiberNode(HostRoot, ...);
root.child = new FiberNode(HostComponent, { key: 'div' }, 'div');
root.child.return = root;

// div 的第一个孩子
const divNode = root.child;
divNode.child = new FiberNode(HostComponent, { key: 'h1' }, 'h1');
divNode.child.return = divNode;

// div 的第二个孩子(兄弟节点)
divNode.sibling = new FiberNode(HostComponent, { key: 'p' }, 'p');
divNode.sibling.return = divNode;

这就是链表! 这是一个完美的、单向的、父子兄弟关系一目了然的链表。


第二部分:线性查找——既然是链表,那就翻吧

既然是链表,React 怎么找东西呢?比如,我现在在 divNode 这个节点上,我想找它的第二个子节点 p,或者我想找它的父节点 root

很显然,React 会使用线性查找

在源码的 ReactFiberTreeTraversal 文件中,你会看到大量这种令人眼花缭乱的 while 循环:

// React 源码风格:查找父节点
function findParentNode(fiber) {
  let node = fiber;
  while (node.return !== null) {
    node = node.return;
  }
  return node;
}

// React 源码风格:通过 key 查找子节点
function findChildFiberByKey(parentFiber, key) {
  let node = parentFiber.child;
  while (node !== null) {
    // 如果 key 匹配,那就找到了!
    if (node.key === key) {
      return node;
    }
    // 否则,下一个兄弟节点走起!
    node = node.sibling;
  }
  return null;
}

这就是典型的线性查找

它的优点是什么?

  1. 内存极小:不需要额外的 Map 结构。每个节点只需要 3 个指针。如果 React 为每个节点都存一个 Map,内存占用会直接翻倍。
  2. 结构清晰:树遍历的逻辑天然就是链表遍历。你从根节点开始,顺着 child 往下走,顺着 sibling 往右走,这就是 DFS(深度优先遍历)。

它的缺点是什么?

  1. 速度慢:如果树很深,或者你想找的节点在兄弟列表的末尾,你就要遍历所有的兄弟节点。复杂度是 O(N)。

第三部分:Map/Set 的诱惑——哈希查找的极速快感

既然线性查找这么慢,React 为什么不直接给每个 Fiber 节点挂一个 Map 呢?

比如这样:

class FiberNode {
  // ... 其他属性 ...
  childrenMap = new Map(); // 每个节点都有一个 Map!
}

// 查找的时候
function findChildByMapKey(fiber, key) {
  return fiber.childrenMap.get(key);
}

如果是这样,查找时间就是 O(1) 了!简直是瞬间完成!

但是! 请注意这个但是。这就是我们要讲的权衡

1. 内存开销的噩梦

React 的渲染树可能非常庞大。一个复杂的应用,可能包含成千上万个 Fiber 节点。

  • 链表方式:每个节点只有 3 个指针(return, child, sibling),加上一些元数据。内存占用极低。
  • Map 方式:每个节点都要维护一个 Map 对象。Map 对象本身就有对象头开销。更糟糕的是,Map 的键是字符串(key)。每个 key 都是一个字符串对象。这意味着,为了存储一个 key="button",你不仅需要 Map 的对象头,还需要一个字符串对象。

如果你有 10,000 个节点,每个节点都有一个 Map,那内存占用会像气球一样爆炸。JavaScript 的垃圾回收器(GC)会哭晕在厕所,导致页面卡顿。

2. 破坏树的拓扑结构

React 的核心工作是协调。协调的过程就是遍历树,对比新旧树,计算差异。

如果你在遍历过程中,发现子节点是个 Map,那你不能直接遍历它,你得先 for (let [key, node] of map),然后再去遍历 node 的子节点。这会让代码逻辑变得极其复杂,原本简单的“父子关系”变成了“父子关系+哈希关系”,代码的可读性和维护性会直线下降。

3. 并发模式下的不稳定性

React 18 引入了并发模式。这意味着渲染可能会被打断、恢复、重试。
链表结构的遍历是确定性的。你遍历到哪一步,就是哪一步。而 Map/Set 虽然快,但它们是动态数据结构。在某些极端的内存碎片化场景下,Map 的哈希计算可能会引入一些微小的不可控因素(虽然在现代 V8 引擎下这种影响很小,但在极致性能追求下,线性遍历的确定性是王道)。


第四部分:实战演练——React 到底怎么选?

虽然 React 内部很少用 Map/Set 来做树的遍历(因为遍历本身就是链表的事),但 React 在某些特定场景下,会“变脸”使用 Map/Set。

场景一:Effect List 的构建(线性查找的变种)

在 React 的渲染循环中,处理 useEffectuseLayoutEffect 时,React 需要遍历树来收集副作用。

React 并没有用 Map,而是维护了一个 effectList,本质上也是一个链表。它通过 firstEffectlastEffect 指针将所有有副作用的节点串联起来。

// React 源码简化逻辑
function traverseEffects(node) {
  if (node.flags & EffectTag) {
    // 如果有副作用,把它挂到链表上
    if (lastEffect) {
      lastEffect.nextEffect = node;
    } else {
      firstEffect = node;
    }
    lastEffect = node;
  }

  let child = node.child;
  while (child) {
    traverseEffects(child);
    child = child.sibling;
  }
}

你看,这里依然是线性查找(遍历子节点),因为这是为了遍历而遍历。Map 会让这个遍历过程变得脱节。

场景二:Diff 算法中的临时 Map(哈希查找的闪现)

这是最有趣的场景。在 React 的 Diff 算法中,React 会尝试复用旧节点。为了快速判断“这个 key 在旧树里有没有出现过”,React 会构建一个临时的 Map!

看这段代码逻辑(概念性):

function reconcileChildren(current, workInProgress, nextChildren) {
  // 1. 先把 current 的所有子节点变成 Map,方便查找
  const existingChildren = new Map();
  let currentFirstChild = current.child;
  while (currentFirstChild) {
    existingChildren.set(currentFirstChild.key, currentFirstChild);
    currentFirstChild = currentFirstChild.sibling;
  }

  // 2. 遍历新的子节点
  let newFirstChild = workInProgress.child;
  while (newFirstChild) {
    const key = newFirstChild.key;

    // 3. 哈希查找!O(1)!
    const existing = existingChildren.get(key);

    if (existing) {
      // 找到了,复用!
      // ... 复用逻辑 ...
      existingChildren.delete(key); // 移除已处理的
    } else {
      // 没找到,创建新的!
      // ... 创建逻辑 ...
    }

    newFirstChild = newFirstChild.sibling;
  }

  // 4. 清理掉 Map 里剩下的(这些是旧树有但新树没的,需要卸载)
  existingChildren.forEach(cleanupNode);
}

这里 React 用了 Map!

为什么这里可以用 Map?

  1. 临时性:这个 Map 只在 Diff 算法执行的那一瞬间存在。一旦 Diff 完毕,这个 Map 就会被销毁。它不会占用长期内存。
  2. 目的明确:Diff 算法的核心任务是“匹配”。Map 是匹配的神器。
  3. 可控性:Diff 算法是同步的,不需要担心并发中断导致的 Map 状态混乱。

但注意! 即使在这里,React 也非常谨慎。它只在 Diff 阶段使用 Map,一旦确定复用关系,它就会立刻把这个 Map 抛弃。它绝不会在 Fiber 节点的实例属性里存一个永久的 Map。


第五部分:深入 FiberNode——React 的进化与妥协

到了 React 18,情况发生了一些变化。React 引入了 ReactFiberNode 的优化。

虽然 React 依然坚持用链表做遍历,但它给 FiberNode 加了一个属性:index

class FiberNode {
  // ... 指针 ...
  this.index = 0; // 在兄弟列表中的位置
}

为什么要加这个?

因为线性查找虽然慢,但在大多数情况下,React 的树结构并不算极其庞大(除非你在一个组件里渲染了 10,000 个 div)。而且,现代浏览器的 JIT(即时编译)对循环的优化非常好。

但是,index 属性是一个巧妙的平衡。
它允许 React 在某些优化路径下,利用数组下标访问来替代部分线性遍历,或者进行更高效的位运算。

但是!请记住,React 永远没有用 Map/Set 来替代 sibling 链表。
为什么?因为 sibling 链表是树的骨架。如果你用 Map 替换链表,你就把“树”变成了“图”。React 的核心算法是基于树结构的,基于图的算法会极其复杂且难以优化。


第六部分:性能剖析——时间 vs 空间

让我们来做一个思想实验。

假设你有一个组件,里面有 100 个子元素。

方案 A:链表 + 线性查找

  • 内存:每个节点 3 个指针 + 元数据。总共 300 个指针。
  • 查找某个特定子节点(假设在中间):平均遍历 50 次。耗时:~50ns(纳秒级)。

方案 B:Map + 哈希查找

  • 内存:每个节点 1 个 Map 对象 + 100 个字符串 Key 对象。总共 100 个 Map 对象 + 10,000 个字符串。
  • 查找某个特定子节点:计算 Hash -> 查找。耗时:~10ns(纳秒级)。

看起来 Map 胜了?别急,这只是单次查找。

React 的工作模式是:遍历整个树
如果是链表,遍历 100 个节点只需要 100 次指针移动。
如果是 Map,遍历 100 个节点意味着你要遍历 100 个 Map 对象,还要处理对象头开销。

结论:
在 React 这种“全树遍历”的渲染模式下,线性查找(链表)在 CPU 时间上完胜 Map。因为 Map 的内存开销会拖慢整个渲染进程的内存分配速度,导致 GC(垃圾回收)频繁触发,进而造成掉帧。


第七部分:什么时候你应该用 Map/Set?

既然 React 内部不用 Map 做树遍历,那我们写代码时什么时候该用 Map/Set 呢?

  1. 频繁查找,树很小:如果你只是在一个小组件里找数据,且数据量少于 20 个,用 Map/Set 绝对比遍历数组快。
  2. 非树形结构:如果你维护的是一个扁平的列表,或者一个配置对象,Map/Set 是神器。
  3. React 内部特定场景:比如 useRef 的缓存,或者 React.memo 的依赖项判断(虽然这里常用数组,但 Set 常用于去重),或者 Effect List 的收集(虽然 React 用链表,但逻辑类似)。

第八部分:总结与展望

回到我们的主题:React 内部 Map 与 Set 的使用权衡。

在 Fiber 树节点检索中,React 做出了一个极其明智的决定:坚持使用线性查找(链表遍历)作为核心检索机制。

这不仅仅是因为代码简单,更是因为:

  1. 树的天然属性:树结构天生就是链表(父子/兄弟)。
  2. 内存优先原则:React 是一个运行在内存受限环境(浏览器)中的库,它必须极度节省内存。
  3. 遍历成本:全树遍历是常态,链表遍历的 CPU 成本远低于 Map 的内存开销。

React 偶尔在 Diff 算法的瞬间使用 Map 来辅助查找,那是为了“作弊”获取极速的匹配能力,但事后立刻清理,绝不留恋。

所以,下次当你看到 React 源码里那令人眼花缭乱的 while (node = node.sibling) 循环时,不要觉得它“土”,不要觉得它“慢”。那是 React 在用最原始、最稳健、最省内存的方式,为你撑起整个应用的渲染性能。

线性查找,是链表的宿命,也是性能的基石。


(讲座结束,问答环节)

Q: 如果我在 React 里手动给 Fiber 节点加个 Map,会有什么后果?
A: 呃,你可以试试。只要你不在生产环境,你可以试试。你会发现你的页面可能还能跑,但内存占用会飙升,点击事件可能会丢失,因为 React 的协调逻辑假设没有 Map,它可能会在遍历树时跳过那些挂载了 Map 的节点,或者因为 Map 的引用导致节点被错误地复用。简单说,你会把 React 搞崩溃。

Q: React 19 会改用 Map 吗?
A: 我不这么认为。只要 Fiber 树还是那个 Fiber 树,链表就是王道。除非 React 放弃 Fiber 树结构,改用完全不同的渲染模型,否则这个权衡就不会改变。

好了,今天的课就上到这里。大家回去记得,写业务代码时,如果是树形结构,尽量复用 React 的遍历逻辑;如果是查找,且数据量小,Map/Set 随你用。谢谢大家!

发表回复

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