大家好,欢迎来到今天的“React 内部黑魔法”系列讲座。我是你们的讲师,一个在代码堆里摸爬滚打多年的“资深编程专家”。
今天我们要聊的话题,非常有意思,也非常硬核。它关乎 React 作为一个框架的“灵魂”之一——事件系统。
想象一下,如果你的应用里有一个巨大的表格,里面有一万行数据,每一行都有一个“删除”按钮。如果你是个新手,你可能会想:“嗨,简单!我在渲染那一万行的时候,给每个按钮都绑一个 onClick 事件不就行了?”
别这么做!千万别这么做!如果你真的这么干了,你的浏览器会哭着对你说:“我内存不够了,我要罢工!”
那么,React 是怎么解决这个“监听器大屠杀”问题的呢?答案就是——事件委托。
今天,我们就来扒一扒 React 的合成事件委托协议,看看它是如何把成千上万个监听器“打包”成一个,统一挂载到 Root 容器上的。
第一章:原生 DOM 的“混乱派对”
在讲 React 之前,我们先看看如果不加处理,原生 JavaScript 是怎么搞的。
假设你有一个简单的 HTML 结构:
<div id="root">
<button>点我</button>
<button>点我</button>
<button>点我</button>
</div>
如果你在 JS 里这么写:
const buttons = document.querySelectorAll('button');
buttons.forEach(btn => {
btn.addEventListener('click', (e) => {
console.log('按钮被点击了!');
});
});
看起来没问题?确实没问题。但是,随着你的应用变大,你的 DOM 树可能会变得像一棵乱糟糟的圣诞树。如果你有一个包含 5000 个子元素的列表,每个子元素都有 5 个事件监听器,那你的浏览器内存里就多了 25,000 个函数引用。
浏览器处理事件是靠“冒泡”的。这意味着,当你点击最底下的那个 button 时,事件会一层层往上跑:button -> li -> ul -> div -> body -> html -> document -> window。
在这个漫长的旅途中,这 25,000 个监听器都要被浏览器遍历一遍。这效率,简直是在侮辱浏览器的性能。
第二章:React 的“特工”哲学
React 早就看穿了一切。它的核心思想是:不要给每个元素都挂监听器,给它们的“房东”——也就是根容器,挂一个监听器!
这就是事件委托。
React 的做法是:
- 它不关心你点的是
div、span还是img。 - 它在根节点(
rootContainer)上挂载了一个超级监听器。 - 当事件冒泡上来时,React 的“特工”会接过话筒,仔细核对:“咦?这个事件是针对谁的?哦,是针对那个
button的。好,我来处理。”
这就像在公司里,老板(React)在会议室(Root)里。员工(DOM 节点)有无数个,但老板只需要坐在会议室里听汇报。谁有事就喊一声,老板就能知道是谁。
第三章:挂载阶段——谁是“房东”?
这一切始于你的应用启动。当你调用 ReactDOM.render 的时候,React 正在忙活什么呢?
它正在忙着把你的组件树翻译成 DOM 树。在这个过程中,React 会调用一个核心方法:listenToAllSupportedEvents。
这个方法听起来很高大上,其实就是个“扫楼”的。它遍历所有支持的事件类型(比如 onClick、onMouseEnter、onSubmit 等),然后针对每一种事件类型,去扫描你的 DOM 树。
但是!重点来了!
React 扫描 DOM 树,是为了注册,而不是为了绑定。
它不会给每个 DOM 节点都加 addEventListener。它只是在内部记录下来:“嘿,这个 div 上有 onClick,那个 input 上有 onSubmit。”
然后,当整个渲染过程结束,React 会找到这个组件树的根节点(也就是你传给 render 的那个容器,比如 <div id="root">),在这个根节点上,只挂载一个事件监听器。
让我们看看源码里是怎么干的(简化版):
// ReactRenderer.js (伪代码)
function render(element, container) {
// 1. 构建 fiber 树
const rootFiber = createFiberRoot(container);
// 2. 关键步骤:挂载所有支持的事件监听器到 Root 容器上
listenToAllSupportedEvents(container);
// 3. 开始调度更新
scheduleUpdateOnFiber(rootFiber, element);
}
那么,listenToAllSupportedEvents 到底干了啥?
// EventPluginHub.js (伪代码)
function listenToAllSupportedEvents(container) {
// 获取所有注册的事件名,比如 ['click', 'change', 'focus', ...]
const registrationNames = EventPluginRegistry.registrationNameModules;
for (const registrationName in registrationNames) {
// 针对每个事件名,执行绑定逻辑
if (registrationName) {
// 比如 'onClick' 对应的是 SimpleEventPlugin
const Module = registrationNames[registrationName];
Module.installAllEvents(container);
}
}
}
这里有个关键点:Registration Name Modules。
React 把不同的事件(比如点击、输入、焦点)分成了不同的“插件模块”。SimpleEventPlugin 管 onClick,ChangeEventPlugin 管 onChange,EnterLeaveEventPlugin 管 onMouseEnter。
这些模块都有一个 installAllEvents 方法,它的核心逻辑是调用 trapBubbledEvent 和 trapCapturedEvent。
第四章:Root 上的“超级特工”
现在,我们要把目光聚焦到 trapBubbledEvent 这个函数上。这是事件委托的核心代码。
React 会遍历所有支持的事件类型,然后在 container(你的 Root 容器)上,注册这些事件。
// DOMEventPlugin.js (伪代码)
function trapBubbledEvent(rootContainer, registrationName, domEventName) {
// 获取原生事件名,比如 'click' -> 'onclick'
const listener = dispatchEvent.bind(null, registrationName);
// 在 Root 容器上添加监听器
// 注意:这里只加了一次!
rootContainer.addEventListener(domEventName, listener, false);
}
看懂了吗?无论你的应用里有几万个组件,这里只会执行一次 addEventListener。
这个 listener 函数,就是我们前面说的“超级特工”。它接收原生事件对象 nativeEvent,然后进行一系列复杂的处理,最后决定是否调用你写的 onClick 函数。
第五章:触发阶段——冒泡的“翻译官”
好了,现在“房东”已经挂好了监听器。接下来,我们来看看用户点击了一个按钮,到底发生了什么?
假设你有一个组件结构:
<MyComponent>
<div onClick={handleDivClick}>
<button onClick={handleButtonClick}>提交</button>
</div>
</MyComponent>
- 用户点击:鼠标点击了
<button>。 - 原生事件触发:浏览器生成了一个
MouseEvent,并且开始冒泡。 - 到达 Root:事件一路向上,最终到达了挂载在
#root上的 React 监听器。 - dispatchEvent:React 的调度中心启动了。
// SyntheticEvent.js (核心逻辑)
function dispatchEvent(registrationName, nativeEvent) {
// 1. 创建合成事件对象
const syntheticEvent = SyntheticEvent.pooledCreate(nativeEvent);
// 2. 获取事件目标
// React 会把事件目标修剪一下,确保我们拿到的就是 React 组件对应的 DOM 节点
const target = nativeEvent.target;
// 3. 构建冒泡路径
// 这一步非常关键!
const eventPath = accumulateTargetAndTreeAncestors(target);
// 4. 遍历冒泡路径,分发事件
for (let i = eventPath.length - 1; i >= 0; i--) {
const pathNode = eventPath[i];
// 5. 获取这个 DOM 节点对应的 React Fiber 节点
const fiber = findFiberFromHostNode(pathNode);
// 6. 找到这个 Fiber 节点上的回调函数
const component = findInstanceByFiber(fiber);
const listener = component[registrationName];
if (listener) {
// 7. 执行回调!
listener(syntheticEvent);
}
}
// 8. 回收合成事件对象
SyntheticEvent.release(syntheticEvent);
}
这段代码展示了 React 事件委托的精髓。
1. accumulateTargetAndTreeAncestors:
React 不会傻傻地只去读 nativeEvent.target。它会手动构建一个冒泡路径数组。比如 [button, div, MyComponent]。这个数组是按照从下到上(从叶子到根)的顺序排列的。
2. findFiberFromHostNode:
这是 React 的“翻译官”。DOM 节点只是树上的叶子,React 需要找到它对应的 Fiber 节点(内部的数据结构),这样才能知道这个 DOM 属于哪个组件实例,以及这个组件上绑了什么回调。
3. dispatch:
React 遍历这个冒泡路径数组。如果是 button,它找到对应的组件实例,取出 onClick 回调执行;如果是 div,它也找对应的组件实例,取出 onClick 回调执行。
这就实现了委托的效果:虽然监听器在 Root 上,但 React 准确地知道事件发生在哪里,并按顺序通知了所有经过的组件。
第六章:事件池——内存的魔法
如果你仔细观察 React 的源码,你会发现 syntheticEvent 对象有一个很奇怪的特性:它是可复用的。
当你写一个事件处理函数时:
function handleClick(e) {
console.log(e.type); // 'click'
console.log(e.target); // <button>
// ... 做一些操作
// 然后你访问 e.persist(),或者仅仅是访问了 e 的属性
}
React 并没有每次都创建一个新的 Event 对象。它维护了一个事件池。
当你点击时,React 从池子里取出一个 SyntheticEvent 对象,用完之后,它不会销毁这个对象,而是把它放回池子,并调用 reset() 方法把里面的数据清空。
这样做有两个目的:
- 性能:避免频繁的垃圾回收(GC)。创建对象和销毁对象是很耗性能的,尤其是高频事件。
- 防误触:如果你在事件处理函数里没有手动调用
e.persist(),React 会把这个对象锁住,防止你在回调函数执行完之后,再去访问这个对象,导致拿到的是“过期的数据”。
// SyntheticEvent.js
class SyntheticEvent {
constructor(dispatchConfig, targetInst, nativeEvent) {
this.dispatchConfig = dispatchConfig;
this._targetInst = targetInst;
this.nativeEvent = nativeEvent;
// ...
}
// ... 其他方法
// 重置方法,把属性全部设为 null
reset() {
this.dispatchConfig = null;
this._targetInst = null;
this.nativeEvent = null;
// ... 其他属性
}
}
// 池子
const eventPool = [];
SyntheticEvent.pooledCreate = function(nativeEvent) {
if (eventPool.length) {
const event = eventPool.pop();
event.reset();
event.nativeEvent = nativeEvent;
return event;
}
return new SyntheticEvent(null, null, nativeEvent);
};
SyntheticEvent.release = function(event) {
eventPool.push(event);
};
第七章:实战演练——手写一个迷你 React
光说不练假把式。为了让你彻底理解,我们来手写一个简化版的 React 事件系统。
注意,这只是一个概念验证,不是生产代码,但逻辑是通的。
class MiniReact {
constructor() {
this.root = null;
this.listeners = {}; // 存储所有注册的事件监听器
}
// 1. 注册事件监听器到 Root
// 这对应了源码中的 trapBubbledEvent
bindEvents(root, domEventName, handler) {
if (!this.listeners[domEventName]) {
// 如果这个事件类型还没注册过监听器,就注册一个
root.addEventListener(domEventName, (e) => {
this.handleEvent(e, domEventName, handler);
});
this.listeners[domEventName] = true;
}
}
// 2. 处理事件(核心逻辑)
handleEvent(nativeEvent, domEventName, handler) {
// 模拟合成事件对象
const syntheticEvent = {
target: nativeEvent.target,
currentTarget: nativeEvent.currentTarget, // 注意这里是 Root
type: domEventName,
preventDefault: () => nativeEvent.preventDefault(),
stopPropagation: () => nativeEvent.stopPropagation(),
// ... 其他属性
};
// 冒泡路径:我们需要手动构建一个简单的冒泡路径
// 在真实 React 中,这里会用 accumulateTwoPhaseListeners
let target = nativeEvent.target;
const path = [];
while (target && target !== nativeEvent.currentTarget) {
path.push(target);
target = target.parentNode;
}
// path 现在是 [button, div, body]
// 从最内层开始遍历
for (let i = path.length - 1; i >= 0; i--) {
const domNode = path[i];
// 查找这个 DOM 节点对应的组件实例
// 在真实 React 中,这里用 findFiberFromHostNode
const componentInstance = this.findComponent(domNode);
if (componentInstance && componentInstance.props[domEventName]) {
// 找到了!执行回调
componentInstance.props[domEventName](syntheticEvent);
}
}
}
// 模拟挂载组件
render(component, container) {
const rootDiv = document.createElement('div');
container.appendChild(rootDiv);
this.root = rootDiv;
// 关键步骤:遍历组件树,找到所有带事件属性的 DOM 节点
// 并在 Root 上绑定事件
this.traverseDOM(component, rootDiv);
}
traverseDOM(component, domNode) {
if (typeof component === 'function') {
component = component();
}
// 渲染 DOM
const dom = document.createElement(component.type);
for (const key in component.props) {
if (key.startsWith('on')) {
// 如果是事件属性,绑定到 Root
this.bindEvents(this.root, key.toLowerCase(), component.props[key]);
}
dom.setAttribute(key, component.props[key]);
}
// 递归子节点
if (component.props.children) {
if (Array.isArray(component.props.children)) {
component.props.children.forEach(child => {
const childDom = this.traverseDOM(child, dom);
});
} else {
this.traverseDOM(component.props.children, dom);
}
}
return dom;
}
findComponent(domNode) {
// 简化版:这里我们假设 domNode 上挂载了一个 _reactInternalInstance
// 真实 React 中,是通过 Fiber 树查回来的
return domNode._reactInternalInstance;
}
}
// --- 使用示例 ---
class Button extends MiniReact.Component {
render() {
return {
type: 'button',
props: {
children: '点击我',
onClick: (e) => {
console.log('按钮被点击了!', e.target.innerText);
alert('按钮被点击了!');
}
}
};
}
}
class App extends MiniReact.Component {
render() {
return {
type: 'div',
props: {
children: [
{ type: 'h1', props: { children: 'React 事件委托演示' } },
Button
]
}
};
}
}
// 初始化
const container = document.getElementById('root');
const miniReact = new MiniReact();
miniReact.render(App, container);
看懂了吗?在这个简陋的 MiniReact 里,bindEvents 只在 Root 上执行了一次。但是当你点击按钮时,handleEvent 函数会通过 traverseDOM 构建的冒泡路径,一层层找到对应的组件,并执行回调。
第八章:关于捕获和冒泡
React 的事件系统不仅支持冒泡,还支持捕获。
你可能听说过“事件捕获”。事件从 window 开始,一直往下走到 target,这个过程叫捕获。
React 同样实现了捕获阶段。在源码中,除了 trapBubbledEvent,还有一个 trapCapturedEvent。
当事件发生时,React 会先走捕获路径(从外到内),再走冒泡路径(从内到外)。
// dispatchEvent 的简化逻辑
function dispatchEvent(registrationName, nativeEvent) {
// 1. 捕获阶段
const capturePhaseListeners = getCapturePhaseListeners(...);
for (let i = 0; i < capturePhaseListeners.length; i++) {
capturePhaseListeners[i](syntheticEvent);
}
// 2. 目标阶段
// 执行目标节点的事件
const targetListeners = getTargetPhaseListeners(...);
for (let i = 0; i < targetListeners.length; i++) {
targetListeners[i](syntheticEvent);
}
// 3. 冒泡阶段
const bubblePhaseListeners = getBubblePhaseListeners(...);
for (let i = 0; i < bubblePhaseListeners.length; i++) {
bubblePhaseListeners[i](syntheticEvent);
}
}
但是,React 有个特点:React 事件默认不支持捕获事件。
如果你在组件上写了 onClickCapture,React 是支持的,但浏览器默认事件冒泡会先触发 onClick,再触发 onClickCapture(因为捕获先发生,但 React 的执行顺序是先捕获后冒泡)。
这有点绕,但核心逻辑没变:所有的监听器都在 Root 上,只是执行顺序不同。
第九章:内存清理与卸载
好,我们讲了挂载和触发。那 React 怎么知道什么时候该把这些监听器卸掉呢?
当你调用 ReactDOM.unmountComponentAtNode 或者组件卸载时,React 会调用 removeAllListeners。
这个函数会遍历注册的事件列表,并调用 root.removeEventListener。
function removeAllListeners(root) {
const domEventNames = Object.keys(this.listeners);
for (let i = 0; i < domEventNames.length; i++) {
root.removeEventListener(domEventNames[i], this.handlers[domEventNames[i]]);
}
this.listeners = {};
}
这确保了你的应用在卸载时,不会残留那些看不见的监听器,防止内存泄漏。
第十章:总结——为什么这样设计?
好了,讲了这么多源码细节,我们再来总结一下 React 为什么要这么干。
- 性能极致:不管你的应用里有 100 个按钮还是 1000 万个按钮,Root 上永远只有 1 个监听器。浏览器不需要维护那 1000 万个函数引用,也不需要在冒泡时遍历那 1000 万个监听器。这简直是性能优化的教科书级案例。
- 统一性:React 的合成事件屏蔽了不同浏览器的差异。IE 的事件对象和 Chrome 的不一样,但 React 把它们都包装成了
SyntheticEvent。这对开发者来说,体验是一致的。 - 内存管理:通过事件池,React 有效地控制了内存的分配和回收。
结语
React 的事件委托协议,就像是一个精密的瑞士钟表。
Root 容器是钟表的外壳,监听器是齿轮,而合成事件系统则是润滑油。
当你点击屏幕上的任何一个像素点时,这个信号会顺着原生 DOM 的树状结构一路向上,最终汇聚到 Root 容器。在那里,React 的调度中心接过了信号,经过层层过滤、翻译、分发,最后精准地落在你写的那个 onClick 函数里。
这就是 React 的魔力——它隐藏了复杂的底层逻辑,给你提供了一个简洁、高效且统一的 API,让你只关心业务,而不必担心浏览器和内存的细节。
希望今天的讲座能让你对 React 的事件系统有一个更深的理解。下次当你写 onClick 的时候,不妨想一想:那个监听器,其实一直都在 Root 容器里等着你呢。
好了,下课!大家有问题可以举手(敲代码)提问!