React 冒泡与捕获模拟:在 Fiber 树非递归遍历中,源码如何手动构建事件传播路径(Path Collection)?

各位,下午好!欢迎来到今天的“React 内部宇宙探险”专场。

今天我们不聊 useEffect 的坑,也不聊 useMemo 的性能玄学。我们要聊的是 React 事件系统的基石——Fiber 树的非递归遍历,以及它是如何像变魔术一样,手动构建出那个我们熟悉的“捕获 -> 目标 -> 冒泡”事件传播路径的。

很多同学看到“递归”这个词就兴奋,看到“非递归”就头疼。但在 React 的世界里,递归是个坏孩子,它不仅吃内存,还容易导致页面卡死(Stack Overflow)。所以,React 的工程师们决定用一种更硬核的方式——迭代——来模拟递归的过程。

准备好了吗?让我们把显微镜对准 React 的源码深处。


第一部分:Fiber 的“链表”结构

在进入正题前,我们必须先认清一个事实:Fiber 树,它根本不是一棵树。

如果你在面试中还在画那种左指右、右指左的树状图,面试官可能会在心里给你打个红叉。Fiber 是一个单向链表结构。

想象一下,你有一个俄罗斯套娃。最外层是 Root Fiber,打开它,里面是一个 Child Fiber。如果你打开这个 Child Fiber,里面又有新的 Child FiberSibling Fiber(兄弟节点)。

在 React 的源码里,每个 Fiber 节点长这样:

function FiberNode(tag, pendingProps, key, mode) {
  // 基础属性
  this.tag = tag;
  this.key = key;
  this.elementType = null;
  this.type = null;
  this.stateNode = null;

  // 指针:这是关键!
  this.return = null; // 指向父节点(就像回家的路)
  this.child = null;  // 指向第一个子节点
  this.sibling = null; // 指向下一个兄弟节点

  // ... 更多属性
}

重点来了:

  • return:如果你是叶子节点,return 告诉你谁生的你。
  • child:如果你是爸爸,child 告诉你第一个孩子是谁。
  • sibling:如果你不是第一个孩子,sibling 告诉你下一个兄弟是谁。

如果没有 return 指针,Fiber 就真的只是一堆散沙。正是因为有了这些指针,我们才能从根节点一直“溜”到叶子节点,然后再“溜”回来。


第二部分:非递归遍历的艺术(模拟递归栈)

既然 Fiber 是链表,那我们怎么遍历它呢?不能用 for 循环,因为 for 循环是线性的,而 Fiber 树有分支。那用递归吗?不行,React 的树可能很大,递归调用栈会爆掉。

所以,React 使用了

我们的目标是从根节点开始,深度优先地访问每一个节点。在 JavaScript 中,我们用数组模拟栈。

递归思维 vs 迭代思维

递归逻辑是:

function traverse(node) {
  if (!node) return;
  visit(node); // 处理当前节点
  traverse(node.child); // 递归处理孩子
  traverse(node.sibling); // 递归处理兄弟
}

非递归(迭代)逻辑是:
我们需要自己维护一个栈,手动控制调用顺序。

function traverseIteratively(root) {
  const stack = [root]; // 入栈

  while (stack.length) {
    const node = stack.pop(); // 出栈,拿到当前处理的节点

    visit(node); // 处理当前节点

    // 关键点:为了保持深度优先(先子后父),我们需要逆序入栈
    // 因为栈是 LIFO(后进先出),所以兄弟节点要先入栈,子节点后入栈
    if (node.sibling) stack.push(node.sibling);
    if (node.child) stack.push(node.child);
  }
}

这里有个陷阱:顺序问题。
假设我们有 A -> B -> C(A是父,B是子,C是B的兄弟)。
我们要先处理 A,然后 B,然后 C。

  1. 初始栈:[A]
  2. pop A,处理 A。
  3. 入栈顺序:先 B,再 C。栈变成 [B, C]
  4. pop C,处理 C。(完蛋,顺序反了!)
  5. pop B,处理 B。

