React 合成事件系统(SyntheticEvent):从浏览器原生事件到 Fiber 节点的委托映射路径

嘿,各位代码界的“泥瓦匠”和“架构师”们,大家好!

今天我们不讲怎么写一个 Hello World,也不讲怎么把你的 React 组件封装成一个漂亮的 UI 库。我们要聊点“底层”的,聊聊那些当你点击屏幕时,在你看不见的地方疯狂奔跑的幽灵——React 合成事件系统

很多人觉得 React 事件很简单:“不就是 onClick 吗?我写上去,React 就处理。” 呵呵,天真。你看到的 onClick 只是个糖衣炮弹,真正的战场在底层,在 Fiber 节点之间,在浏览器和 JavaScript 的夹缝中。

今天,我就要剥开 React 的外衣,带你看看从你手指敲击键盘的那一刻,到事件处理器被调用的这一路,到底发生了什么“血雨腥风”。


第一部分:原生事件的“狗血”历史

在 React 出现之前,我们是怎么处理事件的?

如果你是个老司机,你一定记得那个年代:IE6 还在统治世界,Chrome 还在穿开裆裤。那时候,浏览器对事件的支持简直就是“精神分裂”。

  • 标准浏览器(Firefox, Chrome):使用 addEventListener,事件名是 click,事件对象是 Event
  • IE 浏览器:使用 attachEvent,事件名是 onclick,事件对象是 window.event(这玩意儿会随着作用域飘,非常恶心)。

为了兼容这些“祖宗”,我们以前经常写这种屎山代码:

// 那个年代的经典屎山
function addEvent(elem, type, handler) {
    if (elem.addEventListener) {
        // 现代浏览器
        elem.addEventListener(type, handler, false);
    } else if (elem.attachEvent) {
        // IE 6-8
        elem.attachEvent('on' + type, handler);
    } else {
        // 老古董
        elem['on' + type] = handler;
    }
}

而且,原生事件还有一个巨大的坑:内存泄漏。如果你在一个列表里渲染了 1000 个按钮,每个按钮都绑定了原生事件监听器,那你的内存就爆了。这就是所谓的“事件监听器爆炸”。

React 的出现,就是为了解决这些问题。它统一了事件接口,把所有浏览器差异抹平了,还搞了事件委托


第二部分:事件委托的艺术

React 的事件系统最核心的思想是什么?

懒到什么程度?它根本不给你在具体的按钮上挂监听器。它就像一个超级特工,把所有监听器都挂载在 document 上。

当你点击一个按钮时,流程是这样的:

  1. 你点击了 <button id="btn-1">
  2. 浏览器捕获到了这个点击事件。
  3. React 的 dispatchEvent 接收到通知。
  4. React 开始像剥洋葱一样,一层层往上找,看看是谁在冒泡,最后找到 button 这个 Fiber 节点。
  5. React 检查这个 Fiber 节点有没有挂 onClick
  6. 如果有,执行它。

这就是事件委托。React 不关心你点的是哪个按钮,它只关心“有没有人想听这个消息”。

代码层面上,React 会在渲染根节点的时候,只挂一个监听器:

// React 内部伪代码(极度简化)
function mountRootContainer(container) {
    const root = createContainer(container);
    const fiberRoot = createFiberRoot(container);

    // 关键点:只挂载一个监听器在 document 上
    // 所有的点击、输入、滚动,都归这里管
    document.addEventListener('click', (nativeEvent) => {
        dispatchEventsForPlugins(nativeEvent);
    }, false);

    return fiberRoot;
}

第三部分:映射路径——从 Fiber 到合成事件

这是今天的重头戏。当原生事件触发后,React 怎么知道该找哪个组件?

这里涉及到了 React 最核心的数据结构——Fiber。你可以把 Fiber 树想象成 React 的 DOM 树的“镜像”,它比 DOM 树更轻量,更便于调度。

我们来看看这个神秘的映射路径:

路径:浏览器原生事件 -> React 事件系统 -> Fiber 节点 -> 事件处理器

1. 捕获原生事件

假设你在页面上有一个组件结构:

function App() {
  return (
    <div onClick={(e) => console.log("App Clicked")}>
      <button onClick={(e) => console.log("Button Clicked")}>Click Me</button>
    </div>
  );
}

