React 合成事件系统插件注册表机制

各位同学,大家好!

欢迎来到今天这场关于 React 内部黑魔法的高级研讨会。我是你们的讲师,今天我们不聊怎么写业务代码,也不聊怎么用 Hooks,我们要把镜头拉长,从 100 层楼顶往下看——看 React 是如何在浏览器这片混乱的丛林中,建立起一套井井有条的秩序的。

今天的主角是 React 合成事件系统,特别是它的核心组件——插件注册表机制

如果浏览器是一头脾气暴躁的怪兽,React 就是那个拿着驯兽棒、试图把怪兽变成乖狗狗的驯兽师。而“插件注册表”,就是驯兽师手里的那张驯兽图鉴。它告诉 React:嘿,这头怪兽(浏览器)想要“点击”,我们就给它一个“合成点击”;它想要“滚动”,我们就给它一个“合成滚动”。而且,我们要确保不管这头怪兽是 IE6 还是 Chrome,我们给的“点击”感觉都是一样的。

准备好了吗?系好安全带,我们这就钻进 React 的肚子里看看。


第一章:浏览器是个疯子,我们需要“合成”

在 React 出现之前,前端开发是一场噩梦。每个浏览器厂商都是独立的部落,他们各自为政。

  • IE6 说:“我这里的 onclick 事件,必须绑定在 document 上,而且事件冒泡的时候,你要手动调用 window.event.cancelBubble = true。”
  • Firefox 说:“不,我们不一样。我们在 DOM 节点本身上绑定事件,而且没有 window.event 这个东西,你得用 e.target。”
  • Safari 说:“我在滚动的时候,有时候会冒泡,有时候不冒泡,全看心情。”

所以,如果你直接在代码里写 element.addEventListener('click', handler),你的代码写好了,但只有一半的浏览器能跑通。这就是“原生事件”的局限性。

于是,React 决定:我要发明一种我自己的事件系统,叫“合成事件”

这个系统有两个核心目标:

  1. 跨浏览器一致性:不管底层浏览器怎么变,React 给你返回的一定是一个标准的 SyntheticEvent 对象。
  2. 性能优化:它不是真的在每一个 DOM 节点上绑定监听器(那样内存会爆),而是把监听器统一绑定在最外层的容器上,靠“事件委托”来干活。

但是,怎么把 React 的 onClick 映射到底层浏览器的 click 怎么办?这就引出了我们的核心——插件注册表


第二章:EventPluginRegistry —— 听起来像是个兽医诊所,其实是翻译官

在 React 的源码里,EventPluginRegistry 是一个极其重要的单例。它的主要工作就是翻译

React 定义了一套标准的事件名,比如 onClickonDoubleClickonScroll。但是浏览器只认识 clickdblclickscroll。那么,谁来建立这个映射关系?谁告诉 React,当你点击的时候,该去调用哪个插件来处理?这就是注册表的作用。

1. 插件接口:每个插件都得有个“名片”

一个插件要想加入 React 的事件系统,必须实现一套标准接口。这就像你进餐厅吃饭,你得先看菜单,菜单上写着“这里有红烧肉、清蒸鱼”。对于 React 来说,插件就是“厨师”,注册表就是“菜单”。

让我们看看这个接口长什么样(为了演示,简化了部分代码):

