Appearance
第14章 合成事件系统
本章要点
- 事件委托的演进:从 React 16 的 document 委托到 React 17+ 的 root 容器委托
- SyntheticEvent 的设计哲学:跨浏览器一致性与性能的平衡
- 事件插件系统(Event Plugin System)的架构与注册机制
- 事件优先级模型:Discrete、Continuous、Default 三级优先级与 Lane 的映射
listenToAllSupportedEvents:React 如何在挂载时一次性注册所有事件监听dispatchEvent的完整链路:从原生事件到 React 回调的调度过程- React 19 中事件系统的简化:移除事件池化与遗留兼容逻辑
如果你在 React 组件上写过 onClick、onChange、onScroll,你可能从未思考过一个问题:这些事件处理器实际上并没有绑定在你期望的那个 DOM 节点上。
这不是一个 bug,而是 React 最精妙的设计之一——合成事件系统(Synthetic Event System)。React 没有在每个 <button> 上调用 addEventListener,而是在应用的根容器上统一监听所有事件,然后通过 Fiber 树自己完成事件的分发和冒泡。这个设计的影响是深远的:它让 React 可以控制事件的优先级、批量更新的时机,甚至可以在不同的渲染器(DOM、Native、Canvas)之间共享同一套事件逻辑。
要理解 React 的事件系统,你需要暂时忘记 DOM 事件模型——捕获、目标、冒泡这三个阶段仍然存在,但它们的实现方式被 React 彻底重写了。从 React 17 开始,事件委托的目标从 document 迁移到了 root 容器;从 React 18 开始,事件的触发与调度器深度耦合;到了 React 19,事件系统又经历了一轮显著的精简。让我们从头追溯这个演进过程。
14.1 事件委托:从 document 到 root 的演进
14.1.1 传统 DOM 事件绑定的问题
在没有框架的世界里,给 1000 个列表项绑定点击事件意味着调用 1000 次 addEventListener。每一个监听器都会消耗内存,每一次绑定和解绑都有性能成本。事件委托(Event Delegation)是解决这个问题的经典模式:
typescript
// 传统事件委托
const list = document.getElementById('list');
list.addEventListener('click', (event) => {
const target = event.target as HTMLElement;
if (target.tagName === 'LI') {
handleItemClick(target.dataset.id);
}
});React 从诞生之日起就内建了事件委托,但它把委托做到了极致——不是委托到父容器,而是委托到整个应用的顶层。
14.1.2 React 16 及之前:委托到 document
在 React 16 及之前的版本中,所有事件监听器都被注册在 document 上:
typescript
// React 16 的事件注册(简化)
// packages/react-dom/src/events/ReactBrowserEventEmitter.js
function listenTo(
registrationName: string, // 如 'onClick'
mountAt: Document | Element // 始终是 document
) {
const listeningSet = getListeningSetForElement(mountAt);
const dependencies = registrationNameDependencies[registrationName];
for (const dependency of dependencies) {
if (!listeningSet.has(dependency)) {
// 在 document 上注册原生事件监听
trapEventForPluginEventSystem(dependency, mountAt);
listeningSet.add(dependency);
}
}
}这个设计在大多数场景下工作良好,但存在一个致命的问题:多个 React 应用实例的事件会互相干扰。
tsx
// 微前端场景:两个 React 应用共存
// App A (React 16)
ReactDOM.render(<AppA />, document.getElementById('app-a'));
// App B (React 16)
ReactDOM.render(<AppB />, document.getElementById('app-b'));
// 问题:两个应用的事件都委托到了 document
// App A 中调用 e.stopPropagation() 会阻止 App B 的事件14.1.3 React 17+:委托到 root 容器
React 17 做出了一个看似简单但影响深远的改变——将事件委托的目标从 document 改为 root 容器:
typescript
// React 17+ 的事件注册
// packages/react-dom/src/events/DOMPluginEventSystem.js
function listenToAllSupportedEvents(rootContainerElement: EventTarget) {
if (!(rootContainerElement as any)[listeningMarker]) {
(rootContainerElement as any)[listeningMarker] = true;
allNativeEvents.forEach((domEventName) => {
// 大部分事件同时注册捕获和冒泡阶段
if (!nonDelegatedEvents.has(domEventName)) {
listenToNativeEvent(
domEventName,
false, // 冒泡阶段
rootContainerElement
);
}
listenToNativeEvent(
domEventName,
true, // 捕获阶段
rootContainerElement
);
});
}
}注意 allNativeEvents 这个集合。它包含了 React 支持的所有原生事件名——click、keydown、scroll、pointerdown 等等。React 在应用挂载的那一刻,就在 root 容器上注册了所有事件的监听器,而不是按需注册。这是一个以空间换时间的设计决策:
typescript
// packages/react-dom/src/events/EventRegistry.js
export const allNativeEvents: Set<DOMEventName> = new Set();
// 事件插件在初始化时注册它们关心的原生事件
export function registerTwoPhaseEvent(
registrationName: string, // 如 'onClick'
dependencies: Array<DOMEventName> // 如 ['click']
) {
registerDirectEvent(registrationName, dependencies);
registerDirectEvent(registrationName + 'Capture', dependencies);
}
export function registerDirectEvent(
registrationName: string,
dependencies: Array<DOMEventName>
) {
// 将依赖的原生事件加入全局集合
for (const dependency of dependencies) {
allNativeEvents.add(dependency);
}
}深度洞察:为什么 React 选择一次性注册所有事件,而不是在组件首次使用
onClick时才注册click监听?原因是确定性(Determinism)。如果事件监听是惰性的,那么同一个原生事件在不同时机可能有不同的行为——取决于是否已有组件注册了对应的 React 事件。一次性注册消除了这种时序依赖,让事件系统的行为完全可预测。
14.1.4 listenToNativeEvent 的实现
listenToNativeEvent 是实际调用 addEventListener 的地方:
typescript
// packages/react-dom/src/events/DOMPluginEventSystem.js
function listenToNativeEvent(
domEventName: DOMEventName,
isCapturePhaseListener: boolean,
target: EventTarget
) {
let eventSystemFlags = 0;
if (isCapturePhaseListener) {
eventSystemFlags |= IS_CAPTURE_PHASE;
}
addTrappedEventListener(
target,
domEventName,
eventSystemFlags,
isCapturePhaseListener
);
}
function addTrappedEventListener(
targetContainer: EventTarget,
domEventName: DOMEventName,
eventSystemFlags: EventSystemFlags,
isCapturePhaseListener: boolean
) {
// 根据事件类型创建不同优先级的监听器
let listener = createEventListenerWrapperWithPriority(
targetContainer,
domEventName,
eventSystemFlags
);
let unsubscribeListener;
if (isCapturePhaseListener) {
unsubscribeListener = addEventCaptureListener(
targetContainer,
domEventName,
listener
);
} else {
unsubscribeListener = addEventBubbleListener(
targetContainer,
domEventName,
listener
);
}
}
// 最终落到原生 API
function addEventBubbleListener(
target: EventTarget,
eventType: string,
listener: Function
): Function {
target.addEventListener(eventType, listener, false);
return listener;
}
function addEventCaptureListener(
target: EventTarget,
eventType: string,
listener: Function
): Function {
target.addEventListener(eventType, listener, true);
return listener;
}图 14-1:React 16 vs React 17+ 的事件委托目标
14.2 SyntheticEvent:跨浏览器的事件抽象
14.2.1 为什么需要合成事件
浏览器之间的事件 API 差异曾经是前端开发者的噩梦。即使在现代浏览器中,一些微妙的差异仍然存在。React 的 SyntheticEvent 为此提供了一个统一的跨浏览器接口:
typescript
// packages/react-dom/src/events/SyntheticEvent.js
function createSyntheticEvent(Interface: EventInterfaceType) {
// SyntheticEvent 不再是一个类,而是一个普通对象
// 在 React 17+ 中,每个事件都创建新的对象(不再池化)
function SyntheticBaseEvent(
reactName: string | null,
reactEventType: string,
targetInst: Fiber | null,
nativeEvent: Event,
nativeEventTarget: null | EventTarget
) {
this._reactName = reactName;
this._targetInst = targetInst;
this.type = reactEventType;
this.nativeEvent = nativeEvent;
this.target = nativeEventTarget;
this.currentTarget = null;
// 将原生事件的属性复制到合成事件上
for (const propName in Interface) {
if (!Interface.hasOwnProperty(propName)) continue;
const normalize = Interface[propName];
if (normalize) {
this[propName] = normalize(nativeEvent);
} else {
this[propName] = nativeEvent[propName];
}
}
// 处理 isDefaultPrevented
const defaultPrevented = nativeEvent.defaultPrevented != null
? nativeEvent.defaultPrevented
: nativeEvent.returnValue === false;
this.isDefaultPrevented = defaultPrevented
? functionThatReturnsTrue
: functionThatReturnsFalse;
this.isPropagationStopped = functionThatReturnsFalse;
return this;
}
// 在原型上定义方法
Object.assign(SyntheticBaseEvent.prototype, {
preventDefault: function() {
this.defaultPrevented = true;
const event = this.nativeEvent;
if (!event) return;
if (event.preventDefault) {
event.preventDefault();
} else if (typeof event.returnValue !== 'unknown') {
event.returnValue = false;
}
this.isDefaultPrevented = functionThatReturnsTrue;
},
stopPropagation: function() {
const event = this.nativeEvent;
if (!event) return;
if (event.stopPropagation) {
event.stopPropagation();
} else if (typeof event.cancelBubble !== 'unknown') {
event.cancelBubble = true;
}
this.isPropagationStopped = functionThatReturnsTrue;
}
});
return SyntheticBaseEvent;
}14.2.2 事件接口的层次结构
React 为不同类型的事件定义了不同的接口,每个接口指定了该类事件需要从原生事件中提取哪些属性:
typescript
// 基础事件接口
const EventInterface = {
eventPhase: 0,
bubbles: 0,
cancelable: 0,
timeStamp: function(event: Event) {
return event.timeStamp || Date.now();
},
defaultPrevented: 0,
isTrusted: 0,
};
// UI 事件接口 —— 继承基础接口
const UIEventInterface = {
...EventInterface,
view: 0,
detail: 0,
};
// 鼠标事件接口 —— 继承 UI 事件接口
const MouseEventInterface = {
...UIEventInterface,
screenX: 0,
screenY: 0,
clientX: 0,
clientY: 0,
pageX: 0,
pageY: 0,
ctrlKey: 0,
shiftKey: 0,
altKey: 0,
metaKey: 0,
button: 0,
buttons: 0,
// 标准化获取相关目标
relatedTarget: function(event: MouseEvent) {
return event.relatedTarget ||
(event as any).fromElement === event.target
? (event as any).toElement
: (event as any).fromElement;
},
// 标准化获取页面偏移
movementX: function(event: MouseEvent) {
if ('movementX' in event) return event.movementX;
// 回退方案...
return 0;
},
movementY: function(event: MouseEvent) {
if ('movementY' in event) return event.movementY;
return 0;
},
};
// 键盘事件接口
const KeyboardEventInterface = {
...UIEventInterface,
key: getEventKey, // 标准化 key 属性
code: 0,
location: 0,
ctrlKey: 0,
shiftKey: 0,
altKey: 0,
metaKey: 0,
repeat: 0,
locale: 0,
// 标准化 charCode/keyCode/which
charCode: function(event: KeyboardEvent) {
if (event.type === 'keypress') {
return getEventCharCode(event);
}
return 0;
},
keyCode: function(event: KeyboardEvent) {
if (event.type === 'keydown' || event.type === 'keyup') {
return event.keyCode;
}
return 0;
},
which: function(event: KeyboardEvent) {
if (event.type === 'keypress') {
return getEventCharCode(event);
}
if (event.type === 'keydown' || event.type === 'keyup') {
return event.keyCode;
}
return 0;
},
};
// 创建具体的合成事件构造函数
export const SyntheticMouseEvent = createSyntheticEvent(MouseEventInterface);
export const SyntheticKeyboardEvent = createSyntheticEvent(KeyboardEventInterface);
export const SyntheticFocusEvent = createSyntheticEvent(FocusEventInterface);
export const SyntheticTouchEvent = createSyntheticEvent(TouchEventInterface);
// ... 更多事件类型深度洞察:接口中的值
0是什么含义?当值为0(falsy)时,表示直接从原生事件对象上取同名属性。当值为函数时,表示需要通过该函数对原生属性进行标准化处理。这是一种非常紧凑的声明式 API 设计——用最少的代码表达了"直接取值"和"需要转换"两种语义。
14.2.3 事件池化的废弃(React 17)
在 React 16 及之前,合成事件对象会被池化复用。事件回调执行完毕后,合成事件的所有属性会被置为 null,放回对象池等待下次使用:
typescript
// React 16 中的事件池化(已废弃)
function handleClick(e: React.MouseEvent) {
console.log(e.type); // 'click' ✅
setTimeout(() => {
console.log(e.type); // null ❌ 事件已被回收!
}, 100);
}
// 必须手动调用 persist() 来保留事件
function handleClickFixed(e: React.MouseEvent) {
e.persist(); // 从池中取出,不再回收
setTimeout(() => {
console.log(e.type); // 'click' ✅
}, 100);
}React 17 废弃了事件池化。原因并不复杂:在现代 JavaScript 引擎中,对象创建和垃圾回收的成本已经非常低了。池化带来的微小性能收益远不及它造成的开发者困惑:
typescript
// React 17+ 不再池化,合成事件在整个生命周期内都可用
function handleClick(e: React.MouseEvent) {
console.log(e.type); // 'click' ✅
setTimeout(() => {
console.log(e.type); // 'click' ✅ 不再有问题
}, 100);
}