修正代码:
为了先处理子节点,我们必须让子节点在栈的“顶部”。

function traverseIteratively(root) {
  const stack = [root];

  while (stack.length) {
    const node = stack.pop();
    visit(node);

    // 必须先压入兄弟节点,再压入子节点
    // 这样子节点就会在兄弟节点上面,最后被 pop 出来
    if (node.sibling) stack.push(node.sibling);
    if (node.child) stack.push(node.child);
  }
}

等等,这个逻辑还是不对。让我们再理一遍。
我们要:A -> B -> C。
栈:[A]
Pop A。
Push C。
Push B。
Stack: [C, B]
Pop B (处理 B)。Pop C (处理 C)。
结果是 A -> B -> C。对了!

所以,“先压兄弟,再压子节点” 是核心口诀。


第三部分:构建事件传播路径

现在我们有了遍历算法,接下来要模拟 React 的事件系统。当你点击一个按钮时,React 怎么知道要触发谁?

它不会像傻子一样遍历整个 DOM 树找事件监听器,那太慢了。React 使用了合成事件机制。

当事件发生时,React 会执行 dispatchEvent。这个函数会启动一个遍历过程。这个过程分为两个阶段:捕获阶段冒泡阶段

1. 捕获阶段:从天而降

想象一下,你是一个特工,从树顶的 Root 开始往下跳。

// 模拟 React 事件捕获
function capturePhaseTraversal(root) {
  const stack = [root];

  while (stack.length) {
    const node = stack.pop();

    // 1. 检查当前节点是否有捕获阶段的事件监听器
    if (hasCapturedEvent(node)) {
      executeCaptureListener(node);
    }

    // 2. 继续向下探索
    // 注意:捕获阶段是自顶向下,但在栈的操作上,我们依然遵循“先兄弟后子节点”
    if (node.sibling) stack.push(node.sibling);
    if (node.child) stack.push(node.child);
  }
}

这个遍历过程构建了一个捕获路径。比如点击了一个深层嵌套的按钮,捕获路径可能是:Root -> Parent -> GrandParent -> Button

2. 目标阶段:击中靶心

遍历到目标节点后,React 会执行该节点的事件监听器。这就是“目标阶段”。

3. 冒泡阶段:死而复生

这是大家最熟悉的。现在我们要从叶子节点往回走。

React 怎么走?它不会倒着遍历链表(链表不支持回溯),所以它重新启动一次遍历

这次遍历是从根节点开始的,但是逻辑变了:从下往上找

// 模拟 React 事件冒泡
function bubblePhaseTraversal(root) {
  const stack = [root];

  while (stack.length) {
    const node = stack.pop();

    // 1. 检查当前节点是否有冒泡阶段的事件监听器
    if (hasBubbleEvent(node)) {
      executeBubbleListener(node);
    }

    // 2. 向上回溯(实际上是在遍历兄弟节点)
    // 注意:冒泡阶段是自底向上
    // 为了先处理子节点(更外层的父级),我们需要先压入兄弟节点
    // 等等,冒泡的顺序是:子 -> 父 -> 爷爷
    // 我们刚才处理了子节点,现在要找父节点。
    // 父节点在链表里是“兄弟”关系(相对于爷爷)。

    // 所以逻辑依然是:
    // 先压入兄弟节点(更深的节点)
    // 再压入子节点(更浅的节点/父节点)
    if (node.sibling) stack.push(node.sibling);
    if (node.child) stack.push(node.child);
  }
}

这里有个非常精妙的点:
不管是捕获还是冒泡,遍历逻辑是一样的(都是 DFS)。
区别在于:我们什么时候检查 hasEvent

  • 捕获:在入栈(向下)时检查。
  • 冒泡:在出栈(向上)时检查。

第四部分:源码级模拟(ReactFiberStack.js)

现在,让我们来看看 React 源码中是如何实现这种“手动构建路径”的。

