各位前端界的同仁,还有那些立志要摸透 React 内部黑箱的勇士们,大家好。
今天我们不聊业务,不聊架构模式,也不聊如何用 React 写出“高大上”的组件库。今天,我们要钻进 React 的腹地,去解剖它的心脏——事件系统。
你可能会问:“不就是 onClick={handleClick} 吗?有什么好解剖的?” 哼,肤浅。你以为你只是在写代码,其实你是在和浏览器原生的 DOM API 打交道,而 React 在中间充当了一个极其复杂的“翻译官”和“中间人”。这个中间人叫 SyntheticEvent,也就是我们常说的“合成事件”。
如果你想让 React 支持一个它原本不认识的事件,比如 onConfettiExplosion(恭喜你,放彩带爆炸),你以为是加一行配置就完事了?不,那是做梦。你需要改源码,而且是动刀子地改。
那么,问题来了:如果要为 React 增加一个自定义的合成事件,你需要修改源码中的哪些插件注册表?这就像是在乐高城堡里加一座新的塔楼,你得知道地基在哪里,砖块在哪里,图纸在哪里。
来,把咖啡喝好,我们开始这场源码深潜。
一、 React 事件系统的“三巨头”
在动手之前,你得先理解 React 事件系统的架构。它不是凭空出来的,它是由三个核心模块组成的“三巨头”:
- EventPluginRegistry(事件插件注册表): 这是门卫。它负责登记所有的插件,告诉系统谁是谁。如果没有它,React 就不知道
onClick是谁负责的,onChange又是谁负责的。它是整个系统的“指挥中心”。 - EventPluginHub(事件插件枢纽): 这是大脑。它负责处理事件的核心逻辑,比如事件池的分配、事件的冒泡和捕获、以及把原生事件转换成合成事件。
- EventPluginModule(具体的插件): 这是干活的工人。比如
SimpleEventPlugin负责点击,ChangeEventPlugin负责输入框变化。每个插件都有自己的职责。
我们要做的自定义事件,本质上就是创造一个新的“工人”,然后把它塞进“门卫”的名单里。
二、 核心痛点:SimpleEventPlugin
既然我们要加自定义事件,那么绝大多数自定义事件(比如 onMyCustomEvent)都是直接映射到浏览器原生的某个事件上的。比如我们想监听 DOM 的 scroll 事件,我们就希望 React 能把 onScroll 映射过去。
这时候,SimpleEventPlugin 就是那个最需要被修改的插件。
为什么?因为它是 React 事件系统的“万恶之源”,也是“万善之源”。它定义了 React 如何将 DOM 的原生事件映射成 React 的合成事件。
在 React 源码的 packages/react-dom/src/events/SimpleEventPlugin.js 中,你会看到这样一个核心对象:
const topLevelEventsToReactNames = {
topAbort: 'onAbort',
topAbort: 'onAbort',
topAnimationEnd: 'onAnimationEnd',
topAnimationIteration: 'onAnimationIteration',
topAnimationStart: 'onAnimationStart',
topBlur: 'onBlur',
topCanPlay: 'onCanPlay',
topCanPlayThrough: 'onCanPlayThrough',
topChange: 'onChange',
topClick: 'onClick',
// ... 还有几百行类似的映射
topScroll: 'onScroll',
// ...
};
这就是注册表!这就是你要修改的地方。
如果你想加一个 onConfettiExplosion,你必须在 topLevelEventsToReactNames 里加一行:
const topLevelEventsToReactNames = {
// ... 现有的
topConfettiExplosion: 'onConfettiExplosion', // 我们的新朋友
};
但是,光加这一行还不够。这只是告诉系统“当 DOM 发生 topConfettiExplosion 时,React 的名字叫 onConfettiExplosion”。这就像你给新来的员工发了工牌,但他还没领工具呢。
三、 修改 EventPluginRegistry:插队与排序
光改 SimpleEventPlugin 还不够,你还得去 EventPluginRegistry 里把你的新插件“注册”进去。
源码位置:packages/react-dom/src/events/EventPluginRegistry.js。
这个文件里有一个至关重要的变量 EventPluginOrder。它定义了事件处理的顺序。React 必须按照这个顺序来调用插件,因为有些插件必须先于其他插件处理事件(比如 EnterLeaveEventPlugin 必须在 SimpleEventPlugin 之前处理,否则冒泡逻辑就乱了)。
// 这里的顺序是硬编码的,如果你加的插件依赖某些逻辑,必须调整这里
const EventPluginOrder = [
'ResponderEventPlugin',
'SimpleEventPlugin',
'EnterLeaveEventPlugin',
'ChangeEventPlugin',
'SelectEventPlugin',
'BeforeInputEventPlugin',
];
你需要在这里加上你新插件的名字。假设你的插件叫 MyCustomEventPlugin:
const EventPluginOrder = [
'ResponderEventPlugin',
'SimpleEventPlugin',
'EnterLeaveEventPlugin',
'ChangeEventPlugin',
'SelectEventPlugin',
'BeforeInputEventPlugin',
'MyCustomEventPlugin', // 欢迎加入,请站好队
];
四、 实战演练:手写一个“彩带爆炸”插件
理论讲得太多容易犯困,我们直接上代码。假设我们要监听一个自定义的 onConfettiExplosion 事件。
第一步:创建插件文件 MyCustomEventPlugin.js
这个插件需要实现 React 插件的标准接口:eventTypes(定义事件名)和 extractEvents(从 DOM 事件中提取合成事件)。
// packages/react-dom/src/events/MyCustomEventPlugin.js
import { SyntheticEvent } from 'react';
import { EventConstants } from 'fbjs/lib/EventConstants';
import { EventPropagators } from 'react-dom/lib/EventPropagators';
import { getNativeEventTarget } from 'react-dom/lib/getEventTarget';
// 1. 定义事件类型
const MyCustomEventInterface = {
bubbles: true,
cancelable: true,
composed: false,
};
// 2. 生成事件类型常量(React 内部用这个来校验)
const topLevelTypes = EventConstants.topLevelTypes;
// 3. 定义插件
const MyCustomEventPlugin = {
eventTypes: {
onConfettiExplosion: {
phasedRegistrationNames: {
bubbled: 'onConfettiExplosion',
captured: 'onConfettiExplosionCapture',
},
// 这里可以定义依赖关系,比如这个事件依赖于哪些原生事件
// dependencies: [topLevelTypes.topScroll],
},
},
// 4. 插件顺序(必须与 EventPluginRegistry 中的顺序一致)
extractEvents: function(
topLevelType: mixed,
targetInst: null | object,
nativeEvent: mixed,
nativeEventTarget: null | DOMEventTarget,
): null | ReactEvent {
// 只有当原生事件是我们定义的 topConfettiExplosion 时才处理
if (topLevelType === topLevelTypes.topConfettiExplosion) {
// 创建合成事件
// 注意:这里我们直接复用浏览器原生的 event 对象,或者创建一个新对象
// React 会通过 EventPluginHub 来管理这个对象的生命周期
const event = new SyntheticEvent(
MyCustomEventInterface,
nativeEvent,
nativeEventTarget,
);
// 这里可以添加一些自定义逻辑,比如判断是否真的需要爆炸
// if (shouldExplode(nativeEvent)) { ... }
return event;
}
return null;
},
};
export default MyCustomEventPlugin;
第二步:修改 EventPluginRegistry 注册表
正如前面所说,你必须把这个插件扔进 plugins 数组里。
在 packages/react-dom/src/events/EventPluginRegistry.js 中:
import MyCustomEventPlugin from './MyCustomEventPlugin';
// ... 在文件底部
const plugins = [
// ... 其他插件
MyCustomEventPlugin, // 挂上去
];
// ... 在 installPlugins 函数里
export function installPlugins() {
// ... 现有的逻辑
plugins.forEach(plugin => {
// 这里会遍历 plugins,调用每个插件的 install 方法
// 如果你的插件有 install 方法,这里就是入口
if (plugin.install) {
plugin.install();
}
});
}
第三步:修改 SimpleEventPlugin(关键中的关键)
现在,你的插件已经注册了,它也定义了 eventTypes。但是,React 在渲染的时候,是如何把 JSX 里的 onConfettiExplosion 绑定到 DOM 上的呢?
这就要回到 SimpleEventPlugin。虽然我们主要修改的是注册表,但 SimpleEventPlugin 负责了最繁琐的“映射工作”。
在 packages/react-dom/src/events/SimpleEventPlugin.js 的底部,有一个 registrationNameModules 对象。它记录了每个 React 事件名对应的是哪个插件。
const registrationNameModules = {
onClick: SimpleEventPlugin,
onChange: ChangeEventPlugin,
// ... 现有的
onConfettiExplosion: MyCustomEventPlugin, // 必须加上这一行!
};
这行代码的意思是:“嘿,如果有人用了 onConfettiExplosion,别去查浏览器原生事件了,去找 MyCustomEventPlugin 去提取事件。”
五、 深入 DOM 层:ReactBrowserEventEmitter
光有插件还不够。插件只负责“提取”和“处理”事件。当用户点击页面时,浏览器会触发原生的 click 事件,React 需要拦截这个事件,然后把它分发给对应的插件。
这个拦截工作发生在 ReactBrowserEventEmitter 中。
源码位置:packages/react-dom/src/events/ReactBrowserEventEmitter.js。
这里定义了 React 如何在顶层 DOM 节点上绑定监听器。默认情况下,React 会把所有事件绑定在 document 上,这就是著名的事件委托。
如果你想监听一个自定义事件,你需要确保这个自定义事件对应的原生事件被 React 的监听器捕获了。
在 ReactBrowserEventEmitter.js 中,你会看到 eventTypes 的定义。虽然大部分是 SimpleEventPlugin 定义的,但你需要确认你的插件是否正确地注册了 dependencies。
如果你的 MyCustomEventPlugin 在 eventTypes 里定义了 dependencies: [topLevelTypes.topConfettiExplosion],那么 React 会自动在 document 上监听 topConfettiExplosion(也就是浏览器原生的 confettiexplosion 事件,注意浏览器通常不支持这种自定义事件,你需要用 dispatchEvent 手动触发)。
六、 事件池:内存的艺术
好了,现在你的插件已经写好了,注册表也改了。当你写代码的时候:
function App() {
return (
<div onConfettiExplosion={() => alert("Boom!")}>
Click me to explode!
</div>
);
}
React 会怎么做?它会调用 MyCustomEventPlugin.extractEvents,创建一个合成事件对象,然后通过 EventPluginHub 的 accumulateTwoPhaseDispatches 方法,把这个事件分配给所有匹配的组件。
这里有一个非常重要的机制:Event Pooling(事件池)。
为了性能,React 不会为每次点击都创建一个新的合成事件对象,它会从池子里拿出一个对象复用。
在你的 MyCustomEventPlugin.js 里,当你返回 new SyntheticEvent(...) 时,你需要注意这个对象的生命周期。React 在处理完事件后,会调用 event.reset() 把对象还原回池子。如果你在事件处理函数里保存了 event.target 的引用,而没有在 reset 之前把它拷贝出来,你的引用就会变成 null 或者指向错误的元素。
这是 React 源码中最容易踩坑的地方之一。
七、 总结:你需要改哪些文件?
好了,现在让我们把散落在各处的线索串起来。如果你想给 React 加一个自定义事件,你需要修改(或创建)以下文件:
-
创建插件文件:
packages/react-dom/src/events/MyCustomEventPlugin.js。- 实现
eventTypes。 - 实现
extractEvents。 - 实现插件的安装逻辑(如果需要)。
- 实现
-
修改注册表(EventPluginRegistry):
packages/react-dom/src/events/EventPluginRegistry.js。- 在
EventPluginOrder数组中插入你的插件名字。 - 在
plugins数组中导入并插入你的插件实例。
- 在
-
修改 SimpleEventPlugin(映射表):
packages/react-dom/src/events/SimpleEventPlugin.js。- 在
topLevelEventsToReactNames中添加映射(如果是映射原生事件)。 - 在
registrationNameModules中添加映射(告诉 React 你的插件负责哪个事件名)。
- 在
-
修改 DOM 监听配置(可选):
packages/react-dom/src/events/ReactBrowserEventEmitter.js。- 确保你的插件依赖的原生事件被正确监听。
八、 进阶:EnterLeaveEventPlugin 的陷阱
如果你要加的不仅仅是普通事件,而是像 onMouseEnter 这样的逻辑事件,情况会更复杂。
MouseEnter 和 MouseLeave 是 DOM 原生不支持的事件,它们依赖父级元素和子级元素的冒泡机制。React 的 EnterLeaveEventPlugin 就是专门处理这个“人造”事件的。
如果你要加一个类似的事件,比如 onConfettiEnter,你必须深入研究 EnterLeaveEventPlugin 的 extractEvents 方法。它利用了 SimpleEventPlugin 的结果,通过 EventPropagators 来判断元素是否真的进入了或离开了目标区域。
这就像是给 React 增加了一个全新的语法糖,你需要理解 React 如何利用“冒泡”这个特性来模拟“进入”和“离开”。
九、 另一种选择:ReactDOM.createPortal 与自定义事件
其实,还有一种更简单的方法,不需要动源码。你可以创建一个自定义的 Hook。
function useConfetti() {
useEffect(() => {
const handler = () => console.log("Boom!");
document.addEventListener('confetti-explosion', handler);
return () => document.removeEventListener('confetti-explosion', handler);
}, []);
}
但是,这绕过了 React 的合成事件系统。这意味着你无法使用 e.stopPropagation(),也无法获得 React 合成事件的跨浏览器兼容性。对于追求极致性能和标准化的 React 项目来说,这是不可接受的。
十、 结语:源码中的“魔法”
写到这里,我们回顾一下。React 的事件系统之所以强大,是因为它把所有浏览器的不一致都封装了起来。它把复杂的 DOM 事件映射成了一个统一、干净的 SyntheticEvent。
当你修改那些插件注册表时,你其实是在修改 React 的“宪法”。你把一个陌生的浏览器 API,翻译成了 React 专用的语言。这就是 React 框架的魅力所在——它不仅仅是一个库,它是一个精心设计的、有生命的系统。
如果你能熟练地在 SimpleEventPlugin 里加一行配置,在 EventPluginRegistry 里插一个队,你就能真正理解 React 是如何工作的。这不仅仅是修改代码,这是在理解计算机科学中最迷人的部分之一:抽象。
好了,现在去你的项目里,试着给 React 加一个 onConfettiExplosion 吧。别把它搞炸了,但一定要搞懂它是怎么工作的。祝你好运,勇士!