各位同学好,欢迎大家来到今天这场名为“React 内核八卦专场”的讲座。我是你们的老朋友,一个既喜欢写代码,又喜欢给代码泼冷水的资深 React 架构师。
今天我们不聊怎么用 useEffect,也不聊怎么封装一个好看的 Modal。今天我们要扒开 React 那层金光闪闪的“Hello World”外衣,去看看它底下的那个精密、复杂,甚至有点让人头疼的“机械心脏”——事件系统。
尤其是那个传说中负责调度、匹配、派发的“总管家”——EventPluginHub。大家平时写代码,只是 onClick={() => {}},然后 React 自动就运行了。这中间发生了什么?是不是像变魔术一样?不,这不是魔术,这是计算机科学的胜利,是注册表、委托模式、冒泡机制的完美结合。
来,搬个小板凳,我们开始。
第一章:浏览器是个渣男,React 是个护花使者
在讲注册表之前,我们必须得谈谈为什么 React 要搞这么一套复杂的东西。你想想,浏览器原生的事件系统是什么样的?
这就好比你的女朋友(DOM 元素)和隔壁老王(浏览器)在吵架。浏览器说:“哎呀,这事儿我管不了,我也没看见。” 然后事件就没了。或者浏览器说:“我是 IE,我是古董,我不支持这个功能,你要我干嘛?”
更过分的是,不同浏览器对事件的支持简直是个大杂烩。IE 里有个 mouseenter,标准浏览器里没有,你得手写 mouseover 加逻辑判断;再比如 event.target,在 Safari 里,点击一个 text node,它可能指向那个 text node,但在别的浏览器里,它可能直接指到 <div>。
React 看着这一团糟,心想:“我不管,我 React 就是要给你一个统一的世界。你在 React 里 onClick,我就给你发一个标准的 SyntheticEvent。不管底层是 IE 还是 Chrome,我都给你包装好。”
这就是合成事件的概念。但是,怎么包装?怎么把浏览器那个乱七八糟的 click 事件,精准地找到你写在组件里的那个 onClick 函数?这就需要我们的主角登场了。
第二章:EventPluginRegistry —— 餐厅的菜单注册处
想象一下,这是一个大餐厅。React 就是老板,负责点菜(渲染组件)。但问题是,餐厅里菜单太多,菜系太杂。
这时候,我们需要一个EventPluginRegistry。它的作用就是告诉系统:“嘿,兄弟们,我们要支持哪些事件?”
在 React 源码中,所有的事件插件都被注册在这里。它维护了一个巨大的字典:plugins。
让我们看一段极其简化的注册表逻辑(伪代码):
// EventPluginRegistry.js 的核心部分
const EventPluginRegistry = {
// 所有的插件列表,按优先级排序
plugins: [
EventPluginHub.SimulatedPlugin, // 比如 EnterLeavePlugin
EventPluginHub.SimpleEventPlugin, // 比如 ClickPlugin
EventPluginHub.ChangeEventPlugin, // 比如 InputPlugin
EventPluginHub.SelectEventPlugin,
EventPluginHub.BeforeInputEventPlugin
],
// 这是一个大杀器:注册名称依赖映射表
// 作用:当你写 onClick 时,系统要知道,这个 onClick 实际上监听的是浏览器的 click 事件
// 而且可能还依赖了 onClickCapture(捕获阶段)
registrationNameDependencies: {
onClick: ['onClickCapture', 'onClick'],
onMouseEnter: ['onClickCapture'], // 这是个特例,后面细说
// ... 更多映射
}
};
这个 registrationNameDependencies 是核心中的核心。它定义了“React 事件名”和“浏览器原生事件名”的关系。
比如,当你写:
function App() {
return <button onClick={handleClick}>Click me</button>
}
registrationNameDependencies 会告诉你:onClick 这个名字,它的“真身”是浏览器的 click 事件,并且它有两阶段:捕获阶段 onClickCapture 和 冒泡阶段 onClick。
所有的插件都在这个注册表里报到了。注册表就像是一个“点名册”,谁来了,报个到,告诉我你的特长是什么。
第三章:EventPluginHub —— 中央指挥所
好了,现在我们知道了注册表里有谁。但光知道有谁没用,得有人干活。
这就轮到了我们的主角——EventPluginHub。如果说注册表是“菜单”,那 Hub 就是“后厨”或者“中央指挥所”。
当用户点击屏幕的那一刻,DOM 元素触发了浏览器的 click 事件。React 捕获到这个事件后,会干什么呢?它会调用 Hub 里的一个核心函数:accumulateTwoPhaseDispatches。
这个函数是干嘛的呢?它的任务只有一个:根据当前的事件类型(比如 click),去翻注册表,找到所有可能用到这个事件的处理函数,然后把它们排列好队形。
让我们深入剖析这个函数的内部逻辑。
3.1 遍历树:从叶子到树干
React 的事件系统是“委托”的。也就是说,React 并没有给每个 DOM 元素都挂载监听器,它只挂载在最外层(比如 root)。当事件发生时,React 通过冒泡/捕获机制,一路向上查找。
Hub 的第一个任务就是:确定我要调谁?
假设你有一个组件树:
<div onClick={handleDivClick}> // 层级 1
<div onClick={handleInnerClick}> // 层级 2
<button onClick={handleBtnClick}> // 层级 3
Click
</button>
</div>
</div>
当你在层级 3 点击 button 时,Hub 需要遍历这棵树。它怎么遍历?它使用 EventPluginUtils 里的工具函数 traverseTwoPhase。
这个函数会做两遍遍历:
- 捕获阶段:从根节点往下,遍历到目标节点(button)。它在每一层都会问:“嘿,注册表里有没有说,这个节点上有监听这个事件的函数?”
- 冒泡阶段:从目标节点往上,遍历到根节点。它继续问:“嘿,注册表里有没有说,这个节点上有监听这个事件的函数?”
3.2 匹配:从“用户意图”到“处理器”
Hub 手里拿着两个东西:
- 当前的事件类型(比如
click)。 - 当前遍历到的 DOM 节点。
对于每个节点,Hub 会去查这个节点上挂载的 React 处理器。React 有个很骚的设计,它会把你的函数存到一个对象里,比如:
// React 内部存储结构(简化)
{
instance: buttonInstance,
props: {
onClick: handleClick // 这里是你在 JSX 里写的那个箭头函数
}
}
Hub 拿到 onClick 这个字符串,就去 EventPluginRegistry 的 registrationNameDependencies 里查。
// Hub 的内心独白
if (event.type === 'click') {
if (node.props.onClick) {
// 好的,找到了!这个 onClick 依赖 'click' 和 'onClickCapture'
// 我得把它加入队列
}
}
这里有个非常微妙的点:处理器的执行顺序。
Hub 不会乱序执行,它得按顺序来。它把所有捕获阶段符合条件的事件处理器扔进一个数组 dispatchQueue,然后再把所有冒泡阶段符合条件的事件处理器扔进这个数组。
所以最终的 dispatchQueue 可能长这样:
[
{ instance: handleDivClick, listener: handleDivClick }, // 捕获层
{ instance: handleInnerClick, listener: handleInnerClick }, // 捕获层
{ instance: handleBtnClick, listener: handleBtnClick }, // 捕获层 (目标)
{ instance: handleBtnClick, listener: handleBtnClick }, // 冒泡层 (目标)
{ instance: handleInnerClick, listener: handleInnerClick }, // 冒泡层
{ instance: handleDivClick, listener: handleDivClick } // 冒泡层
]
3.3 代码示例:Hub 的核心实现
为了让大家更直观地感受到 Hub 的逻辑,我们用伪代码模拟一下 accumulateTwoPhaseDispatches 是如何工作的:
// EventPluginHub.js 的简化逻辑
function accumulateTwoPhaseDispatches(event) {
// 1. 从目标节点开始向上遍历
const targetNode = event.target;
const path = getAncestors(targetNode); // 获取所有祖先节点,包括 target 本身
// 2. 准备两个队列:捕获阶段 和 冒泡阶段
const capturePhaseQueue = [];
const bubblePhaseQueue = [];
// 3. 遍历整个路径
for (let i = 0; i < path.length; i++) {
const node = path[i];
const { instance, props } = node; // React 节点对象
// 4. 对于每个节点,我们检查所有注册的事件类型
// 这里为了演示简化,只看 onClick
const onClickHandler = props.onClick;
// 5. 检查注册表,看看这个 onClick 依赖什么浏览器事件
const dependencies = EventPluginRegistry.registrationNameDependencies.onClick;
// 6. 如果当前触发的浏览器事件类型在依赖列表里
if (dependencies.includes(event.type)) {
if (i === path.length - 1) {
// 如果是最后一个节点(即 target 本身),放入冒泡队列
bubblePhaseQueue.push({ instance, listener: onClickHandler });
} else {
// 否则放入捕获队列(因为遍历是从上往下的)
capturePhaseQueue.push({ instance, listener: onClickHandler });
}
}
}
// 7. 拼接队列:先捕获,后冒泡
event._dispatchListeners = capturePhaseQueue.concat(bubblePhaseQueue);
event._dispatchInstances = capturePhaseQueue.map(node => node.instance).concat(bubblePhaseQueue.map(node => node.instance));
}
看到没?这就是 Hub 的核心逻辑。它把“事件触发”和“处理器匹配”解耦了。它不关心你的 handler 里写了什么,它只负责把该跑的函数都找出来排好队。
第四章:事件插件们的“特长表演”
Hub 负责发号施令,那具体谁来执行匹配呢?这就是各个Event Plugin 的任务。
React 里有好几个插件,每个插件都有自己的绝活。Hub 会根据事件类型,去问:“嘿,谁认识 click 事件?”
4.1 SimpleEventPlugin:最普通的插件
这是最常用的插件。它的工作就是把浏览器的标准事件映射到 React 的标准事件。
浏览器事件:click, input, change, submit, focus, blur, keydown, keyup 等。
React 事件:onClick, onInput, onChange, onSubmit, onFocus, onBlur, onKeyDown, onKeyUp 等。
它的代码非常简单,就是一个配置映射:
// SimpleEventPlugin.js 的核心映射
const simpleEventPlugins = {
click: {
phasedRegistrationNames: {
bubbled: 'onClick',
captured: 'onClickCapture',
},
dependencies: ['click', 'dblclick', 'contextmenu', 'dragstart', 'focusin', 'focusout'],
},
input: {
phasedRegistrationNames: {
bubbled: 'onInput',
captured: 'onInputCapture',
},
dependencies: ['input', 'textInput'],
},
// ... 更多映射
};
当你点击按钮时,SimpleEventPlugin 会告诉 Hub:“click 事件属于我,它支持 onClick 和 onClickCapture。” Hub 就会根据这个信息去调用上面的代码逻辑。
4.2 EnterLeaveEventPlugin:那个最聪明的插件
这个插件有点意思。你知道在 HTML 里,没有 mouseenter 和 mouseleave 这两个事件吗?标准 DOM 只有 mouseover 和 mouseout。
mouseover 的问题是:当你鼠标从父 div 移动到子 div 上的一瞬间,mouseover 事件会触发两次(一次在父 div,一次在子 div)。这会让你的 onMouseEnter 函数执行两次,简直令人抓狂。
EnterLeaveEventPlugin 就是来解决这个问题的。它的策略是:
- 它不监听
mouseover和mouseout。 - 它监听
click、focus、keydown、keyup这些通用事件。 - 当这些通用事件触发时,它检查
relatedTarget(相关元素)。
如果 event.relatedTarget 在 event.currentTarget 之外,且不包含 event.currentTarget,它就会伪造一个 mouseenter 事件。
看它的注册表映射:
const EnterLeavePlugin = {
eventTypes: {
mouseEnter: {
registrationName: 'onMouseEnter',
registrationDependencies: ['topMouseEnter', 'topMouseLeave'],
},
},
};
注意看 registrationDependencies。onMouseEnter 依赖 topMouseEnter 和 topMouseLeave。这些 top 前缀是 React 内部对浏览器事件的标准化命名。
当 Hub 收到浏览器的 mouseover 事件时,它会去找 EnterLeavePlugin。EnterLeavePlugin 会分析:哦,这其实是 mouseenter。于是 Hub 就会在调度队列里加上 onMouseEnter 的处理函数。
这就是为什么你在 React 里能安心地用 onMouseEnter 而不用担心 mouseover 的重复合规问题。这是一个典型的“智能代理”。
4.3 ChangeEventPlugin:那个被 InputEventPlugin 取代的插件
以前,input 事件和 change 事件是分开的。但是,现在的浏览器支持 input 事件了(IE9+)。
ChangeEventPlugin 负责把 input 事件映射到 onChange。而新版本的 BeforeInputEventPlugin 负责把 input 事件映射到 onBeforeInput。它们分工明确,互不干扰。
第五章:EventPluginUtils —— 基础设施的建设者
光有 Hub 还不行,Hub 需要工具。这个工具就是 EventPluginUtils。
它提供了一些极其底层的辅助函数,让 Hub 能够遍历 DOM 树。
最著名的就是 traverseTwoPhase 和 traverseEnterLeave。我们刚才提到的“从叶子到树干”的遍历逻辑,大部分都写在这里。
// EventPluginUtils.js
function traverseTwoPhase(inst, cb, arg) {
if (inst) {
cb(inst, 'topLevel', arg);
inst = inst.return;
}
// ... 一直往上走到 null
}
// 这个函数用来找 ancestor(祖先)
function getAncestors(node) {
// 这是一个栈操作,把 DOM 节点一层一层压栈
const ancestors = [];
let node = node;
while (node) {
ancestors.push(node);
node = node.return;
}
return ancestors;
}
你可以把 Hub 想象成一个大厨,EventPluginUtils 就是他的刀和案板。Hub 需要把食材(DOM 节点)切成片(处理函数),然后炒成菜(合成事件)。
第六章:合成事件 —— 那个完美的包装盒
所有的准备工作都做好了,队列也排好了。接下来,我们需要一个东西来承载这些信息,把这个丑陋的浏览器事件变成漂亮的 React 事件。这个东西叫 SyntheticEvent。
Hub 最后一步工作就是创建这个合成事件对象。
// 模拟创建合成事件
function createSyntheticEvent(event) {
const base = {
type: event.type, // 比如 'click'
target: event.target,
currentTarget: event.currentTarget, // 当前绑定的那个 DOM 元素
// ... 更多标准属性
};
// 关键点:拦截 nativeEvent
base.nativeEvent = event;
// 阻止冒泡的代理方法
const stopPropagation = () => {
event.stopPropagation(); // 调用原生事件的方法
base.isPropagationStopped = () => true; // 标记自己停止了
};
base.stopPropagation = stopPropagation;
return base;
}
然后,Hub 开始执行 dispatchQueue。
for (let i = 0; i < dispatchQueue.length; i++) {
const { instance, listener } = dispatchQueue[i];
// 调用你的 handler
if (instance) {
listener.call(instance, syntheticEvent);
} else {
listener.call(null, syntheticEvent);
}
}
注意看这里:listener.call(instance, syntheticEvent)。
这是 React 的一个关键设计。因为你的 handler 是写在 JSX 里的,比如 onClick={handleClick}。handleClick 通常是一个纯函数,或者箭头函数,它们可能没有绑定 this。
如果 handleClick 里使用了 this.setState,那 this 谁来传?
React 会把当前组件的实例(instance)传进去。
handleClick.call(componentInstance, syntheticEvent)。
这样,即使你的函数没有 bind(this),它也能正常访问 this.setState。
第七章:综合案例 —— 完整的调用链路
让我们把所有的线索串起来。假设你在屏幕上有一个点击事件。
步骤 1:用户点击
用户手指敲击屏幕,浏览器在 div#root 上触发了 click 事件。
步骤 2:React 捕获
React 监听器捕获到 click 事件。它调用了 accumulateTwoPhaseDispatches。
步骤 3:查找匹配
Hub 查询 EventPluginRegistry,发现 SimpleEventPlugin 注册了 click。
Hub 递归遍历 DOM 树,找到你的 <button onClick={handleSubmit}>。
步骤 4:构建队列
Hub 把 handleSubmit 加入捕获队列,又把 handleSubmit 加入冒泡队列。
步骤 5:插件介入
如果这时候鼠标刚好移进了子元素,Hub 还会去问 EnterLeavePlugin,发现这是个 mouseenter,于是把 onMouseEnter 的处理器也加入队列。
步骤 6:创建合成事件
Hub 创建了一个 SyntheticEvent 对象,把原生的事件对象塞了进去,并覆盖了 stopPropagation 等方法。
步骤 7:执行
Hub 开始执行队列里的函数。
- 捕获阶段:
handleSubmit在<Form>组件上被调用。 - 捕获阶段:
handleSubmit在<Input>组件上被调用。 - 目标阶段:
handleSubmit在<button>组件上被调用。 - 冒泡阶段:
handleSubmit在<button>组件上再次被调用。 - 冒泡阶段:
handleSubmit在<Input>组件上被调用。 - 冒泡阶段:
handleSubmit在<Form>组件上被调用。
这就是为什么 React 事件是“两阶段”的。
第八章:聊聊那些“坑”
讲了这么多机制,我们得聊聊这些机制带来的影响。
8.1 事件委托的性能
因为 React 只在根节点挂载事件,所以无论你的组件树多深,实际的事件监听器数量永远是固定的(比如 1 个 click 监听器)。
所有的匹配、查找工作都在 JS 层完成。这比给每个按钮都挂一个 addEventListener 要高效得多,尤其是在移动端。
8.2 e.target 的陷阱
在原生 JS 里,e.target 可能是 <div>,而 e.currentTarget 是 <button>。
在 React 里也是一样的。
但是,React 为了统一,把 e.target 指向了最底层的那个点击元素(通常是文本节点或具体的标签)。这使得 e.target 比 event.target 更稳定。
8.3 不支持 passive listeners
这是一个著名的争议点。React 的合成事件系统不支持浏览器的 passive: true 选项。
这会导致滚动性能在某些情况下不如原生。但是,为了保持跨浏览器的一致性和合成事件的灵活性,React 选择牺牲一部分性能。如果你需要极致的滚动性能,还得用原生的。
8.4 箭头函数的性能
如果你在 render 里面写 onClick={() => this.handleClick()},每次 render 都会创建一个新的函数实例。
EventPluginHub 虽然会检查这个函数是否变化,但如果组件频繁重渲染,这个开销还是存在的。所以,尽量把 handler 提取到 render 外面,或者用 useCallback。
第九章:总结与展望
好了,同学们,今天的讲座就要接近尾声了。
我们今天扒开了 React 事件系统的内核,看到了 EventPluginRegistry(菜单注册处)和 EventPluginHub(中央指挥所)是如何协作的。
我们了解到,React 的事件系统不是简单的“事件监听”,而是一个复杂的调度与匹配系统。
它通过注册表维护映射关系,通过Hub 动态构建执行队列,通过合成事件提供统一的 API,最终通过委托机制实现高性能的跨浏览器支持。
EventPluginHub 之所以复杂,是因为它需要处理极其多的边缘情况:
mouseenter不存在怎么办?focus和blur不冒泡怎么办?input事件在不同浏览器的表现不一致怎么办?stopPropagation怎么处理?e.persist()怎么处理?(虽然现在不推荐用)
它就像一个瑞士军刀,把所有这些乱七八糟的边缘情况都塞进了一个统一的流程里。
如果你能理解了 accumulateTwoPhaseDispatches 的工作原理,你就能理解 React 的很多高级用法。比如,为什么你不能在事件处理函数里修改 state 然后依赖下一次 render 里的 DOM 变化?因为事件处理函数是同步的,而 render 是异步的。再比如,为什么 ref 回调函数会在 onMouseEnter 之后触发?因为那是遍历队列的顺序。
最后,我想说,React 的事件系统虽然庞大,但它的设计哲学是极其优雅的。它把复杂留给内部,把简单留给开发者。
希望大家以后写 onClick 的时候,不要只觉得它是一个简单的属性,而要看到它背后那无数行代码的精密运转。那是一个为了给你一个稳定世界而默默工作的英雄。
好了,今天的课就上到这里。下课!大家可以回去自己下载源码,跑一遍 EventPluginHub 的测试用例,记得,要带脑子,别光动手不动脑。
谢谢大家!