当你点击“Click Me”时,浏览器会触发 click 事件。React 在 document 上捕获到这个事件。

2. 提取事件并分发

React 会调用 extractEvents。这就像一个分拣中心,把原生的事件对象(DOM Event)转换成 React 的“合成事件对象”(SyntheticEvent)。

// React 内部伪代码:事件提取
function extractEvents(topLevelType, targetInst, nativeEvent, nativeEventTarget) {
    switch (topLevelType) {
        case 'click':
            // 创建合成事件对象
            return SyntheticEvent.clone(nativeEvent);
        // ... 其他事件类型
    }
}

3. 遍历 Fiber 树(委托的核心)

现在,React 有了事件对象,也有了目标节点的 Fiber 实例(targetInst)。接下来,React 需要找到这个 Fiber 节点,并沿着树向上遍历,看看有没有人订阅了这个事件。

这个过程叫冒泡(Bubble)和捕获(Capture)。

React 会创建一个 dispatchQueue,里面装着要执行的任务。

// React 内部伪代码:冒泡阶段
function dispatchEventForPluginEventSystem(topLevelType, nativeEvent) {
    // 1. 找到目标 Fiber 节点
    const targetInst = getFiberFromDOMNode(nativeEvent.target);

    // 2. 准备事件配置
    const eventSystemConfig = {
        propagationPhases: [bubblingPhase, capturingPhase],
    };

    // 3. 提取事件
    const nativeEvent = nativeEvent;
    const event = extractEvents(topLevelType, targetInst, nativeEvent, nativeEventTarget);

    // 4. 关键步骤:遍历 Fiber 树,寻找监听器
    // 这里的 processDispatchQueue 会处理捕获和冒泡两个阶段
    processDispatchQueue(event, eventSystemConfig.propagationPhases);
}

4. processDispatchQueueaccumulateTwoPhaseListeners

这是最复杂的部分。React 怎么知道哪些 Fiber 节点有监听器?

每个 Fiber 节点都有一个 memoizedProps,这里面存着你的 props,比如 onClick。React 会在渲染阶段就把这些监听器注册到一个全局的 listenerMap 里。

当事件触发时,React 会顺着树往上找:

// React 内部伪代码:遍历逻辑
function processDispatchQueue(event, phases) {
    // phases = ['capture', 'bubble']

    for (let i = 0; i < phases.length; i++) {
        const phase = phases[i];
        const listeners = getListenersForPhase(event, phase); // 获取该阶段的所有监听器

        // 遍历监听器列表
        for (let j = 0; j < listeners.length; j++) {
            const listener = listeners[j];
            // 执行监听器
            // 注意:这里传入的是合成事件对象
            listener(event);
        }
    }
}

这里的 getListenersForPhase 做了什么?它利用了事件委托的精髓。它不是去查 DOM,而是去查 Fiber 树。

假设我们的树结构是:
Root -> App (div) -> Button

捕获阶段(从外向内):

  1. React 找到 Root 的 Fiber。检查 Root 的 props。没 onClick
  2. React 找到 App 的 Fiber。检查 App 的 props。发现有个 onClick!把它加入捕获队列。
  3. React 找到 Button 的 Fiber。检查 Button 的 props。发现有个 onClick!加入捕获队列。
  4. 执行顺序: Button -> App -> Root。

冒泡阶段(从内向外):

  1. React 找到 Button 的 Fiber。检查。执行。
  2. React 找到 App 的 Fiber。检查。执行。
  3. React 找到 Root 的 Fiber。检查。执行。
  4. 执行顺序: Button -> App -> Root。

5. 执行合成事件处理器

终于,到了执行函数的时刻。你写的 onClick={(e) => console.log("Button Clicked")} 被调用了。

传入的 e 是什么?是合成事件对象

// 你的代码
<button onClick={(e) => {
    // 这里的 e 是 SyntheticEvent
    e.preventDefault(); // 这不是浏览器的 preventDefault
    console.log(e.target); // 这不是 DOM 的 target
}} />

第四部分:合成事件对象——它是谁?

React 的合成事件对象长得和原生事件很像,但它是 React 重新造的轮子。

