React 浏览器兼容层:源码分析内部封装的合成事件归一化(Normalization)处理逻辑

各位未来的架构师,大家好!

今天我们不谈业务,不谈代码风格,我们要深入 React 的“下水道”——也就是它的核心事件系统。为什么?因为如果不搞懂这个,你永远只是一个“会用 React 的人”,而无法成为“理解 React 的人”。

想象一下,如果浏览器是上帝,那么 React 开发者就是试图用泥巴搭建摩天大楼的凡人。浏览器的原生事件系统是什么?它是混乱的,它是充满 Bug 的,它是充满了对 IE6 的怨念的。

React 做了什么?它搭建了一个“兼容层”。今天,我们就来扒开这个兼容层的裤腰带,看看它是如何把那些千奇百怪的浏览器原生事件,强行揉捏成一个统一、规范、优雅的“合成事件”的。这个过程,我们就叫它“归一化”

准备好了吗?让我们开始这场名为“拯救 DOM 事件”的冒险。


第一回:原生事件的“坑爹”往事

在讲归一化之前,我们必须先看看原生事件有多糟糕。如果不了解敌人的丑陋,你就无法欣赏我方的帅气。

在 React 出现之前,你在浏览器里写事件是这样的:

// 这里的 this 指向谁?window?还是 undefined?谁在乎!
button.addEventListener('click', function(e) {
  // 老版本 IE6 还要判断 e.srcElement,现代浏览器是 e.target
  // e.pageX 还是 e.clientX?这取决于视口是否滚动。

  // 冒泡顺序是什么?IE 是 capture -> target -> bubble,标准浏览器是相反的。
  // 如果我点了按钮,按钮冒泡到 div,div 的 click 事件先触发还是后触发?
  // 哎呀,我的头好痛。
});

React 的核心思想是“一致性”。无论你在 Chrome、Safari 还是 IE11 上,只要我写了 onClick,它的表现必须一模一样。

为了实现这一点,React 在 DOM 事件和 React 事件之间,建立了一个巨大的、精密的中间层。这个中间层就是合成事件系统


第二回:合成事件系统架构总览

React 的事件系统,名字叫 EventSystem。它主要由三个核心模块组成,就像一个豪华的三人乐队:

  1. EventPluginHub(指挥家): 负责管理事件监听器的注册、分发,以及那个核心的数据结构 EventPluginHub
  2. EventPluginRegistry(乐谱编辑器): 负责把各种事件类型(比如 click, focus, submit)映射到具体的处理逻辑上。
  3. EventPropagators(演奏家): 负责事件的冒泡和捕获传播。

今天我们的重点是归一化,也就是 EventPluginRegistryEventPluginHub 在幕后干了什么。


第三回:归一化的魔法——EventPluginRegistry

React 的事件不是凭空捏造的,也不是直接透传原生事件的。它把原生事件“吃进去”,嚼碎了,吐出来一个全新的、标准化的 SyntheticEvent

这个“嚼碎”的过程,就是归一化。

1. 插件系统

React 把不同类型的事件归一化逻辑,拆分成了不同的“插件”。这是典型的策略模式。

在 React 源码中,有一个 injectEventPluginsByName 方法。它会把一堆插件注册进去。这些插件主要有哪些?

  • SimpleEventPlugin: 处理 click, focus, blur, submit 等最常见的事件。
  • EnterLeaveEventPlugin: 处理 mouseenter, mouseleave。注意,原生 DOM 里没有这两个事件!React 是怎么造出来的?这归功于它的事件冒泡机制。
  • ChangeEventPlugin: 处理 input, change。这玩意儿在浏览器里的行为也极其不一致(IE 用 onpropertychange,其他浏览器用 oninput)。
  • SelectEventPlugin: 处理 onSelect
  • BeforeInputEventPlugin: 处理 onBeforeInput

2. 归一化配置

当你在 React 里写 onClick 时,React 并没有直接去监听浏览器的 click 事件(虽然它确实会监听,但那是另一回事)。React 在初始化时,会构建一个庞大的配置表。

让我们看一段伪代码,感受一下这个配置表的“恐怖”:

