React 事件冒泡模拟:源码解析如何通过收集 Fiber 树路径手动触发两阶段(捕获/冒泡)遍历

深入剖析:React 事件系统的“特工行动”——如何手动收集路径与两阶段遍历

各位同学,大家好!

今天我们要聊点刺激的。在 React 的世界里,事件处理往往像是一种“魔法”。你点击一个按钮,屏幕上什么都没发生(或者发生了你想看的事),但底层的 DOM 事件却像是有生命的藤蔓一样,顺着树的层级疯狂蔓延。

这就是我们要讲的主题:React 事件冒泡模拟。更具体地说,我们要看看 React 的源码是如何像特工一样,在 DOM 事件触发的那一刻,通过收集 Fiber 树的路径,手动指挥一场“两阶段遍历”(捕获阶段 + 冒泡阶段)的。

别被“源码解析”这几个字吓到了,我们今天不讲枯燥的架构图,我们像剥洋葱一样,一层一层把 React 事件系统的内核剥开,看看它到底是怎么“搞事情”的。


第一章:DOM 事件与 React 世界的隔阂

首先,咱们得明白,浏览器的事件系统是原生的。当你点击一个 div 时,浏览器会立刻感知到,并且会在 DOM 树上像接力赛一样传递这个点击信号。

React 作为一个库,它不想直接去管浏览器那一套。它有自己的“地盘”,那就是 Fiber 树。

Fiber 树 是 React 的内部数据结构,它是 React 对 UI 状态的抽象。而 DOM 树 是浏览器真正渲染出来的东西。这两者并不总是一一对应的(比如条件渲染、Suspense 等情况),但大多数时候,它们是同构的。

当你在 React 里写 <button onClick={handleClick}> 时,React 并没有在每一个按钮上单独绑定一个 addEventListener。那样太蠢了,性能太差。相反,React 只在根节点(Root Fiber)上绑定了一个监听器。

这就好比:你不是在每一扇门上都装了门铃,你只是在房子的大门口装了一个总控台。有人按了哪扇门的铃,总控台听到了,然后它要去查那个门是谁家的。

这就是 事件委托 的核心思想。


第二章:从 DOM 目标到 Fiber 节点的“寻人启事”

当你在页面上点击了某个具体的 DOM 节点(比如一个 button),浏览器会告诉你:嘿,有个点击事件发生在 event.target 上。

React 接到这个信号后,它首先得知道:这个 event.target 对应的是我 Fiber 树里的哪个节点?

这时候,React 就要开始它的“寻人启事”工作了。它不能瞎猜,它得顺着 DOM 树往上爬。

源码里有一个非常关键的函数,大概长这样(为了方便理解,我做了大量简化):

function getClosestFiberFromDOM(target) {
  // 1. 找到这个 DOM 节点对应的 Fiber 节点
  // 这通常是通过 document.getElementById 或者 React 内部的映射表来做的
  let fiberNode = findFiberByDOM(target);

  // 2. 如果找到了,我们就有了目标节点。
  // 但是,我们还需要路径!我们需要知道从根节点到目标节点的所有中间人。
  // 因为事件处理函数可能挂在父级上,也可能挂在子级上。

  const path = [];
  let current = fiberNode;

  // 3. 递归向上遍历 Fiber 树
  // 注意:Fiber 节点是通过 return 指针连接父节点的
  while (current) {
    // 把当前节点加入路径
    path.push(current);

    // 去找它的父节点
    current = current.return;
  }

  return path;
}

这里的 path 就是我们的“特工小队名单”。

你想想,如果这个路径是 [Root, ParentA, ParentB, Button],这意味着什么?这意味着:

  1. Root 可能有一个 onClick 处理函数。
  2. ParentA 可能有一个 onClick 处理函数。
  3. ParentB 可能有一个 onClick 处理函数。
  4. Button 本身可能有一个 onClick 处理函数。

React 收集完这个路径之后,它的工作就完成了一大半。接下来,就是我们要重点讲的——如何通过这个路径手动触发两阶段遍历


第三章:两阶段遍历——捕获与冒泡的“双面间谍”

React 事件系统最迷人的地方就在于它的两阶段

第一阶段:捕获阶段

想象一下,一个巨大的漏斗。事件信号从 DOM 树的根节点开始,像水一样倒灌下来。它流过每一个节点。在这个阶段,如果你在某个父组件里写了 e.stopPropagation(),那就像是你在漏斗口放了个塞子,下面的水(事件)就再也流不下去了。

