各位未来的架构师,大家好!
今天我们不谈业务,不谈代码风格,我们要深入 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。它主要由三个核心模块组成,就像一个豪华的三人乐队:
- EventPluginHub(指挥家): 负责管理事件监听器的注册、分发,以及那个核心的数据结构
EventPluginHub。 - EventPluginRegistry(乐谱编辑器): 负责把各种事件类型(比如
click,focus,submit)映射到具体的处理逻辑上。 - EventPropagators(演奏家): 负责事件的冒泡和捕获传播。
今天我们的重点是归一化,也就是 EventPluginRegistry 和 EventPluginHub 在幕后干了什么。
第三回:归一化的魔法——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>
当你点击这个按钮时,原生浏览器会发生什么?
mousedown在按钮上触发。mouseup在按钮上触发。click在按钮上触发。click在div上触发。click在body上触发。- …
React 怎么做?
React 会把所有这些事件收集到一个队列里。
- 它给
button绑定了topMouseDown,topMouseUp,topClick。 - 它给
div绑定了topMouseDown,topMouseUp,topClick。 - 当你点击按钮时,React 会收到这一串原生事件。
- 关键逻辑: 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 之前触发 onMouseDown 和 onMouseUp。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 规范里,没有 mouseenter 和 mouseleave 事件。
为什么?因为 mouseover 和 mouseout 会一直冒泡。如果你从 div 移动到 button,mouseover 会触发 div,然后触发 button,然后触发 body……直到 html。
这太蠢了。我们通常只需要知道“鼠标进入”和“鼠标离开”当前元素,而不是它的所有祖先。
React 怎么办?造一个!
这就是 EnterLeaveEventPlugin 的作用。
它的核心逻辑是:利用 topMouseOver 和 topMouseLeave 事件,结合 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.relatedTarget 和 targetInst 的 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 游戏开发中,我们经常在滚动容器里监听 touchstart 或 scroll,并且调用 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 的浏览器兼容层——合成事件系统,它到底干了什么?
- 屏蔽差异: 它把 IE 的
srcElement变成了标准的target,把mouseenter变成了mouseover的模拟版。 - 聚合与归一: 它把
mousedown,mouseup,click打包成onClick,把focus和blur变成可冒泡的focusin。 - 手动传播: 它抛弃了原生的冒泡机制,用自己维护的 Fiber 树手动实现冒泡和捕获,保证了顺序的绝对一致。
- 性能优化: 它通过 Passive Events 等机制,让事件处理更流畅。
这就是归一化的力量。它让 React 成为了一种“跨平台”的架构基础。当你未来使用 React Native 或 React Fiber 时,你会发现,这套归一化的事件逻辑,依然在底层默默工作,支撑着整个应用的交互。
所以,下次当你点击一个按钮,屏幕上弹出一个 Toast 的时候,不要只看到那个闪烁的动画。你应该看到,在内存深处,在成千上万行 C++ 和 JavaScript 混合的代码中,无数个合成事件对象正排队通过归一化管道,最终精准地落在你的 onClick 处理函数上。
这就是工程之美,这就是 React 的灵魂。
谢谢大家!