// 简化的 EventPluginRegistry 逻辑
const eventTypes = {
  click: {
    phasedRegistrationNames: {
      bubbled: 'onClick',
      captured: 'onCaptureClick',
    },
    dependencies: ['topMouseDown', 'topMouseUp', 'topClick'],
  },
  mouseEnter: {
    phasedRegistrationNames: {
      bubbled: 'onMouseEnter',
      captured: 'onCaptureMouseEnter',
    },
    dependencies: ['topMouseOver', 'topMouseLeave'],
  },
  // ... 还有几百个
};

const plugins = [
  SimpleEventPlugin,
  EnterLeaveEventPlugin,
  // ...
];

function injectEventPluginsByName() {
  // 1. 收集所有事件类型的注册名
  // 2. 按照依赖关系排序
  // 3. 注册到 EventPluginHub
}

看到 dependencies 了吗?这就是归一化的关键!

当你写 onClick 时,React 并不是只监听 click。React 会去监听它的依赖项:topMouseDown(鼠标按下),topMouseUp(鼠标松开),topClick(鼠标点击)。

3. 事件聚合

这是归一化最迷人的一步。

假设你有一个嵌套结构:

<div onClick={handleDivClick}>
  <button onClick={handleButtonClick}>点击我</button>
</div>

当你点击这个按钮时,原生浏览器会发生什么?

  1. mousedown 在按钮上触发。
  2. mouseup 在按钮上触发。
  3. click 在按钮上触发。
  4. clickdiv 上触发。
  5. clickbody 上触发。

React 怎么做?

React 会把所有这些事件收集到一个队列里。

  1. 它给 button 绑定了 topMouseDown, topMouseUp, topClick
  2. 它给 div 绑定了 topMouseDown, topMouseUp, topClick
  3. 当你点击按钮时,React 会收到这一串原生事件。
  4. 关键逻辑: React 会把这些原生事件“合并”成一个合成事件对象
// 伪代码:React 内部的事件聚合逻辑
function processEventQueue(event, inCapturePhase) {
  // 这是一个巨大的数组
  const syntheticEvents = [];

  // 遍历所有注册的监听器
  // 这里的逻辑非常复杂,涉及到事件冒泡/捕获阶段
  // 简化版:
  if (inCapturePhase) {
    // 捕获阶段:从 window 到 target
    triggerCaptures(event, syntheticEvents);
  } else {
    // 冒泡阶段:从 target 到 window
    triggerBubbles(event, syntheticEvents);
  }

  // 归一化处理
  return normalizeEvents(syntheticEvents);
}

这个 syntheticEvents 数组里,可能包含 3 个 topClick 事件对象(一个来自按钮,一个来自 div,一个来自 body)。


第四回:深度解析——SimpleEventPlugin 的归一化逻辑

现在,我们进入重头戏。来看看那个最核心的 SimpleEventPlugin 是如何把一堆杂乱的事件,变成我们熟悉的 onClick 的。

这个插件的代码逻辑大致如下:

// React 源码中的 SimpleEventPlugin 简化版
const SimpleEventPlugin = {
  eventTypes: {
    onClick: {
      phasedRegistrationNames: {
        bubbled: 'onClick',
        captured: 'onCaptureClick',
      },
      // 依赖项:这决定了 React 会监听哪些原生事件
      dependencies: ['topMouseDown', 'topMouseUp', 'topClick'],
    },
    // ... 其他事件
  },

  // 这个函数是归一化的核心入口
  extractEvents: function(topLevelType, targetInst, nativeEvent, nativeEventTarget) {
    // 1. 判断事件类型
    // 如果是 topClick,或者 topMouseDown + topMouseUp(组合成 Click),或者是 topMouseUp(组合成 Click)
    // 我们就需要创建一个合成事件。

    let event = null;

    switch (topLevelType) {
      case 'topClick':
        // 如果是点击,或者鼠标松开(可能触发了点击),或者鼠标按下
        event = SyntheticEvent.pooledCreate(nativeEvent);
        break;

      // ... 处理其他事件
    }

    // 2. 填充合成事件
    if (event) {
      // 设置事件的目标
      event.target = nativeEventTarget;
      // 设置事件的类型
      event.type = topLevelType;
      // 设置当前 DOM 节点
      event.currentTarget = targetInst;
    }

    return event;
  },
};