// 伪代码:插件必须有的三个“武器”
const EventPluginInterface = {
  // 1. 插件名
  name: 'SimpleEventPlugin',

  // 2. 插件注册的事件类型
  // 告诉注册表:我负责处理 'onClick', 'onDoubleClick' 这两个事件
  eventTypes: {
    onClick: {
      phasedRegistrationNames: {
        bubbled: 'onClick',
        captured: 'onCaptureClick', // 捕获阶段的别名
      },
      // 这个对象很重要,决定了这个事件在 DOM 层面对应什么原生事件
      registrationName: 'onClick',
    },
    onDoubleClick: {
      phasedRegistrationNames: {
        bubbled: 'onDoubleClick',
        captured: 'onCaptureDoubleClick',
      },
      registrationName: 'onDoubleClick',
    }
  },

  // 3. 提取合成事件的方法
  // 当浏览器触发原生事件时,这个方法被调用,负责把原生事件包装成合成事件
  extractEvents: function (topLevelType, targetInst, nativeEvent, nativeEventTarget) {
    // 简单的例子:如果是 click,就返回一个合成点击事件对象
    if (topLevelType === 'click') {
      return new SyntheticMouseEvent('onClick', nativeEvent);
    }
    return null;
  }
};

2. 注册表的核心逻辑:建立索引

React 启动时,会调用 injectEventPluginsByName。这个方法会遍历所有注册的插件,把它们的 eventTypes 挂载到一个全局对象上。

这个全局对象是干什么的?它是 React 事件系统的字典。当你写 onClick={handler} 时,React 会去查这个字典,找到对应的插件。

代码示例:注册表的内部实现逻辑

// 这是一个极度简化版的 EventPluginRegistry 内部逻辑
const EventPluginRegistry = {
  // 存储所有注册的插件实例
  plugins: [],

  // 核心映射表:注册名 -> 插件实例
  // 比如 'onClick' -> EventPluginInterface
  registrationNameModules: {},

  // 核心映射表:注册名 -> 原生事件类型
  // 比如 'onClick' -> 'click'
  registrationNameDependencies: {},

  // 注册插件
  injectEventPluginsByName: function (pluginNameToPlugin) {
    // ... 排序逻辑(稍微有点复杂,涉及插件依赖顺序,这里略过)

    // 遍历所有插件
    Object.keys(pluginNameToPlugin).forEach(pluginName => {
      const plugin = pluginNameToPlugin[pluginName];

      // 1. 注册事件类型
      // 将插件提供的 eventTypes 挂载到全局
      const { eventTypes, name } = plugin;
      this.eventTypes = { ...this.eventTypes, ...eventTypes };

      // 2. 建立注册名到插件的映射
      // 这意味着:当你在代码里写 onClick 时,React 知道你要找的是这个插件
      Object.keys(eventTypes).forEach(registrationName => {
        this.registrationNameModules[registrationName] = plugin;
      });

      // 3. 建立注册名到原生事件类型的映射
      // 这意味着:React 知道要在 DOM 上监听什么原生事件
      Object.keys(eventTypes).forEach(registrationName => {
        const { phasedRegistrationNames } = eventTypes[registrationName];
        // 这里不仅存了 bubbled,还存了 captured
        if (phasedRegistrationNames) {
          Object.keys(phasedRegistrationNames).forEach(phasedName => {
            const dependentRegistrationName = phasedRegistrationNames[phasedName];
            this.registrationNameDependencies[dependentRegistrationName] = [
              ...this.registrationNameDependencies[dependentRegistrationName],
              registrationName
            ];
          });
        }
      });

      this.plugins.push(plugin);
    });
  }
};

// 使用示例
EventPluginRegistry.injectEventPluginsByName({
  SimpleEventPlugin: EventPluginInterface
});

// 此时,注册表已经变成了这样:
console.log(EventPluginRegistry.registrationNameModules['onClick']); 
// 输出: SimpleEventPlugin 实例
console.log(EventPluginRegistry.registrationNameDependencies['onClick']); 
// 输出: ['onClick']

看到没?注册表就像一个巨大的路由表。它不仅告诉你“是谁写的这个事件”,还告诉你“这个事件在底层对应哪个原生事件”。没有这个注册表,React 就是一堆没有灵魂的代码,根本不知道怎么去响应你的点击。


第三章:EventPluginHub —— 中央指挥中心

有了注册表,React 知道了“菜单”(事件名)和“厨师”(插件),但是当用户真的点击了屏幕,谁来指挥这些厨师干活呢?