第二阶段:冒泡阶段

当信号流到了最底层的“目标节点”后,它不会停在那儿。它会像气泡一样,从目标节点开始,向上冒。它经过父组件,再经过祖父组件,直到回到根节点。

为什么要分两阶段?
这给了 React 的 SyntheticEvent(合成事件)极大的灵活性。它可以在事件到达目标之前就拦截它,或者可以在事件离开目标之后再做最后的处理。


第四章:手动触发模拟——源码级的“特洛伊木马”

好了,理论讲完了。现在,让我们来模拟一下 React 核心源码里的 dispatchEvent 函数是如何运作的。

假设我们已经拿到了那个 path 数组:[RootFiber, ParentFiber, ChildFiber]

React 源码里会创建一个事件对象(SyntheticEvent),然后开始遍历。

第一步:执行捕获阶段

代码逻辑大概是这样的:

function dispatchEvent(event) {
  // 1. 获取事件类型,比如 'click'
  const eventType = event.type;

  // 2. 构造合成事件对象
  const syntheticEvent = new SyntheticEvent(event);

  // 3. 获取路径(假设这是从 getClosestFiberFromDOM 拿到的)
  const path = [rootFiber, parentFiber, childFiber];

  // --- 捕获阶段 ---
  // 从路径的最后一个元素开始(也就是最底层的子节点),往前遍历
  // 这就是“捕获”:从下往上遍历路径数组
  for (let i = path.length - 1; i >= 0; i--) {
    const fiber = path[i];

    // 检查这个 Fiber 节点是否在这个事件类型上有监听器
    // 比如 fiber.props.onClick
    if (fiber.props && fiber.props[eventType]) {
      // 哎哟,有人在这儿设了卡!
      // 调用这个回调函数
      // 注意:这里传递的是合成事件对象
      fiber.props[eventType](syntheticEvent);

      // 关键点来了!如果用户调用了 stopPropagation()
      if (syntheticEvent.propagationStopped) {
        console.log("捕获阶段被拦截!冒泡阶段不会执行了!");
        return; // 退出函数,后续代码不执行
      }
    }
  }

  // --- 冒泡阶段 ---
  // 信号到达了目标节点,现在开始“冒泡”
  // 从路径的起始元素开始(也就是根节点),往后遍历
  // 这就是“冒泡”:从上往下遍历路径数组
  for (let i = 0; i < path.length; i++) {
    const fiber = path[i];

    if (fiber.props && fiber.props[eventType]) {
      // 再次调用回调
      fiber.props[eventType](syntheticEvent);

      // 如果用户又调用了 stopPropagation()
      if (syntheticEvent.propagationStopped) {
        console.log("冒泡阶段被拦截!");
        return;
      }
    }
  }
}

听懂了吗?这就是 React 事件冒泡的精髓!

它并没有利用浏览器的默认冒泡机制(虽然底层还是依赖浏览器),而是手动构建了路径数组,然后利用 for 循环,硬生生地把“捕获”和“冒泡”给模拟出来了。

这种设计有什么好处?

  1. 跨平台一致性:无论浏览器怎么实现事件冒泡,React 都有一套统一的执行顺序。
  2. 精确控制:React 可以在冒泡的任何一刻改变事件对象的状态(比如 stopPropagation),而不受限于浏览器原生的实现细节。

第五章:深入 Fiber 树路径收集的细节

让我们把镜头拉远一点,看看那个 while (current = current.return) 循环。

这是 React 架构中最基础的一环。每个 Fiber 节点都有一个 return 属性,指向它的父节点。

// 这是一个简化的 Fiber 节点结构
const fiberNode = {
  type: 'button',
  stateNode: domNode, // 指向真实的 DOM 节点
  return: null,       // 父节点
  props: {
    onClick: handleClick
  }
};

当事件发生时,React 需要把 fiberNode.stateNode(真实的 DOM)和 fiberNode(Fiber 节点)对应起来。React 在渲染的时候,会把这个关系维护好。

然后,收集路径的过程就是:

const path = [];
let node = fiberNode; // 从目标 Fiber 开始

while (node) {
  path.push(node);
  node = node.return; // 往回找爸爸
}

如果 path 构建成功,我们就得到了一个有序的列表。这个列表就像是通往事件处理函数的“高速公路网”。