这里有一个非常重要的概念:事件池

在旧版本的 React 中,为了性能,合成事件对象是复用的。就像一个“雨伞”,大家共用一把,用完放回去,下次下雨再拿出来。这极大地减少了垃圾回收(GC)的压力。

// 旧版 React 的事件池逻辑
function SyntheticEvent(reactName, reactEventSystemConfig, domEventName, targetInst) {
  this.dispatchConfig = reactName;
  this.dispatchTargetInst = targetInst;

  // 闭包里的 nativeEvent
  this.nativeEvent = nativeEvent;

  // 标记是否已释放
  this.isPersistent = function() {
    return !this.isRemoved;
  };

  this.release = function() {
    // 把自己放回池子
    SyntheticEvent.pooledCount++;
    if (SyntheticEvent.pooledCount < MAX_POOL_SIZE) {
      // 这里的逻辑是把当前实例挂到全局的池子里,下次取用
    } else {
      // 池子满了,直接销毁
      this.isRemoved = true;
    }
  };
}

注意,新版 React(React 16+)为了简化内存管理和不可变性,去掉了事件池。现在每次触发事件,都会创建一个新的合成事件对象。这听起来更符合直觉,但性能开销也稍微大一点(虽然 React 做了大量的优化)。


第五回:归一化的灵魂——点击事件的判定

我们之前提到,onClick 依赖 topMouseDown, topMouseUp, topClick。这是怎么判定的?

在 React 内部,有一个 SimpleEventPlugin 的逻辑,它会检查这些原生事件的组合。

// 模拟 React 内部处理点击的逻辑
let isDown = false;

function handleMouseDown() {
  isDown = true;
}

function handleMouseUp() {
  if (isDown) {
    // 鼠标松开时,如果之前按下了,就判定为 Click
    dispatchClick(); 
  }
  isDown = false;
}

function handleClick() {
  // 如果是点击,直接分发
  dispatchClick();
}

function dispatchClick() {
  // 这里会调用 EventPluginHub,找到所有注册了 onClick 的组件
  // 然后逐个执行
  EventPluginHub.executeDispatchesInOrder(event);
}

这解释了为什么有时候你会在 onClick 之前触发 onMouseDownonMouseUp。React 把这些“前置动作”和“结果动作”统一打包了。

这就是归一化的魅力: 你不需要关心底层的鼠标是怎么移动的,你只需要关心“点击”这个结果。


第六回:冒泡机制的“手动模拟”

浏览器的事件冒泡是原生的。但 React 为了统一,它不使用原生的冒泡,而是自己写了一套冒泡逻辑。

为什么?因为原生的冒泡顺序在不同浏览器里是不一致的(还记得那个 IE6 的故事吗?)。而且,React 需要在冒泡过程中动态地决定是否停止冒泡,或者修改事件对象。

React 的冒泡逻辑位于 EventPropagators 模块中。

当合成事件产生后,React 会根据你写的 onCaptureClick(捕获阶段)还是 onClick(冒泡阶段)来决定执行顺序。

// EventPropagators 的简化逻辑
function traverseTwoPhase(inst, event, listener) {
  // 1. 捕获阶段
  // 从 window 往下找,直到 target
  traverseEnterLeave(inst, null, event, listener, true);

  // 2. 冒泡阶段
  // 从 target 往上找,直到 window
  traverseEnterLeave(null, inst, event, listener, false);
}

function traverseEnterLeave(leaveNode, enterNode, event, listener, isEnterPhase) {
  let node = isEnterPhase ? enterNode : leaveNode;
  // 向上遍历 DOM 树
  while (true) {
    // 执行当前节点的 listener
    // 如果 listener 返回 false,就停止传播(阻止冒泡)
    const ret = executeListener(node, event, listener);
    if (ret === false) return;

    // 移动到父节点
    node = getParent(node);
    if (node === null) break;
  }
}

这里的 getParent 方法,React 是通过维护一个“Fiber 树”来实现的。React 会从当前的 DOM 节点,反向查找对应的 Fiber 节点,再通过 Fiber 节点找到父 Fiber 节点,最后映射回真实的 DOM 父节点。