这就轮到 EventPluginHub 登场了。Hub 的主要职责是管理监听器

1. 监听器银行

想象一下,你的应用里有 10000 个按钮,每个按钮都有 onClick。如果每个按钮都绑一个监听器,那内存早就爆了。React 的做法是:只绑定一个

这个唯一的监听器绑定在哪里?通常绑定在 document 或者 React 的根容器上。

Hub 里有一个叫 listenerBank 的东西。它是一个 Map 结构,把 DOM 节点作为 Key,把该节点上注册的所有 React 事件监听器作为 Value。

const EventPluginHub = {
  // 监听器银行:记录哪些节点绑定了哪些监听器
  listenerBank: {},

  // 注册监听器
  listenTo: function (registrationName, targetNode) {
    // 1. 查注册表,看看这个 registrationName 需要监听哪些原生事件
    const dependencies = EventPluginRegistry.registrationNameDependencies[registrationName];

    // 2. 遍历依赖,在 targetNode 上绑定原生监听器
    dependencies.forEach(event => {
      // 这里是真正的 DOM 操作,虽然很底层
      targetNode.addEventListener(event, this.handleTopLevelEvent, false);

      // 同时把监听器存入 listenerBank
      // listenerBank[targetNode][event] = listener
    });
  }
};

2. 单例模式与事务

Hub 是一个单例。它不仅仅是存监听器,它还负责执行监听器。

当顶层事件触发时,React 的顶层处理函数会调用 handleTopLevelEvent。这个函数会做三件事:

  1. 识别事件类型:是 click?还是 scroll?
  2. 提取合成事件:调用所有插件的 extractEvents 方法,收集所有相关的合成事件对象。
  3. 分发事件:把这些合成事件交给 EventPropagators 去传播。

第四章:EventPropagators —— 冒泡与捕获的华尔兹

现在,我们有了事件,也有了监听器。接下来,事件要开始“流动”了。这就是 EventPropagators 的舞台。

React 的事件流分为两个阶段:

  1. 捕获阶段:从 Window 到 Document,再到父元素,最后到目标元素。
  2. 冒泡阶段:从目标元素,一直冒泡到 Window。

1. 递归的舞蹈

EventPropagators 通过递归来处理这个流程。它维护了一个 dispatchConfig,里面包含了当前事件的配置(比如事件名、冒泡阶段回调列表、捕获阶段回调列表)。

代码示例:事件传播的核心逻辑

const EventPropagators = {
  // 分发事件的核心入口
  accumulateTwoPhaseDispatches: function (event) {
    // 如果事件没有配置(比如有些事件不需要冒泡),直接返回
    if (!event.dispatchConfig.phasedRegistrationNames) return;

    // 遍历配置中的 bubbled 和 captured
    const { bubbledRegistrationName, capturedRegistrationName } = event.dispatchConfig;

    // 1. 先跑捕获阶段(从外到内)
    if (capturedRegistrationName) {
      thisaccumulateTwoPhaseDispatchesSingle(event, capturedRegistrationName);
    }

    // 2. 再跑冒泡阶段(从内到外)
    if (bubbledRegistrationName) {
      this.accumulateTwoPhaseDispatchesSingle(event, bubbledRegistrationName);
    }
  },

  // 单次传播逻辑
  accumulateTwoPhaseDispatchesSingle: function (event, registrationName) {
    // 1. 查找注册表,找到这个 registrationName 属于哪个插件
    const pluginModule = EventPluginRegistry.registrationNameModules[registrationName];

    if (pluginModule) {
      // 2. 调用插件的方法,把事件绑定到当前节点的“回调队列”中
      // event._dispatchListeners.push(插件提供的回调函数)
      pluginModule.executeDispatch(event, registrationName);
    }
  },

  // 执行回调
  executeDispatch: function (event, registrationName) {
    // 这里就是真正执行你写的 onClick={handler} 的地方
    // event.targetInst 是被点击的组件实例
    // handler 是你传入的函数
    const handler = event.targetInst[registrationName];
    if (handler) {
      handler(event.nativeEvent);
    }
  }
};