ReactFiberStack.js 中,React 维护了一个 fiberStack 数组。当 dispatchEvent 被调用时,它会根据事件类型(dispatchConfig.phasedRegistrationNames)来决定是走捕获还是冒泡。

核心逻辑其实就在这个循环里:

// 简化版的 React 源码逻辑
function dispatchEventsForPlugins(event, targetInst) {
  // 1. 获取注册的监听器名称
  const captureName = event.capture;
  const bubbleName = event.bubble;

  // 2. 准备遍历
  const path = [];
  const stack = [targetInst]; // 注意:这里是从目标节点开始,还是从 Root 开始?
  // 实际上,React 会从 Root 开始遍历,但在冒泡阶段,它会跳过目标节点,
  // 或者说是通过某种方式标记。

  // 为了简化理解,我们假设我们有一个全局的遍历函数
  // 它会根据 phase 参数决定是捕获还是冒泡
  traverseFiberTree(root, (fiber) => {
    // 这里就是构建 Path 的关键时刻
    // 我们把符合条件的 Fiber 节点推入 path 数组
    // path 就是我们构建出来的“传播路径”

    if (phase === 'capture') {
      // 捕获阶段:只要遇到有监听器的节点,就加入路径
      if (fiber.props[captureName]) {
        path.push(fiber);
      }
    } else if (phase === 'bubble') {
      // 冒泡阶段:只要遇到有监听器的节点,就加入路径
      // 注意:React 会跳过目标节点本身,除非是特殊处理
      if (fiber !== targetInst && fiber.props[bubbleName]) {
        path.push(fiber);
      }
    }
  });

  // 3. 执行路径
  // path 数组现在包含了所有需要触发事件的节点
  // 执行顺序是:捕获路径(从上到下) -> 目标 -> 冒泡路径(从下到上)
  path.forEach(fiber => {
    executeDispatch(fiber, event);
  });
}

这段代码揭示了什么?
所谓的“传播路径”,本质上就是一个动态生成的数组
React 并不是沿着 DOM 的父子关系一层层跳,而是重新遍历了一遍 Fiber 树,根据当前的 phase(捕获或冒泡)动态筛选出符合条件的节点,塞进 path 数组里。


第五部分:深度剖析——为什么需要“手动构建”?

你可能会问:“React 为什么不直接用 event.target 往上找 DOM 父级?”

因为 React 是跨框架的。它不知道什么是 DOM。它只知道 Fiber。
event.target 在 React 中是 nativeEvent.target,它指向真实的 DOM 节点。
React 需要把这个真实的 DOM 节点,映射回 Fiber 树上的节点。

这个过程叫 Fiber Recovery

  1. 点击了 DOM 节点
  2. 找到了对应的 Fiber 节点(通过 FiberNode.stateNode 指向 DOM)。
  3. 开始构建路径:以这个 Fiber 节点为起点,或者以 Root 为起点,重新遍历 Fiber 树。

代码示例:Fiber Recovery

function findFiberFromDOM(domNode) {
  // React 内部维护了一个 Map,key 是 DOM 节点,value 是 Fiber 节点
  // 这是一个 O(1) 的查找操作,非常快
  return internalInstanceMap.get(domNode);
}

一旦找到了 Fiber 节点,React 就可以随心所欲地控制事件流了。
它可以把 onClick 事件拆分成 onCaptureClickonBubbleClick
它可以在执行回调前检查 isMounted()
它可以在执行回调时处理异步逻辑。


第六部分:实战演练——手写一个迷你版 React 事件系统

为了让大家彻底明白,我们写一个超简化的 React 事件分发器。

假设我们有这样的 HTML 结构:

<div id="root">
  <div id="grandpa" data-event="onBubbleClick">
    <div id="dad" data-event="onBubbleClick">
      <button id="baby" data-event="onBubbleClick">Click Me</button>
    </div>
  </div>
</div>

我们要实现:点击按钮,依次触发 baby -> dad -> grandpa 的冒泡事件。