这就像是一场精准的导航,而不是随波逐流的落叶。


第五回:那些“不存在”的事件——EnterLeaveEventPlugin

这是归一化处理中最让人拍案叫绝的地方之一。

HTML/DOM 规范里,没有 mouseentermouseleave 事件。

为什么?因为 mouseovermouseout 会一直冒泡。如果你从 div 移动到 buttonmouseover 会触发 div,然后触发 button,然后触发 body……直到 html

这太蠢了。我们通常只需要知道“鼠标进入”和“鼠标离开”当前元素,而不是它的所有祖先。

React 怎么办?造一个!

这就是 EnterLeaveEventPlugin 的作用。

它的核心逻辑是:利用 topMouseOvertopMouseLeave 事件,结合 React 的 Fiber 树结构,计算鼠标是否真的离开了当前元素。

// EnterLeaveEventPlugin 的简化逻辑
const EnterLeaveEventPlugin = {
  eventTypes: {
    onMouseEnter: {
      phasedRegistrationNames: {
        bubbled: 'onMouseEnter',
        captured: 'onCaptureMouseEnter',
      },
      // 依赖项:MouseOver 和 MouseLeave
      dependencies: ['topMouseOver', 'topMouseLeave'],
    },
  },

  extractEvents: function(topLevelType, targetInst, nativeEvent, nativeEventTarget) {
    if (topLevelType === 'topMouseOver') {
      // 如果是 mouseover
      // 检查 relatedTarget(相关目标)
      // 如果 relatedTarget 不在当前节点的子树中,说明鼠标进入了新节点
      return SyntheticMouseEvent.getPooled(nativeEvent, targetInst);
    }
    if (topLevelType === 'topMouseLeave') {
      // 如果是 mouseleave
      // 检查 relatedTarget
      // 如果 relatedTarget 不在当前节点的子树中,说明鼠标离开了当前节点
      return SyntheticMouseEvent.getPooled(nativeEvent, targetInst);
    }
  },
};

React 通过比较 nativeEvent.relatedTargettargetInst 的 Fiber 树关系,精确地判断出鼠标是进入了,还是离开了。这比原生的 mouseover 优雅得多。


第八回:ChangeEventPlugin 的“坑”——Input vs Change

表单事件也是归一化的一大难点。

input 事件和 change 事件,在浏览器里的行为简直是两个物种。

  • IE: input 事件在用户每次按键时都触发(非常频繁),change 事件在失去焦点时触发。
  • Safari/Chrome: input 事件在值改变时触发(包括通过键盘、粘贴、拖拽),change 事件在失去焦点时触发。

React 的 ChangeEventPlugin 做了什么?它做了一个过滤

如果你监听 onChange,React 会根据你当前选中的 <input> 类型,决定何时分发 change 事件。

  • 对于 <input type="checkbox"><input type="radio">:它会在 onChangeInput(即 input 事件)时触发。因为选中状态的变化通常很即时。
  • 对于 <input type="text">:它通常监听 onChange,但在内部处理时,它可能结合了 input 事件来检测内容变化,或者等待 change 事件(失焦)。

虽然具体的逻辑非常复杂,涉及大量的正则判断和浏览器嗅探,但核心思想只有一个:屏蔽差异,统一接口


第九回:代码实战——从源码看归一化流程

好了,理论讲得够多了,我们来看一段稍微真实的 React 源码片段,感受一下那种“黑客帝国”般的代码质感。

这是 React 处理事件分发的核心入口 dispatchEvent

// ReactEventListener.js (简化版)

function dispatchEvent(event) {
  // 1. 获取事件配置
  const dispatchConfig = event.dispatchConfig;

  // 2. 获取事件的目标 Fiber 节点
  const targetInst = event._targetInst;

  // 3. 开始传播
  // 如果是捕获阶段,先从 window 往下找
  if (dispatchConfig.capturePhase) {
    traverseTwoPhase(targetInst, event, dispatchConfig.captured);
  } else {
    // 如果是冒泡阶段,先从 target 往上找
    traverseTwoPhase(targetInst, event, dispatchConfig.bubbled);
  }
}