2. 代码演示:点击一个嵌套的 Div

假设你有这样的 DOM 结构:
<div id="root"><div id="A"><div id="B">点击我</div></div></div>

你给 A 绑了 onClick,给 B 绑了 onClick

  1. 捕获阶段:React 在 root 拦截事件。它发现 root 没有监听器,往下找 AA 有监听器!执行 A 的回调。继续往下找 BB 有监听器!执行 B 的回调
  2. 目标阶段:到达 B
  3. 冒泡阶段B 处理完,往回找 AA 处理完,往回找 root

这就是 EventPropagators 的工作,它像一个不知疲倦的邮递员,把包裹(事件对象)一层层地传递下去,或者往上传递。


第五章:合成事件的“一次性”与内存管理

这是 React 合成事件系统最精妙的地方,也是很多新手容易困惑的地方。

1. 为什么合成事件是一次性的?

当你写 onClick={handleClick} 时,React 会把你的 handleClick 函数保存起来。但是,当你点击按钮时,React 不会把这个函数永久绑定在 DOM 节点上。

相反,React 会维护一个事件队列。每次点击发生,React 会清空这个队列,把新的合成事件对象放进去,然后执行队列里的所有回调。

这意味着:e.persist() 是没用的。每次你拿到 SyntheticEvent,它都是全新的,用完即焚。

2. 性能优化:hasListener 检查

你可能听说过“一次性事件”。这是 React 为了性能做的一个优化。

假设你有一个父组件 Parent 和一个子组件 Child
你在 Parent 上绑了 onClick,在 Child 上也绑了 onClick

当你点击 Child 时,React 必须去检查 ParentonClick 是否被禁用了。
React 怎么知道有没有被禁用?它不会每次都去遍历整个组件树。它使用了一个 hasListener 检查机制

当你在 Parent 上调用 onClick={null}(或者 onClick={undefined})时,React 会把这个状态记录下来。然后,在事件传播到 Parent 之前,React 会检查:
if (EventPluginHub.hasListener(ReactDOMComponent, 'onClick')) { ... }

如果 hasListener 返回 false,React 会直接跳过这个节点,不执行回调,也不继续向下传播(如果是捕获阶段)或者向上传播(如果是冒泡阶段)。

代码示例:hasListener 的实现

const EventPluginHub = {
  // 记录组件上是否绑定了特定的事件监听器
  // key 是组件实例,value 是一个 Set,包含所有注册的事件名
  listenerBank: {},

  // 注册监听器时调用
  putListener: function (inst, registrationName, listener) {
    const bankForInst = this.listenerBank[inst] || (this.listenerBank[inst] = {});
    bankForInst[registrationName] = listener;
  },

  // 移除监听器时调用
  deleteListener: function (inst, registrationName) {
    const bankForInst = this.listenerBank[inst];
    if (bankForInst) {
      delete bankForInst[registrationName];
    }
  },

  // 核心检查函数
  hasListener: function (inst, registrationName) {
    const bankForInst = this.listenerBank[inst];
    return !!bankForInst && !!bankForInst[registrationName];
  }
};

这个机制非常高效。它避免了在事件触发时,去遍历组件树查找 onClick 是否为空。它直接查表,O(1) 复杂度。这就是 React 事件系统性能强悍的秘密武器之一。


第六章:被动事件与浏览器新特性

随着现代浏览器的进化,Chrome 和 Firefox 引入了一个新概念:被动事件监听器

什么是被动事件?

  • 被动:监听器会调用 preventDefault()。浏览器可以立即开始滚动页面,不用等 JS 执行完。
  • 主动:监听器可能会调用 preventDefault()。浏览器必须等 JS 执行完,确认不阻止默认行为后,才能滚动。