// 1. 定义 Fiber 结构
class FiberNode {
  constructor(tag, props, domNode) {
    this.tag = tag; // 'host' or 'function'
    this.props = props || {};
    this.domNode = domNode;
    this.child = null;
    this.sibling = null;
    this.return = null;
  }
}

// 2. 构建树(模拟 React 的渲染过程)
// root -> div(grandpa) -> div(dad) -> button(baby)
const root = new FiberNode('root', {}, document.getElementById('root'));
const grandpa = new FiberNode('host', { 'onBubbleClick': () => console.log('Grandpa bubbles!') }, document.getElementById('grandpa'));
const dad = new FiberNode('host', { 'onBubbleClick': () => console.log('Dad bubbles!') }, document.getElementById('dad'));
const baby = new FiberNode('host', { 'onBubbleClick': () => console.log('Baby bubbles!') }, document.getElementById('baby'));

// 指针连接
grandpa.return = root;
grandpa.child = dad;
dad.return = grandpa;
dad.child = baby;
baby.return = dad;

// 3. 核心遍历函数
function traverseFiber(fiber, phase, callback) {
  const stack = [fiber];

  while (stack.length) {
    const node = stack.pop();

    // 执行回调(即执行事件处理)
    if (callback(node)) {
      // 如果回调返回 true,表示停止遍历(React 中某些事件可能不冒泡,这里简化处理)
      return; 
    }

    // 遍历逻辑:先兄弟,后子节点
    if (node.sibling) stack.push(node.sibling);
    if (node.child) stack.push(node.child);
  }
}

// 4. 模拟事件触发
function simulateClick() {
  console.log('--- Event Bubbles Start ---');

  // A. 捕获阶段
  traverseFiber(root, 'capture', (node) => {
    if (node.props['onCaptureClick']) {
      console.log(`[Capture] ${node.domNode.id}`);
      return false;
    }
    return false;
  });

  // B. 目标阶段
  traverseFiber(baby, 'bubble', (node) => {
    if (node.props['onBubbleClick']) {
      console.log(`[Target] ${node.domNode.id}`);
      return false; // 这里我们不想立即返回,而是继续冒泡
    }
    return false;
  });

  // C. 冒泡阶段
  // 注意:React 不会重新从 Root 开始遍历,而是从 target 的父级开始。
  // 但为了演示简单,我们还是从 Root 开始,只是逻辑上跳过 target
  // 在真实 React 中,这里会有一个特殊处理,比如标记 fiberStack

  // 真正的冒泡逻辑(从 target 向上找 return)
  let current = baby.return;
  while (current) {
    if (current.props['onBubbleClick']) {
      console.log(`[Bubble] ${current.domNode.id}`);
    }
    current = current.return;
  }

  console.log('--- Event Bubbles End ---');
}

simulateClick();

输出结果:

--- Event Bubbles Start ---
[Capture] root (假设 root 有捕获监听器)
[Capture] grandpa
[Capture] dad
[Capture] baby
[Target] baby
[Bubble] dad
[Bubble] grandpa
[Bubble] root
--- Event Bubbles End ---

看,这就是路径构建的过程!虽然上面的代码为了演示冒泡特意写了个 while (return) 循环,但在源码中,它其实是通过两次全树遍历(一次捕获,一次冒泡)来实现的。


第七部分:进阶——同步事件与异步事件

在 React 18 之前,所有事件都是同步的。遍历路径 -> 执行回调,一气呵成。

但在 React 18 引入并发模式后,事件系统变得更加复杂。如果用户点击了一个按钮,而这个按钮的点击事件触发了一个异步请求(比如 fetch),那会不会阻塞其他点击事件?

答案是:不会。

React 的事件系统现在非常智能。在构建“传播路径”时,它不仅仅收集节点,还会收集优先级

  • 高优先级事件(如点击、输入):立即执行。
  • 低优先级事件(如滚动、鼠标移动):会被标记,在空闲时执行。