// 事件分发器
function executeDispatch(event, listener, domEventTarget) {
  // 如果 listener 返回 false,阻止传播
  const returnValue = listener(event, listener._currentElement);

  if (returnValue === false) {
    // 如果用户代码返回了 false,我们就阻止冒泡(类似于 e.stopPropagation())
    // 但这里有个坑:React 的 stopPropagation 实现比较特殊,它只是设置了一个标记
    // 真正的冒泡停止是在 traverseTwoPhase 里检查这个标记
    event.isPropagationStopped = function() {
      return true;
    };
  }
}

再看一下 EventPluginHub,它是怎么找到 listener 的:

// EventPluginHub.js

// 存储所有注册的监听器
let listenerBank = {};

function registerEvents(registrationName, domEventName) {
  // 这里的逻辑是将 DOM 事件名映射到 React 事件名
  // 比如 'click' -> ['onClick', 'onCaptureClick']
}

function executeDispatchesInOrder(event) {
  const dispatchListeners = event._dispatchListeners;
  // 遍历所有监听器
  for (let i = 0; i < dispatchListeners.length; i++) {
    const listener = dispatchListeners[i];
    if (listener) {
      executeDispatch(event, listener, event._currentTarget);
    }
  }
  // 清空监听器,防止内存泄漏
  event._dispatchListeners = null;
}

这段代码虽然简化了很多,但它揭示了真相:React 并没有直接把原生事件塞给你的组件。它做了一次翻译,一次打包,一次分发。


第十回:被动事件与性能优化

最后,我们要聊聊归一化带来的一个副作用:性能。

因为 React 统一了事件处理,它就可以对事件处理函数进行优化。最著名的优化就是 Passive Event Listeners(被动事件监听器)

在 Web 2D 游戏开发中,我们经常在滚动容器里监听 touchstartscroll,并且调用 e.preventDefault() 来阻止滚动。

但在原生 DOM 中,这会导致性能问题。浏览器必须等你的 JS 执行完 preventDefault 后,才能决定是否滚动页面。这会导致滚动卡顿。

React 的归一化层允许你标记事件监听器是“被动”的。

// React 组件内部
const { passive } = this.props;

// 注册事件时
ReactEventListener.ensureListeningTo(this, 'topScroll');
// 如果是 passive,告诉浏览器:“放心滚,我不会阻止默认行为”
ReactEventListener.usePassthroughEventRegistration(this, 'topScroll', passive);

如果 React 判断你的事件处理函数不会调用 preventDefault()(或者你显式标记了 passive),它就会把监听器注册为 passive: true

这样,浏览器在滚动时,就可以直接执行滚动逻辑,不需要等待 JS 脚本。这就是 React 16+ 以后性能提升的一个重要原因。


结语:归一化的艺术

好了,各位开发者,我们的讲座即将结束。

回顾一下,React 的浏览器兼容层——合成事件系统,它到底干了什么?

  1. 屏蔽差异: 它把 IE 的 srcElement 变成了标准的 target,把 mouseenter 变成了 mouseover 的模拟版。
  2. 聚合与归一: 它把 mousedown, mouseup, click 打包成 onClick,把 focusblur 变成可冒泡的 focusin
  3. 手动传播: 它抛弃了原生的冒泡机制,用自己维护的 Fiber 树手动实现冒泡和捕获,保证了顺序的绝对一致。
  4. 性能优化: 它通过 Passive Events 等机制,让事件处理更流畅。

这就是归一化的力量。它让 React 成为了一种“跨平台”的架构基础。当你未来使用 React Native 或 React Fiber 时,你会发现,这套归一化的事件逻辑,依然在底层默默工作,支撑着整个应用的交互。

所以,下次当你点击一个按钮,屏幕上弹出一个 Toast 的时候,不要只看到那个闪烁的动画。你应该看到,在内存深处,在成千上万行 C++ 和 JavaScript 混合的代码中,无数个合成事件对象正排队通过归一化管道,最终精准地落在你的 onClick 处理函数上。

这就是工程之美,这就是 React 的灵魂。

谢谢大家!

发表回复

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