React 在合成事件系统中也支持这个特性。这主要涉及 onScroll 等事件。

// 注册表中的配置示例
const ScrollEventPlugin = {
  // ...
  eventTypes: {
    onScroll: {
      // ...
      // 指定这是被动事件
      isInteractive: true, 
    }
  },

  // ...
};

当你写 <div onScroll={handler}> 时,React 会检测到这是一个 isInteractive 事件,然后在绑定原生监听器时,使用 passive: true 选项。

这解决了 React 早期版本中 onScroll 阻塞页面滚动的 Bug。这再次证明了 React 事件系统的灵活性和对浏览器标准的紧跟。


第七章:实战演练 —— 从点击到回调的完整链路

让我们把所有东西串起来,模拟一次真实的点击。

场景:你在 <button onClick={handleClick}> 上点击了鼠标。

  1. 初始化

    • React 渲染 button
    • EventPluginRegistry 已经注册了 SimpleEventPlugin
    • EventPluginHub.listenTo('onClick', buttonDOMElement) 被调用。
    • 注册表查到 onClick 依赖 click
    • buttonDOMElement.addEventListener('click', handleTopLevelEvent, false) 被执行。
    • handleTopLevelEvent 被存入 listenerBank
  2. 用户交互

    • 用户手指按下,松开。
    • 浏览器发出原生 click 事件。
  3. 顶层处理

    • handleTopLevelEvent 被触发。
    • React 提取出 topLevelType = 'click'
    • React 找到目标组件实例 buttonInstance
  4. 事件提取

    • React 遍历所有插件。
    • SimpleEventPlugin.extractEvents('click', buttonInstance, nativeEvent, target) 被调用。
    • SimpleEventPlugin 创建了一个 SyntheticEvent 对象,并返回。
  5. 分发与传播

    • EventPropagators.accumulateTwoPhaseDispatches(syntheticEvent) 被调用。
    • 捕获阶段
      • React 向上查找父节点。
      • 找到父节点,检查 hasListener(parent, 'onClick') -> True。
      • 找到 SimpleEventPlugin,执行 pluginModule.executeDispatch
      • buttonInstance.onClick(即你的 handleClick)被调用。
    • 冒泡阶段
      • React 向下查找子节点(如果有)。
      • 回到 buttonInstance
      • 再次执行 buttonInstance.onClick
      • 最后回到 document,清理。
  6. 清理

    • 合成事件对象被销毁(不可复用)。

第八章:总结与吐槽

好了,各位同学,今天的讲座就到这里。

我们聊了 React 合成事件系统,特别是那个默默无闻却至关重要的 EventPluginRegistry(插件注册表)。

它不仅仅是一个字典,它是 React 事件系统的基石。

  • 它负责翻译(React 事件名 <-> 原生事件名)。
  • 它负责分发(决定哪个插件处理什么事件)。
  • 它配合 EventPluginHubEventPropagators,构建了一个高性能、跨浏览器的统一事件层。

React 的设计哲学在这里体现得淋漓尽致:封装复杂性,暴露简单接口。

你不需要知道 addEventListener,不需要知道 event.stopPropagation(虽然你还可以用,但通常不需要),不需要知道冒泡捕获的底层逻辑。你只需要写 onClick,React 会处理好剩下的一切。

但是,当你遇到奇怪的事件行为,或者为了极致的性能优化时,了解这个“幕后黑手”会让你受益匪浅。

最后,给各位留个作业:
去读读 React 源码里的 EventPluginRegistry.jsEventPluginHub.js。别被那一堆 Object.freezein 操作符吓到。你会发现,这些代码虽然老(React 15/16 时期写的),但设计得非常优雅,充满了工程美感。

好了,下课!记得把你的 e.persist() 丢掉,现在我们用合成事件,它不存在的!

发表回复

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