各位同学,大家好!
欢迎来到今天这场关于 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 决定:我要发明一种我自己的事件系统,叫“合成事件”。
这个系统有两个核心目标:
- 跨浏览器一致性:不管底层浏览器怎么变,React 给你返回的一定是一个标准的
SyntheticEvent对象。 - 性能优化:它不是真的在每一个 DOM 节点上绑定监听器(那样内存会爆),而是把监听器统一绑定在最外层的容器上,靠“事件委托”来干活。
但是,怎么把 React 的 onClick 映射到底层浏览器的 click 怎么办?这就引出了我们的核心——插件注册表。
第二章:EventPluginRegistry —— 听起来像是个兽医诊所,其实是翻译官
在 React 的源码里,EventPluginRegistry 是一个极其重要的单例。它的主要工作就是翻译。
React 定义了一套标准的事件名,比如 onClick,onDoubleClick,onScroll。但是浏览器只认识 click,dblclick,scroll。那么,谁来建立这个映射关系?谁告诉 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。这个函数会做三件事:
- 识别事件类型:是 click?还是 scroll?
- 提取合成事件:调用所有插件的
extractEvents方法,收集所有相关的合成事件对象。 - 分发事件:把这些合成事件交给
EventPropagators去传播。
第四章:EventPropagators —— 冒泡与捕获的华尔兹
现在,我们有了事件,也有了监听器。接下来,事件要开始“流动”了。这就是 EventPropagators 的舞台。
React 的事件流分为两个阶段:
- 捕获阶段:从 Window 到 Document,再到父元素,最后到目标元素。
- 冒泡阶段:从目标元素,一直冒泡到 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。
- 捕获阶段:React 在
root拦截事件。它发现root没有监听器,往下找A。A有监听器!执行 A 的回调。继续往下找B。B有监听器!执行 B 的回调。 - 目标阶段:到达
B。 - 冒泡阶段:
B处理完,往回找A。A处理完,往回找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 必须去检查 Parent 的 onClick 是否被禁用了。
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}> 上点击了鼠标。
-
初始化:
- React 渲染
button。 EventPluginRegistry已经注册了SimpleEventPlugin。EventPluginHub.listenTo('onClick', buttonDOMElement)被调用。- 注册表查到
onClick依赖click。 buttonDOMElement.addEventListener('click', handleTopLevelEvent, false)被执行。handleTopLevelEvent被存入listenerBank。
- React 渲染
-
用户交互:
- 用户手指按下,松开。
- 浏览器发出原生
click事件。
-
顶层处理:
handleTopLevelEvent被触发。- React 提取出
topLevelType='click'。 - React 找到目标组件实例
buttonInstance。
-
事件提取:
- React 遍历所有插件。
SimpleEventPlugin.extractEvents('click', buttonInstance, nativeEvent, target)被调用。SimpleEventPlugin创建了一个SyntheticEvent对象,并返回。
-
分发与传播:
EventPropagators.accumulateTwoPhaseDispatches(syntheticEvent)被调用。- 捕获阶段:
- React 向上查找父节点。
- 找到父节点,检查
hasListener(parent, 'onClick')-> True。 - 找到
SimpleEventPlugin,执行pluginModule.executeDispatch。 buttonInstance.onClick(即你的handleClick)被调用。
- 冒泡阶段:
- React 向下查找子节点(如果有)。
- 回到
buttonInstance。 - 再次执行
buttonInstance.onClick。 - 最后回到
document,清理。
-
清理:
- 合成事件对象被销毁(不可复用)。
第八章:总结与吐槽
好了,各位同学,今天的讲座就到这里。
我们聊了 React 合成事件系统,特别是那个默默无闻却至关重要的 EventPluginRegistry(插件注册表)。
它不仅仅是一个字典,它是 React 事件系统的基石。
- 它负责翻译(React 事件名 <-> 原生事件名)。
- 它负责分发(决定哪个插件处理什么事件)。
- 它配合 EventPluginHub 和 EventPropagators,构建了一个高性能、跨浏览器的统一事件层。
React 的设计哲学在这里体现得淋漓尽致:封装复杂性,暴露简单接口。
你不需要知道 addEventListener,不需要知道 event.stopPropagation(虽然你还可以用,但通常不需要),不需要知道冒泡捕获的底层逻辑。你只需要写 onClick,React 会处理好剩下的一切。
但是,当你遇到奇怪的事件行为,或者为了极致的性能优化时,了解这个“幕后黑手”会让你受益匪浅。
最后,给各位留个作业:
去读读 React 源码里的 EventPluginRegistry.js 和 EventPluginHub.js。别被那一堆 Object.freeze 和 in 操作符吓到。你会发现,这些代码虽然老(React 15/16 时期写的),但设计得非常优雅,充满了工程美感。
好了,下课!记得把你的 e.persist() 丢掉,现在我们用合成事件,它不存在的!