第六章:代码实战——构建一个简易的 React 事件系统

为了彻底搞懂,我们写一个完全模拟 React 事件冒泡的玩具版本。这不需要任何外部依赖,纯原生 JS。

场景设定

  • 页面上有三个层级:GrandParent -> Parent -> Child
  • 每个层级都有一个点击处理函数,打印自己的名字。
  • Parent 的处理函数里会调用 stopPropagation()

代码实现

// 1. 定义 Fiber 节点结构
class FiberNode {
  constructor(type, props, returnNode) {
    this.type = type;
    this.props = props;
    this.return = returnNode;
    this.stateNode = null; // 稍后绑定真实 DOM
  }
}

// 2. 构建 Fiber 树
// 我们手动构建一个简单的树:GrandParent -> Parent -> Child
const childFiber = new FiberNode('button', { onClick: handleChild }, null);
const parentFiber = new FiberNode('div', { onClick: handleParent }, childFiber);
const grandParentFiber = new FiberNode('body', { onClick: handleGrandParent }, parentFiber);

// 绑定真实 DOM (模拟 React 的挂载)
childFiber.stateNode = document.createElement('button');
parentFiber.stateNode = document.createElement('div');
grandParentFiber.stateNode = document.body;

// 3. 定义处理函数
function handleChild(e) {
  console.log("🟢 Child: 我被点击了!");
  // 模拟 React 的合成事件
  e.stopPropagation = () => {
    e.propagationStopped = true;
  };
}

function handleParent(e) {
  console.log("🔵 Parent: 我收到了点击信号!");
  // 父组件阻止冒泡
  e.stopPropagation(); 
}

function handleGrandParent(e) {
  console.log("🔴 GrandParent: 只有我能听到这个声音!");
}

// 4. 核心模拟:dispatchEvent
function dispatchEvent(fiberNode) {
  // 创建合成事件对象
  const syntheticEvent = {
    type: 'click',
    target: fiberNode.stateNode,
    propagationStopped: false,
    stopPropagation: function() {
      this.propagationStopped = true;
    }
  };

  console.log("=== 事件触发开始 ===");

  // --- 阶段一:捕获 ---
  console.log("【捕获阶段】从根节点往下找...");
  let currentNode = fiberNode;
  const path = []; // 收集路径

  // 递归向上收集路径
  while (currentNode) {
    path.unshift(currentNode); // 加到数组前面,保持路径顺序
    currentNode = currentNode.return;
  }

  // 遍历路径执行捕获
  for (let i = path.length - 1; i >= 0; i--) {
    const node = path[i];
    console.log(`检查节点: ${node.type}`);
    if (node.props && node.props.onClick) {
      node.props.onClick(syntheticEvent);
      if (syntheticEvent.propagationStopped) {
        console.log("⚠️ 捕获阶段被拦截,停止遍历!");
        return;
      }
    }
  }

  // --- 阶段二:冒泡 ---
  console.log("n【冒泡阶段】从目标节点往上冒...");
  for (let i = 0; i < path.length; i++) {
    const node = path[i];
    console.log(`检查节点: ${node.type}`);
    if (node.props && node.props.onClick) {
      node.props.onClick(syntheticEvent);
      if (syntheticEvent.propagationStopped) {
        console.log("⚠️ 冒泡阶段被拦截!");
        return;
      }
    }
  }

  console.log("=== 事件触发结束 ===n");
}

// 5. 执行模拟
dispatchEvent(childFiber);

运行结果解读

当你运行这段代码时,控制台会输出:

=== 事件触发开始 ===
【捕获阶段】从根节点往下找...
检查节点: body
检查节点: div
🔵 Parent: 我收到了点击信号!
⚠️ 捕获阶段被拦截,停止遍历!

【冒泡阶段】从目标节点往上冒...
// 注意:这里什么都不会发生,因为上面 return 了

再试一次,去掉 Parent 的 stopPropagation

=== 事件触发开始 ===
【捕获阶段】从根节点往下找...
检查节点: body
检查节点: div
🔵 Parent: 我收到了点击信号!
检查节点: button
🟢 Child: 我被点击了!

【冒泡阶段】从目标节点往上冒...
检查节点: button
🟢 Child: 我被点击了!
检查节点: div
🔵 Parent: 我收到了点击信号!
检查节点: body
🔴 GrandParent: 只有我能听到这个声音!
=== 事件触发结束 ===