为什么要造轮子?

  1. 跨浏览器一致性:无论你在 Chrome 还是 IE11 上,e.preventDefault() 的行为都是一样的。
  2. 性能优化:React 对事件对象进行了池化处理。在旧版本中,为了减少垃圾回收(GC)的压力,React 会复用事件对象。虽然新版 React 已经移除了事件池,但这个概念依然影响着 React 的设计。
  3. 事件监听器的稳定性:React 的事件监听器永远不会被卸载(除了组件卸载时),这保证了内存安全。

e.target vs e.currentTarget

这是 React 事件系统中最容易让人掉坑里的地方,没有之一。

function Parent() {
  return (
    <div onClick={() => console.log("Parent Clicked")}>
      <button>Child</button>
    </div>
  );
}

如果你在 Parent 里写 onClick,在 button 里也写 onClick

  • e.target:是真正被点击的那个元素。也就是 <button>
  • e.currentTarget:是当前事件绑定在那个元素上。也就是 <div>

React 的合成事件系统完美地保留了这两个属性的含义,和原生 DOM 事件完全一致。

e.persist()

在 React 17 之前,如果你有这样一个需求:

class MyComponent extends React.Component {
    constructor(props) {
        super(props);
        this.state = { timer: null };
    }

    handleClick = (e) => {
        // 试图在异步回调中访问 e
        setTimeout(() => {
            console.log(e.target); // React 16 及以前会报错:e is null
        }, 100);
    }

    render() {
        return <button onClick={this.handleClick}>Click</button>;
    }
}

为什么?因为在 React 16 中,事件对象是复用的。当你在 setTimeout 里访问它时,那个事件对象可能已经被 React 回收了,去服务下一个事件了。

为了解决这个问题,你需要手动调用 e.persist(),告诉 React:“嘿,别回收这个对象,它现在归我了。”

好消息: React 17 之后,为了简化,直接把事件对象变成了不可复用的实例,或者说是每次都创建一个新的,所以 e.persist() 永远不会再报错了。这是 React 17 一个巨大的性能和安全改进。


第五部分:onClick 的魔法——冒泡与捕获

React 的事件系统支持捕获阶段冒泡阶段

通常我们写 onClick,指的是冒泡阶段

但是,React 提供了一个属性叫 onCaptureClick(或者 onMouseDownonMouseEnter 等),它指的是捕获阶段

让我们看看代码中的区别:

function Demo() {
  return (
    <div 
      onClick={() => console.log("Bubble: Div")}
      onCaptureClick={() => console.log("Capture: Div")}
    >
      <button 
        onClick={() => console.log("Bubble: Button")}
        onCaptureClick={() => console.log("Capture: Button")}
      >
        Click
      </button>
    </div>
  );
}

当你点击按钮时,控制台会输出:

  1. Capture: Button
  2. Capture: Div
  3. Bubble: Button
  4. Bubble: Div

这个顺序非常重要。如果你在捕获阶段调用了 e.stopPropagation(),React 就会停止向父节点传播,父节点的 onClick 就不会触发。

<button onClick={(e) => {
    e.stopPropagation(); // 停止冒泡
    console.log("I am alone");
}}>
    Stop Propagation
</button>

第六部分:Fiber 与事件系统的深度耦合

这里我要稍微深入一点,讲讲 Fiber 节点是如何存储事件信息的。

在 React 的渲染过程中,Fiber 节点不仅仅存储了 DOM 节点的信息,还存储了事件监听器

当组件渲染时,React 会遍历虚拟 DOM 树,生成 Fiber 树。在这个过程中,React 会把这些 onClickonChange 等属性,转换成 React 内部的事件监听器注册表。

这是一个非常精妙的映射:

Props -> Event Listeners

// React 内部伪代码:事件注册
function createFiberFromElement(element) {
    const fiber = {
        type: element.type,
        props: element.props,
        // React 会在这里做特殊处理
        // 如果 props 里是 onClick,它会转换成 listeners: [listener1, listener2]
        listeners: getEventListeners(element.props),
    };
    return fiber;
}

当事件触发时,React 并不是去遍历 DOM 树找 onclick 属性,而是直接遍历 Fiber 树。

这种设计让 React 可以非常轻松地实现并发模式下的取消和中断。