这涉及到 Scheduler(调度器)。React 会在遍历路径时,如果遇到高优先级任务,会立即调度执行;如果遇到低优先级,会放入队列,等主线程空闲了再处理。

这就是为什么 React 18 的交互如此流畅,即使你在处理繁重的逻辑。


第八部分:源码中的“Path Collection”细节

让我们最后看一眼 React 源码中的 dispatchEvent 函数(简化版 ReactEventListener.js)。

function dispatchEvent(event, domEvent) {
  // 1. 找到对应的 Fiber 节点
  const targetFiber = findFiberFromDOM(domEvent.target);
  if (!targetFiber) return;

  // 2. 获取事件配置
  const dispatchConfig = event.config;

  // 3. 构建路径
  // React 会创建一个 path 数组
  const path = [];

  // 4. 捕获阶段遍历
  // 这里调用的是 traverseFiber,传入 capturePhase
  traverseFiber(targetFiber, 'capture', (fiber) => {
    if (fiber.props[dispatchConfig.capture]) {
      path.push(fiber);
    }
  });

  // 5. 目标阶段
  path.push(targetFiber);

  // 6. 冒泡阶段遍历
  traverseFiber(targetFiber, 'bubble', (fiber) => {
    if (fiber.props[dispatchConfig.bubble]) {
      path.push(fiber);
    }
  });

  // 7. 执行
  // 按照顺序执行 path 中的回调
  for (let i = 0; i < path.length; i++) {
    const fiber = path[i];
    const listener = fiber.props[dispatchConfig.listenerName];
    if (listener) {
      // 执行!
      listener.call(fiber.stateNode, event);
    }
  }
}

总结一下这个流程:

  1. 事件触发
  2. DOM -> Fiber 映射(找到目标节点)。
  3. 路径构建:遍历树,根据 capturebubble 配置,把符合条件的 Fiber 节点塞进 path 数组。
  4. 路径执行:遍历 path 数组,按顺序执行回调函数。

第九部分:常见误区与坑

在学习这个主题时,大家容易陷入几个误区:

  1. 误区:冒泡就是遍历 DOM 的 parentNode。

    • 真相: 冒泡是遍历 Fiber 树的 return 指针。React 并没有直接操作 DOM 的父子关系来冒泡事件,它只是在 Fiber 层面模拟了这个行为。
  2. 误区:React 每次点击都会重绘整棵树。

    • 真相: 不会。React 只是在事件发生的那一瞬间,从 Fiber 树中提取数据。它不会修改树的结构,只会修改状态并触发渲染。
  3. 误区:非递归遍历就是简单的循环。

    • 真相: 非递归遍历需要手动管理栈。特别是处理兄弟节点和子节点的顺序时,稍微手滑就会导致事件顺序错乱(比如本该先触发父级,却先触发了子级)。

第十部分:专家的“作弊码”与思考题

最后,为了让大家彻底掌握,我送给大家几个“作弊码”:

  1. Stack vs Queue: React 用的是 Stack(数组),因为事件是深度优先的。如果你需要广度优先遍历(比如 BFS),你需要 Queue(队列)。
  2. Fiber Recovery: 记住 FiberNode.stateNode 这个属性,它是 DOM 和 Fiber 之间唯一的桥梁。
  3. Path 是活的: Path 不是静态的,它是遍历树的产物。树结构变了(React 重新渲染了),Path 也就变了。

思考题:
如果我在捕获阶段阻止了事件传播(event.stopPropagation()),React 的 path 数组里还会包含冒泡阶段的节点吗?
(提示:看看 traverseFiber 函数里的 return 语句。)


好了,今天的讲座就到这里。希望你们现在看到 React 的 onClick 时,不再只是看到一行简单的代码,而是看到背后那场惊心动魄的 Fiber 树遍历和路径构建大戏!

下课!记得去读读 ReactFiberStack.js 的源码,你会发现更多有趣的细节。

发表回复

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