看到了吗?这就是 React 事件冒泡的完整生命周期!它完全由我们手动构建的 path 数组驱动,而不是完全依赖浏览器的原生行为(虽然我们在 Demo 里用了原生 DOM,但逻辑是 React 的)。


第七章:为什么 React 要这么做?(性能与哲学)

你可能会问:“React,你这么麻烦,直接用 e.stopPropagation() 不就行了,为什么还要自己搞一套两阶段遍历,还要收集路径?”

这涉及到 React 的一个核心哲学:可预测性

  1. 统一性:在 React 16 之前,React 的事件系统其实是直接使用浏览器的事件委托。但在 React 16 引入了 Fiber 之后,React 重写了事件系统。为什么?因为 Fiber 引入了时间切片和并发模式。如果事件处理函数执行时间过长,可能会导致 UI 卡顿。React 需要一个更可控的机制来处理事件,确保高优先级任务能抢占低优先级任务的时间片。
  2. 合成事件:React 提供的 SyntheticEvent 是一个跨浏览器兼容的包装器。它不是原生的 DOM 事件对象。React 通过这种两阶段遍历,可以确保在任何时候,事件对象都是最新状态的(因为 React 是异步更新 DOM 的,如果直接用原生事件,可能会拿到旧的值)。
  3. 调试友好:React 的开发者工具可以精确地告诉你事件是在哪一层被触发的,这得益于它对事件流的可控性。

第八章:源码中的“黑魔法”——Path 的构建与清理

在 React 的真实源码中,事情比我们要复杂得多。这里有几个容易让人晕头转向的点。

1. 同步更新与异步更新

在 Fiber 架构下,事件处理函数的执行是同步的。但是,当你在事件处理函数里调用 setState,这会触发 React 的调度器。调度器可能会挂起当前的渲染,去处理高优先级的更新。

这就意味着,当你点击一个按钮触发事件时,React 树的状态可能正在发生剧烈的变化(比如正在卸载组件)。

React 必须非常小心地处理 path。它不能在事件触发的那一刻就固定死这个路径,因为它可能在遍历的过程中,路径就变了。源码中有一套复杂的逻辑来处理这种“路径不稳定”的情况,通常是通过创建一个“副本”或者使用“回调”的方式来确保引用的稳定性。

2. dispatchEvent 的内部流程

在 React 源码中,dispatchEvent 函数并不是一个简单的 for 循环。它包含了大量的安全检查:

  • 检查 Fiber 节点是否还存在(防止组件已经卸载)。
  • 检查 stateNode 是否有效(防止 Fiber 节点没有对应的 DOM,比如某些服务端渲染的组件)。
  • 检查 props 是否为 nullundefined

3. 原生事件 vs. React 事件

React 的事件系统并不是完全脱离 DOM 的。它依然利用了浏览器的原生事件委托。React 会把 onClick 绑定在 rootFiber.stateNode(即根 div)上。当原生事件冒泡到根节点时,React 的 dispatchEvent 才会被触发。

这就像:浏览器说“嘿,有个点击!” -> React 听到后说“别急,让我查查是谁点的,然后按我的规矩办。”


第九章:总结与升华

好了,同学们,今天的“源码讲座”就到这里。

我们回顾一下今天的核心内容:

  1. 事件委托:React 只在根节点监听事件,不逐个绑定。
  2. 路径收集:通过 fiberNode.return 递归向上遍历,构建出 [Root -> ... -> Target] 的路径数组。这是两阶段遍历的基础。
  3. 两阶段遍历
    • 捕获阶段:从路径末尾向前遍历(从下往上)。
    • 冒泡阶段:从路径开头向后遍历(从上往下)。
  4. 手动模拟:通过 for 循环遍历收集到的路径数组,依次调用 onClick 处理函数,并检查 stopPropagation 状态。

React 事件系统就像是一个精密的瑞士钟表。它没有依赖大自然(浏览器)的随机性,而是通过自己构建的齿轮(Fiber 树路径)和杠杆(两阶段遍历),精确地控制着每一个事件流。

希望这篇长文能让你对 React 的底层原理有一个更深的理解。下次当你点击屏幕上的某个按钮时,记得感谢那些隐藏在代码深处的 while 循环和 path 数组,是它们把你的点击变成了 React 世界的精彩故事。

下课!

发表回复

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