想象一下,你正在点击一个按钮,此时浏览器正在执行一个耗时的 JS 计算。React 的 Fiber 引擎会暂停渲染,直到这个计算完成。

如果事件系统是直接绑定在 DOM 上的,那 React 就没法控制了。但因为是委托在 document 上,并且事件分发是在 JS 层面通过 Fiber 树进行的,React 就可以随时打断事件处理,或者重新分配事件处理逻辑。


第七部分:调试与排查

如果你在 React 事件系统里遇到了问题,怎么调?

方法一:debugger 断点

dispatchEvent 函数里打上断点。你可以看到 nativeEvent,可以看到 targetInst(Fiber 节点),可以看到 listeners 列表。

方法二:打印事件流

function logEvent(e) {
    console.group("Event Details");
    console.log("Target:", e.target);
    console.log("CurrentTarget:", e.currentTarget);
    console.log("Native Event:", e.nativeEvent);
    console.groupEnd();
}

// 使用
<button onClick={logEvent}>Debug</button>

常见坑:

  1. onMouseDown 里阻止默认行为,onClick 不触发?
    这是正常的!onClick 是在冒泡阶段触发的。如果 onMouseDown 阻止了冒泡,onClick 自然就没了。

  2. onBlur 里阻止 onFocus
    不可能的。focusblur 是捕获阶段的。React 把它们作为特殊事件处理,不能通过 stopPropagation 阻止。

  3. 动态添加的事件不生效?
    React 的事件委托只在渲染阶段构建 Fiber 树时生效。如果你在组件挂载后,用 document.createElement 动态添加了一个 <button> 并绑定了 onClick,React 是不知道的。你需要手动把事件挂载到 document 上,或者用 useEffect 手动绑定。


第八部分:useLayoutEffect 与事件系统的微妙关系

最后,我们聊聊 useLayoutEffect

大家都知道 useLayoutEffect 是同步执行的,在浏览器绘制之前。它和 onClick 有什么关系?

当你点击一个按钮,onClick(也就是 onMouseDown)是异步执行的(在浏览器空闲时,或者下一帧)。

但是,如果你在 useLayoutEffect 里修改了 DOM,并且你想在修改后立即响应一个点击事件,你会发现 onClick 已经在上一帧执行完了。

useEffect(() => {
    console.log("useEffect: 异步,渲染后执行");
}, []);

useLayoutEffect(() => {
    document.body.style.backgroundColor = 'red';
    console.log("useLayoutEffect: 同步,绘制前执行");
}, []);

如果你在 useLayoutEffect 里改变样式,然后马上点击,你可能会发现样式变了,但点击逻辑还没跑。这就是因为 onClick 的处理逻辑是在 useLayoutEffect 执行完之后才触发的。

所以,如果你在 useLayoutEffect 里操作了 DOM 导致布局变化,并且想监听这个变化(比如监听 click),你需要小心时序问题。


第九部分:总结——这就是“中间层”的艺术

好了,咱们把镜头拉远。

React 的合成事件系统,本质上是一个中间层

  • 底层是混乱、性能低下、兼容性差的原生 DOM 事件。
  • 上层是优雅、声明式、易于维护的 React 组件 API。

React 的工程师们做了一件极其聪明的事情:委托

他们没有给每个按钮都建一个哨兵,而是让整个 document 变成了一个巨大的雷达站。

当你点击屏幕时,你面对的不是一个按钮,而是一棵巨大的 Fiber 树。React 像侦探一样,沿着这棵树一路向上追溯,找到了你的代码,然后说:“嘿,这里有个监听器,你来处理吧。”

这就是 React 事件系统的全貌。它不仅仅是封装了 addEventListener,它是重构了整个前端交互的底层逻辑。

所以,下次当你写 onClick 的时候,请心存敬畏。这行短短的代码背后,是成千上万行复杂的 JavaScript 逻辑在为你服务,是 React 团队为了解决浏览器兼容性、性能优化和代码可维护性所付出的巨大心血。

代码虽短,江湖路远。希望大家在 React 的世界里,既能写出漂亮的组件,也能读懂底层的逻辑。

现在,放下手机,去写点代码吧!

发表回复

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