React 19 内核探秘
第14章 合成事件系统
第14章 合成事件系统
本章要点
- 事件委托的演进:从 React 16 的 document 委托到 React 17+ 的 root 容器委托
- SyntheticEvent 的设计哲学:跨浏览器一致性与性能的平衡
- 事件插件系统(Event Plugin System)的架构与注册机制
- 事件优先级模型:Discrete、Continuous、Default 三级优先级与 Lane 的映射
listenToAllSupportedEvents:React 如何在挂载时一次性注册所有事件监听dispatchEvent的完整链路:从原生事件到 React 回调的调度过程- 事件系统的版本演进:React 17 停用事件池化 / onScroll 不再冒泡;React 19 对 Discrete 清单与遗留兼容做边际调整(如 beforetoggle),IE 等 polyfill 源码路径仍可能保留
如果你在 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,事件系统在 Discrete 清单与插件层有边际调整(例如 beforetoggle、FormAction 相关路径),但并非“删光遗留兼容”——像 ChangeEventPlugin 的 IE propertychange 分支在 v19.0.0 源码中仍保留。让我们从头追溯这个演进过程。
14.1 事件委托:从 document 到 root 的演进
14.1.1 传统 DOM 事件绑定的问题
在没有框架的世界里,给 1000 个列表项绑定点击事件意味着调用 1000 次 addEventListener。每一个监听器都会消耗内存,每一次绑定和解绑都有性能成本。事件委托(Event Delegation)是解决这个问题的经典模式:
// 传统事件委托
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 上:
// React 16 的事件注册(简化)
// 历史路径:packages/react-dom/src/events/ReactBrowserEventEmitter.js
// (该文件在 v19 已不存在;事件系统现位于 packages/react-dom-bindings/src/events/)
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 树时会出问题——典型的是渐进升级场景:老应用整体跑在 React 16 上,其中某个区域想先换成新版本 React。
// 渐进升级场景:React 16 老应用里嵌套一棵新版本 React 树
// 外层应用(React 16)
ReactDOM.render(<LegacyApp />, document.getElementById('root'));
// 内层某个区域由另一个版本的 React 接管
ReactDOM.render(<ModernWidget />, innerContainer);
// 问题:两棵树的监听器都委托到了同一个 document
// 内层组件里调用 e.stopPropagation(),本意是"别再传给外层了"——
// 但外层的"监听"同样发生在 document:事件到达 document 时
// 两棵树的处理器谁先谁后全凭注册顺序,内层根本拦不住外层
内层树对事件传播的控制在 document 这一层完全失效——这让不同版本的 React 无法安全共存,渐进升级也就无从谈起。这正是 React 17 迁移委托目标的核心动机。
14.1.3 React 17+:委托到 root 容器
React 17 做出了一个看似简单但影响深远的改变——将事件委托的目标从 document 改为 root 容器:
// React 17+ 的事件注册
// packages/react-dom-bindings/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 容器上注册了所有事件的监听器,而不是按需注册。这是一个以空间换时间的设计决策:
// packages/react-dom-bindings/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 的地方:
// packages/react-dom-bindings/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;
}
graph TD
subgraph "React 16: 委托到 document"
D16["document"] -->|"所有事件"| A16["App A root"]
D16 -->|"所有事件"| B16["App B root"]
A16 --> C16["<button onClick>"]
B16 --> D16B["<input onChange>"]
end
subgraph "React 17+: 委托到 root"
A17["App A root<br/>所有事件监听"] --> C17["<button onClick>"]
B17["App B root<br/>所有事件监听"] --> D17["<input onChange>"]
end
style D16 fill:#ff6b6b,stroke:#333,color:#fff
style A17 fill:#69db7c,stroke:#333
style B17 fill:#69db7c,stroke:#333
图 14-1:React 16 vs React 17+ 的事件委托目标
14.2 SyntheticEvent:跨浏览器的事件抽象
14.2.1 为什么需要合成事件
浏览器之间的事件 API 差异曾经是前端开发者的噩梦。即使在现代浏览器中,一些微妙的差异仍然存在。React 的 SyntheticEvent 为此提供了一个统一的跨浏览器接口:
// packages/react-dom-bindings/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 为不同类型的事件定义了不同的接口,每个接口指定了该类事件需要从原生事件中提取哪些属性:
// 基础事件接口
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,放回对象池等待下次使用:
// 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 引擎中,对象创建和垃圾回收的成本已经非常低了。池化带来的微小性能收益远不及它造成的开发者困惑:
// React 17+ 不再池化,合成事件在整个生命周期内都可用
function handleClick(e: React.MouseEvent) {
console.log(e.type); // 'click' ✅
setTimeout(() => {
console.log(e.type); // 'click' ✅ 不再有问题
}, 100);
}
14.3 事件插件系统
14.3.1 插件架构概览
React 的事件系统采用插件式架构,每个插件负责处理一类相关的事件。这种设计让事件系统具有极强的可扩展性:
// packages/react-dom-bindings/src/events/DOMPluginEventSystem.js
// 核心事件插件
import * as SimpleEventPlugin from './plugins/SimpleEventPlugin';
import * as EnterLeaveEventPlugin from './plugins/EnterLeaveEventPlugin';
import * as ChangeEventPlugin from './plugins/ChangeEventPlugin';
import * as SelectEventPlugin from './plugins/SelectEventPlugin';
import * as BeforeInputEventPlugin from './plugins/BeforeInputEventPlugin';
// 按顺序注册插件
// 注册顺序决定了插件的执行优先级
SimpleEventPlugin.registerEvents();
EnterLeaveEventPlugin.registerEvents();
ChangeEventPlugin.registerEvents();
SelectEventPlugin.registerEvents();
BeforeInputEventPlugin.registerEvents();
每个插件实现两个核心方法:registerEvents(注册阶段)和 extractEvents(分发阶段)。
14.3.2 SimpleEventPlugin:最核心的插件
SimpleEventPlugin 处理大多数"简单"事件——即原生事件名和 React 事件名存在直接映射关系的事件:
// packages/react-dom-bindings/src/events/plugins/SimpleEventPlugin.js
function registerSimpleEvents() {
// 事件映射表:[原生事件名, React 事件名]
const simpleEventPluginEvents = [
'abort', 'Abort',
'canPlay', 'CanPlay',
'cancel', 'Cancel',
'click', 'Click',
'close', 'Close',
'copy', 'Copy',
'cut', 'Cut',
'drag', 'Drag',
'dragEnd', 'DragEnd',
'drop', 'Drop',
'input', 'Input',
'keyDown', 'KeyDown',
'keyPress', 'KeyPress',
'keyUp', 'KeyUp',
'load', 'Load',
'mouseDown', 'MouseDown',
'mouseUp', 'MouseUp',
'paste', 'Paste',
'pause', 'Pause',
'play', 'Play',
'scroll', 'Scroll',
'submit', 'Submit',
'touchStart', 'TouchStart',
'touchEnd', 'TouchEnd',
// ... 更多事件
];
for (let i = 0; i < simpleEventPluginEvents.length; i += 2) {
const domEventName = simpleEventPluginEvents[i] as DOMEventName;
const reactName = 'on' + simpleEventPluginEvents[i + 1];
// 建立 原生事件 → React 事件名 的映射
topLevelEventsToReactNames.set(domEventName, reactName);
// 注册为两阶段事件(捕获 + 冒泡)
registerTwoPhaseEvent(reactName, [domEventName]);
}
// 特例:原生事件名与 React 事件名对不上的,在成对数组之外单独注册
registerSimpleEvent('dblclick', 'onDoubleClick');
registerSimpleEvent('focusin', 'onFocus'); // onFocus 监听的是 focusin
registerSimpleEvent('focusout', 'onBlur'); // onBlur 监听的是 focusout
}
注意最后三行特判:onFocus/onBlur 映射的原生事件是 focusin/focusout 而不是 focus/blur——前者会冒泡、可以委托,后者不会(详见 §14.6.1);dblclick 与 onDoubleClick 则纯粹是命名习惯的差异。
extractEvents 是插件在事件分发时被调用的方法:
function extractEvents(
dispatchQueue: DispatchQueue,
domEventName: DOMEventName,
targetInst: null | Fiber,
nativeEvent: AnyNativeEvent,
nativeEventTarget: null | EventTarget,
eventSystemFlags: EventSystemFlags,
targetContainer: EventTarget
) {
const reactName = topLevelEventsToReactNames.get(domEventName);
if (reactName === undefined) return;
// 根据原生事件类型选择对应的合成事件构造函数
let SyntheticEventCtor = SyntheticEvent;
switch (domEventName) {
case 'keydown':
case 'keypress':
case 'keyup':
SyntheticEventCtor = SyntheticKeyboardEvent;
break;
case 'click':
case 'dblclick':
case 'mousedown':
case 'mouseup':
case 'mousemove':
SyntheticEventCtor = SyntheticMouseEvent;
break;
case 'drag':
case 'dragend':
case 'dragenter':
case 'dragexit':
case 'dragleave':
case 'dragover':
case 'dragstart':
case 'drop':
SyntheticEventCtor = SyntheticDragEvent;
break;
case 'touchcancel':
case 'touchend':
case 'touchmove':
case 'touchstart':
SyntheticEventCtor = SyntheticTouchEvent;
break;
case 'scroll':
case 'scrollend':
SyntheticEventCtor = SyntheticUIEvent;
break;
// ... 更多事件类型
}
// 从 Fiber 树中收集所有需要触发的监听器
const listeners = accumulateSinglePhaseListeners(
targetInst,
reactName,
nativeEvent.type,
isCapturePhaseListener,
nativeEvent,
inCapturePhase
);
if (listeners.length > 0) {
// 创建合成事件并加入分发队列
const event = new SyntheticEventCtor(
reactName,
domEventName,
targetInst,
nativeEvent,
nativeEventTarget
);
dispatchQueue.push({ event, listeners });
}
}
14.3.3 ChangeEventPlugin:复杂事件的典范
onChange 是 React 中最"不简单"的事件之一。在原生 DOM 中,<input> 的 change 事件只在失去焦点时触发,而 React 的 onChange 会在每次输入时触发——它实际上映射到了多个原生事件:
// packages/react-dom-bindings/src/events/plugins/ChangeEventPlugin.js
function registerEvents() {
// onChange 依赖多个原生事件
registerTwoPhaseEvent('onChange', [
'change',
'click',
'focusin',
'focusout',
'input',
'keydown',
'keyup',
'selectionchange',
]);
}
function extractEvents(
dispatchQueue: DispatchQueue,
domEventName: DOMEventName,
targetInst: null | Fiber,
nativeEvent: AnyNativeEvent,
nativeEventTarget: null | EventTarget
) {
const targetNode = targetInst
? getNodeFromInstance(targetInst)
: (window as any);
let getTargetInstFunc: Function | undefined;
let handleEventFunc: Function | undefined;
if (isTextInputElement(targetNode)) {
// 文本输入框:使用 input 事件作为主要触发源
getTargetInstFunc = getTargetInstForInputOrChangeEvent;
} else if (isCheckboxOrRadio(targetNode)) {
// 复选框和单选按钮:使用 click 事件
getTargetInstFunc = getTargetInstForClickEvent;
} else if (isSelectElement(targetNode)) {
// 下拉选择框:使用 change 事件
getTargetInstFunc = getTargetInstForChangeEvent;
}
if (getTargetInstFunc) {
const inst = getTargetInstFunc(domEventName, targetInst);
if (inst) {
// 检测值是否真的发生了变化
// 避免重复触发(多个原生事件可能对应同一次逻辑变更)
createAndAccumulateChangeEvent(
dispatchQueue, inst, nativeEvent, nativeEventTarget
);
}
}
}
深度洞察:React 的
onChange为什么要模拟成"每次输入都触发"而不是保持原生change的语义?这是一个深思熟虑的设计决策。React 的核心理念是 UI = f(state),而"受控组件"(Controlled Component)模式要求 state 始终与 UI 同步。如果onChange只在失焦时触发,那么在用户输入的过程中,state 和 UI 就会处于不一致的状态。这违背了 React 的数据流模型。所以 React 选择重新定义onChange的语义,让它在语义上更接近onInput,但名字保留了开发者更熟悉的onChange。
14.4 事件优先级与调度器的协作
14.4.1 三级事件优先级
React 将所有事件分为三个优先级等级,这些优先级直接决定了事件触发的更新以何种方式被调度:
// packages/react-dom-bindings/src/events/ReactDOMEventListener.js
export function getEventPriority(domEventName: DOMEventName): EventPriority {
switch (domEventName) {
// ========== Discrete 离散事件(最高优先级)==========
// 用户的"点按"操作,要求即时响应
case 'click':
case 'keydown':
case 'keyup':
case 'mousedown':
case 'mouseup':
case 'pointerdown':
case 'pointerup':
case 'focusin':
case 'focusout':
case 'input':
case 'change':
case 'textInput':
case 'compositionstart':
case 'compositionend':
case 'compositionupdate':
case 'beforeinput':
case 'copy':
case 'cut':
case 'paste':
case 'submit':
return DiscreteEventPriority; // SyncLane
// ========== Continuous 连续事件(次高优先级)==========
// 用户的"拖拽/滚动"操作,高频触发但可以合并
case 'drag':
case 'dragenter':
case 'dragexit':
case 'dragleave':
case 'dragover':
case 'mousemove':
case 'mouseout':
case 'mouseover':
case 'pointermove':
case 'pointerout':
case 'pointerover':
case 'scroll':
case 'touchmove':
case 'wheel':
return ContinuousEventPriority; // InputContinuousLane
// ========== Default 默认事件(普通优先级)==========
default:
return DefaultEventPriority; // DefaultLane
}
}
需要说明:上面是删减示意——真实源码中 Discrete 分支约有 50 个事件(auxclick、contextmenu、reset、volumechange、fullscreenchange、hashchange、popstate 等都在列,React 18.0 起即如此),这里只保留最有代表性的 20 个。React 19 还把 toggle 从 Continuous 挪进了 Discrete,并新增 beforetoggle。完整列表可查 ReactDOMEventListener.js 的 getEventPriority。
这三级优先级与 React 的 Lane 模型有直接的映射关系:
// packages/react-reconciler/src/ReactEventPriorities.js
export const DiscreteEventPriority: EventPriority = SyncLane;
export const ContinuousEventPriority: EventPriority = InputContinuousLane;
export const DefaultEventPriority: EventPriority = DefaultLane;
export const IdleEventPriority: EventPriority = IdleLane;
graph LR
subgraph "事件优先级"
D["Discrete<br/>click, keydown, input"]
CO["Continuous<br/>scroll, mousemove, drag"]
DE["Default<br/>其他事件"]
end
subgraph "Lane 优先级"
SL["SyncLane<br/>同步执行"]
IL["InputContinuousLane<br/>阻塞 Lane"]
DL["DefaultLane<br/>阻塞 Lane"]
end
subgraph "调度行为"
S1["microtask 同步 flush<br/>render 不可时间切片"]
S2["Scheduler UserBlocking<br/>render 仍阻塞、不切片"]
S3["Scheduler Normal<br/>render 仍阻塞、不切片"]
end
D --> SL --> S1
CO --> IL --> S2
DE --> DL --> S3
style D fill:#ff6b6b,stroke:#333,color:#fff
style CO fill:#ffa94d,stroke:#333,color:#fff
style DE fill:#69db7c,stroke:#333
图 14-2:事件优先级 → Lane 优先级 → 调度行为的映射链
⚠️ 常见误解:InputContinuous / Default 虽然经 Scheduler 异步排程,但二者同属 blocking lane(
includesBlockingLane),真正 render 时走不可中断路径,不是 Transition 那种可时间切片的并发渲染。只有startTransition/ Suspense Retry 等非阻塞 lane 才会renderRootConcurrent。
14.4.2 createEventListenerWrapperWithPriority
事件优先级是如何在监听器层面生效的?答案在 createEventListenerWrapperWithPriority 中:
// packages/react-dom-bindings/src/events/ReactDOMEventListener.js
export function createEventListenerWrapperWithPriority(
targetContainer: EventTarget,
domEventName: DOMEventName,
eventSystemFlags: EventSystemFlags
): Function {
const eventPriority = getEventPriority(domEventName);
let listenerWrapper;
switch (eventPriority) {
case DiscreteEventPriority:
listenerWrapper = dispatchDiscreteEvent;
break;
case ContinuousEventPriority:
listenerWrapper = dispatchContinuousEvent;
break;
case DefaultEventPriority:
default:
listenerWrapper = dispatchEvent;
break;
}
return listenerWrapper.bind(
null,
domEventName,
eventSystemFlags,
targetContainer
);
}
不同优先级的包装器会在分发事件前设置当前的更新优先级:
function dispatchDiscreteEvent(
domEventName: DOMEventName,
eventSystemFlags: EventSystemFlags,
container: EventTarget,
nativeEvent: AnyNativeEvent
) {
// 保存之前的优先级
const previousPriority = getCurrentUpdatePriority();
try {
// 将当前更新优先级设置为 Discrete(最高)
setCurrentUpdatePriority(DiscreteEventPriority);
// 分发事件——此时事件回调中触发的所有 setState
// 都会被标记为 SyncLane
dispatchEvent(domEventName, eventSystemFlags, container, nativeEvent);
} finally {
// 恢复之前的优先级
setCurrentUpdatePriority(previousPriority);
}
}
function dispatchContinuousEvent(
domEventName: DOMEventName,
eventSystemFlags: EventSystemFlags,
container: EventTarget,
nativeEvent: AnyNativeEvent
) {
const previousPriority = getCurrentUpdatePriority();
try {
// 设置为 Continuous 优先级
setCurrentUpdatePriority(ContinuousEventPriority);
dispatchEvent(domEventName, eventSystemFlags, container, nativeEvent);
} finally {
setCurrentUpdatePriority(previousPriority);
}
}
这就是 React 事件系统与调度器协作的核心机制:事件的类型决定了它的优先级,优先级决定了它触发的更新如何被调度。 用户点击按钮触发的 setState 会以 SyncLane 经 microtask 同步 flush;滚动事件中的 setState 会以 InputContinuousLane 进入 Scheduler(UserBlocking),但 render 仍是 blocking(不可时间切片)——不要把“经 Scheduler 排程”误当成“并发可中断渲染”。
14.4.3 批量更新的秘密
在 React 18 之前,批量更新(Batching)的范围仅限于 React 事件处理器内部。而在 React 18+ 中,所有更新都默认批量处理——这个变化也与事件系统的重构密切相关:
// React 17: 只有 React 事件回调中的更新是批量的
function handleClick() {
setCount(c => c + 1); // 不会立即渲染
setFlag(f => !f); // 不会立即渲染
// 回调结束后,批量执行一次渲染
}
// React 17: setTimeout 中的更新不是批量的
setTimeout(() => {
setCount(c => c + 1); // 立即渲染!
setFlag(f => !f); // 又渲染一次!
}, 100);
// React 18+: 所有更新都是批量的
setTimeout(() => {
setCount(c => c + 1); // 不会立即渲染
setFlag(f => !f); // 不会立即渲染
// 微任务中批量执行一次渲染
}, 100);
这个变化的技术基础来自于事件分发流程中的 flushSync 和微任务调度:
// React 18+ 的分发逻辑
function dispatchEvent(
domEventName: DOMEventName,
eventSystemFlags: EventSystemFlags,
targetContainer: EventTarget,
nativeEvent: AnyNativeEvent
) {
// 1. 找到原生事件对应的 Fiber 节点
const nativeEventTarget = getEventTarget(nativeEvent);
let targetInst = getClosestInstanceFromNode(nativeEventTarget);
// 2. 通过插件系统提取合成事件
dispatchEventForPluginEventSystem(
domEventName,
eventSystemFlags,
nativeEvent,
targetInst,
targetContainer
);
// 在 React 18+ 中,不再在这里同步刷新
// 而是让 ensureRootIsScheduled 在微任务中统一调度
}
14.5 dispatchEvent 的完整链路
14.5.1 从原生事件到 Fiber 节点
当一个原生 DOM 事件触发时,React 需要找到它对应的 Fiber 节点。这个过程是通过 DOM 节点上的内部属性完成的:
// packages/react-dom-bindings/src/client/ReactDOMComponentTree.js(v19.0.0)
const randomKey = Math.random().toString(36).slice(2);
const internalInstanceKey = '__reactFiber$' + randomKey;
const internalPropsKey = '__reactProps$' + randomKey;
// 在创建 DOM 节点时,React 会将 Fiber 实例存储在 DOM 上
export function precacheFiberNode(
hostInst: Fiber,
node: Instance
): void {
(node as any)[internalInstanceKey] = hostInst;
}
// 在更新 props 时,React 会将最新的 props 存储在 DOM 上
export function updateFiberProps(
node: Instance,
props: Props
): void {
(node as any)[internalPropsKey] = props;
}
// 通过 DOM 节点反查 Fiber 实例
export function getClosestInstanceFromNode(targetNode: Node): null | Fiber {
let targetInst = (targetNode as any)[internalInstanceKey];
if (targetInst) {
return targetInst;
}
// 如果当前节点没有 Fiber,向上查找父节点
let parentNode = targetNode.parentNode;
while (parentNode) {
targetInst = (parentNode as any)[internalInstanceKey];
if (targetInst) {
return targetInst;
}
parentNode = parentNode.parentNode;
}
return null;
}
深度洞察:为什么 React 使用随机后缀(
__reactFiber$xxxxx)作为属性名?这不仅是为了避免与其他库冲突,更重要的是防止同一页面上的多个 React 实例互相读取对方的 Fiber 节点。每个 React 实例有自己的随机后缀,确保了命名空间的隔离。
14.5.2 沿 Fiber 树收集监听器
找到目标 Fiber 后,React 需要沿着 Fiber 树向上遍历,收集路径上所有相关的事件监听器——这就是 React 自己实现的"冒泡"过程:
// packages/react-dom-bindings/src/events/DOMPluginEventSystem.js
export function accumulateSinglePhaseListeners(
targetFiber: Fiber | null,
reactName: string | null,
nativeEventType: string,
isCapturePhase: boolean,
accumulateTargetOnly: boolean,
nativeEvent: AnyNativeEvent
): Array<DispatchListener> {
const captureName = reactName !== null ? reactName + 'Capture' : null;
const reactEventName = isCapturePhase ? captureName : reactName;
const listeners: Array<DispatchListener> = [];
let instance = targetFiber;
let lastHostComponent = null;
// 从目标 Fiber 向上遍历到根节点
while (instance !== null) {
const { stateNode, tag } = instance;
// 只处理 HostComponent(原生 DOM 节点)
if (tag === HostComponent && stateNode !== null) {
lastHostComponent = stateNode;
if (reactEventName !== null) {
// 从 Fiber 的 props 中获取事件处理器
const listener = getListener(instance, reactEventName);
if (listener != null) {
listeners.push(
createDispatchListener(instance, listener, lastHostComponent)
);
}
}
}
// 如果只收集目标节点的监听器,到这里就停止
if (accumulateTargetOnly) break;
// 继续向上遍历
instance = instance.return;
}
return listeners;
}
// 从 Fiber 的 props 中取出事件处理器
function getListener(inst: Fiber, registrationName: string): Function | null {
const stateNode = inst.stateNode;
if (stateNode === null) return null;
const props = getFiberCurrentPropsFromNode(stateNode);
if (props === null) return null;
const listener = props[registrationName];
return listener;
}
14.5.3 processDispatchQueue:执行分发
收集完所有监听器后,React 按照正确的顺序执行它们:
// packages/react-dom-bindings/src/events/DOMPluginEventSystem.js
export function processDispatchQueue(
dispatchQueue: DispatchQueue,
eventSystemFlags: EventSystemFlags
) {
const inCapturePhase = (eventSystemFlags & IS_CAPTURE_PHASE) !== 0;
for (let i = 0; i < dispatchQueue.length; i++) {
const { event, listeners } = dispatchQueue[i];
processDispatchQueueItemsInOrder(event, listeners, inCapturePhase);
}
}
function processDispatchQueueItemsInOrder(
event: ReactSyntheticEvent,
dispatchListeners: Array<DispatchListener>,
inCapturePhase: boolean
) {
if (inCapturePhase) {
// 捕获阶段:从外向内执行(反向遍历)
for (let i = dispatchListeners.length - 1; i >= 0; i--) {
const { instance, currentTarget, listener } = dispatchListeners[i];
// 检查是否已停止传播
if (event.isPropagationStopped()) break;
executeDispatch(event, listener, currentTarget);
}
} else {
// 冒泡阶段:从内向外执行(正向遍历)
for (let i = 0; i < dispatchListeners.length; i++) {
const { instance, currentTarget, listener } = dispatchListeners[i];
if (event.isPropagationStopped()) break;
executeDispatch(event, listener, currentTarget);
}
}
}
function executeDispatch(
event: ReactSyntheticEvent,
listener: Function,
currentTarget: EventTarget
) {
// 设置 currentTarget(会随着冒泡过程变化)
event.currentTarget = currentTarget;
try {
listener(event);
} catch (error) {
// 错误不会中断事件分发
// 而是被收集后统一通过 reportGlobalError 抛出
reportGlobalError(error);
}
event.currentTarget = null;
}
整个分发链路可以用下图总结:
flowchart TD
A["原生 DOM 事件触发<br/>(如 click)"] --> B["root 容器上的监听器被调用"]
B --> C["getEventPriority()<br/>确定事件优先级"]
C --> D["setCurrentUpdatePriority()<br/>设置当前更新优先级"]
D --> E["getClosestInstanceFromNode()<br/>找到目标 Fiber"]
E --> F["extractEvents()<br/>插件提取合成事件"]
F --> G["accumulateSinglePhaseListeners()<br/>沿 Fiber 树收集监听器"]
G --> H["new SyntheticEvent()<br/>创建合成事件对象"]
H --> I["processDispatchQueue()<br/>按顺序执行监听器"]
I --> J["ensureRootIsScheduled()<br/>调度可能产生的更新"]
style A fill:#e7f5ff,stroke:#333
style C fill:#ff6b6b,stroke:#333,color:#fff
style G fill:#ffa94d,stroke:#333,color:#fff
style I fill:#69db7c,stroke:#333
style J fill:#da77f2,stroke:#333,color:#fff
图 14-3:从原生事件触发到 React 调度更新的完整链路
14.6 React 19 中事件系统的简化
14.6.1 移除的遗留逻辑
到 React 19,事件系统已经甩掉了多项历史遗留的兼容逻辑。但要注意版本归属——下面几项常被笼统记在 19 名下,其中不少其实早在 17 就已完成,19 只是延续:
1. 事件池化:早已停用,persist() 保留为空操作
React 17 移除了事件池,persist() 从那时起就变成保留下来的空操作(no-op)——React 19 依然如此,并没有把它删掉:
// React 17+(含 React 19):
// packages/react-dom-bindings/src/events/SyntheticEvent.js
persist: function () {
// Modern event system doesn't use pooling.
},
isPersistent: functionThatReturnsTrue,
也就是说,调用 e.persist() 不会报错——@types/react 19 的类型定义里该方法也仍然保留——它只是不再有任何意义,可以放心删除。真正被 React 19 移除的是 createFactory、字符串 ref 这类 API,事件池的"尸体"反而一直留着。
2. onChange 主路径与仍保留的 IE polyfill
现代浏览器下 ChangeEventPlugin 的主路径依赖原生 input / change 等事件,并用 inputValueTracking.updateValueIfChanged 判断值是否真的变了。但 v19.0.0 源码并未删除 IE 兼容分支:当 isInputEventSupported === false(典型是 documentMode ≤ 9)时,仍会走 attachEvent('onpropertychange', …) 的 polyfill(见 ChangeEventPlugin.js 中 startWatchingForValueChange / handlePropertyChange)。
// v19.0.0 仍保留的能力探测(语义摘要)
let isInputEventSupported = false;
if (canUseDOM) {
// IE9 声称支持 input,但删字时不可靠,故 documentMode ≤ 9 不走主路径
isInputEventSupported =
isEventSupported('input') &&
(!document.documentMode || document.documentMode > 9);
}
// 主路径:所有浏览器共用的“值是否变化”检查(本身不含 propertychange)
function getInstIfValueChanged(targetInst: Fiber) {
const targetNode = getNodeFromInstance(targetInst);
if (updateValueIfChanged(targetNode)) {
return targetInst;
}
}
// 仅 isInputEventSupported === false 时启用的 polyfill(源码仍在)
// activeElement.attachEvent('onpropertychange', handlePropertyChange)
因此不能写「React 19 已去掉 propertychange」——更准确的说法是:日常开发几乎只碰到主路径;源码层仍为极老 IE 留了一条死气沉沉但未删除的分支。
3. onScroll 不再冒泡——这其实是 React 17 的变更
原生 scroll 事件是不冒泡的,但 React 16 及之前版本中 onScroll 会冒泡——这是一个长期存在的不一致行为。React 17 的官方发布说明明确修复了这个问题,此后所有版本(含 React 19)保持一致,常有资料把这笔账错记在 18 或 19 头上:
function App() {
return (
// React ≤16: 子元素滚动时外层 onScroll 也会触发(冒泡行为)
// React 17+: 只有 div 自身滚动时 onScroll 才触发
<div onScroll={() => console.log('scrolled')}>
<div style={{ overflow: 'auto', height: 200 }}>
<div style={{ height: 1000 }}>
Tall content
</div>
</div>
</div>
);
}
4. focusin/focusout:同样定型于 React 17
React 16 时代,onFocus/onBlur 基于原生 focus/blur 事件加一层模拟冒泡的处理。React 17 起改为直接监听原生 focusin/focusout——它们本身就会冒泡,polyfill 逻辑随之删除,React 19 延续这一实现:
// React 17+: onFocus/onBlur 映射到原生 focusin/focusout
// (registerSimpleEvents 中的特判,见 §14.3.2)
registerSimpleEvent('focusin', 'onFocus');
registerSimpleEvent('focusout', 'onBlur');
也正因为 focusin/focusout 可以正常冒泡、走 root 委托,它们不在 nonDelegatedEvents 集合里——进入那个集合的是 scroll、load、媒体事件这类原生就不冒泡的事件(见 §14.6.2),两件事不要混为一谈。
14.6.2 nonDelegatedEvents 的精细化
某些事件不适合委托到 root 容器——它们要么不冒泡,要么在冒泡时会丢失关键信息。React 维护了一个不委托事件的集合:
// packages/react-dom-bindings/src/events/DOMPluginEventSystem.js (React 19)
export const nonDelegatedEvents: Set<DOMEventName> = new Set([
'beforetoggle',
'cancel',
'close',
'invalid',
'load',
'scroll',
'scrollend',
'toggle',
// 还包括全部媒体事件(play/pause/timeupdate/volumechange 等,
// 源码直接展开 mediaEventTypes 数组塞进来)
...mediaEventTypes,
]);
对于 nonDelegatedEvents 中的事件,React 在 root 上只注册捕获阶段的监听器。但这不等于这些事件没有"冒泡侧"的监听——它们的非捕获监听器是在元素挂载时由 listenToNonDelegatedEvent 直接 addEventListener 到目标 DOM 元素上的(比如 <img onLoad={...}> 的 load 监听就挂在那个 <img> 上),只是不再走 root 委托这条路。回顾 listenToAllSupportedEvents 的代码:
allNativeEvents.forEach((domEventName) => {
if (!nonDelegatedEvents.has(domEventName)) {
// 普通事件:注册冒泡 + 捕获
listenToNativeEvent(domEventName, false, rootContainerElement);
}
// 所有事件都注册捕获阶段
listenToNativeEvent(domEventName, true, rootContainerElement);
});
14.7 与原生事件的交互与冲突处理
14.7.1 合成事件与原生事件的执行顺序
理解 React 合成事件与原生事件的执行顺序,是避免事件冲突的关键:
function EventOrderDemo() {
const buttonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const button = buttonRef.current!;
// 原生事件监听(直接绑定到元素)
button.addEventListener('click', () => {
console.log('2. 原生事件 - 元素上');
});
// 原生事件监听(绑定到 document)
document.addEventListener('click', () => {
console.log('4. 原生事件 - document 上');
});
return () => {
// 清理...
};
}, []);
return (
<button
ref={buttonRef}
onClick={() => console.log('3. React 合成事件 - 冒泡')}
onClickCapture={() => console.log('1. React 合成事件 - 捕获')}
>
点击我
</button>
);
}
// React 17/18/19 中点击按钮后的输出顺序:
// 1. React 合成事件 - 捕获
// 2. 原生事件 - 元素上
// 3. React 合成事件 - 冒泡
// 4. 原生事件 - document 上
这个执行顺序的原因在于事件传播机制:
- 捕获阶段:事件从
document向目标元素下行,先经过root容器。回顾 14.6 节——React 17+ 用addEventListener(root, listener, true)在 root 上注册的是真实的捕获阶段监听器,所以事件经过 root 时,React 就沿 Fiber 路径从外向内执行完所有onClickCapture。此时事件还没有到达button - 目标阶段:事件到达
button,元素上直接绑定的原生监听器触发 - 冒泡阶段:事件冒泡回 root 容器,React 的冒泡阶段监听器沿 Fiber 路径从内向外执行所有
onClick - 事件继续冒泡到
document,触发 document 上的原生监听器
版本陷阱:如果你看到"元素上的原生监听器先于
onClickCapture执行"的结论,那是 React 16 及以前的行为——当时所有 React 监听器都挂在document的冒泡阶段,捕获与冒泡都是等事件冒泡到document之后才统一"模拟"出来的,自然晚于元素上的原生监听。React 17 把捕获事件改为 root 上真实的捕获监听器之后,onClickCapture的执行时机被提前到了捕获阶段,这条老结论就不再成立。
14.7.2 stopPropagation 的陷阱
React 合成事件的 stopPropagation() 内部会调用原生事件的 stopPropagation()——它同时做了两件事:阻止 React 沿 Fiber 路径继续执行外层处理器,以及阻止原生事件从 root 容器继续向上(例如到 document)传播。真正的陷阱在于它"管不到"的两个地方:
function StopPropagationDemo() {
return (
<div onClick={() => console.log('父元素 React 事件')}>
<button
onClick={(e) => {
e.stopPropagation();
// 拦住了:父元素的 onClick,以及事件继续冒泡到 document
// 管不到的两件事:
// 1. button → root 路径上直接绑定的原生监听器——React 处理器
// 是在事件冒泡到 root 时才运行的,那些监听器早已执行完毕
// 2. 同一节点上的其他监听器——stopPropagation 从不拦截同级监听
console.log('子元素 React 事件');
}}
>
点击
</button>
</div>
);
}
如果需要连同一节点上的其他监听器一并拦截(例如某个第三方库也在 root 容器上注册了 click 监听),要用 nativeEvent 上的 stopImmediatePropagation:
function StopNativePropagation() {
return (
<button
onClick={(e) => {
e.stopPropagation(); // 阻止 React 冒泡 + 原生事件继续上行
e.nativeEvent.stopImmediatePropagation(); // 再拦下同节点上的其他监听器
}}
>
完全阻止冒泡
</button>
);
}
版本陷阱:React 17 改为 root 委托后,合成事件的
e.stopPropagation()能真正拦住document上的原生监听器;而 React 16 做不到——React 自己就挂在document上,它的处理器运行时事件已经到顶了。"React 的 stopPropagation 拦不住 document 上的原生监听"这条流传很广的经验,同样只适用于 React 16。
14.7.3 常见冲突场景与解决方案
场景 1:第三方库的事件监听与 React 冲突
function DropdownWithThirdParty() {
const [open, setOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
// 第三方库通常在 document 上监听点击来关闭弹窗
const handleDocumentClick = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('click', handleDocumentClick);
return () => document.removeEventListener('click', handleDocumentClick);
}, []);
return (
<div ref={dropdownRef}>
<button onClick={() => setOpen(o => !o)}>Toggle</button>
{open && (
<div
onClick={(e) => {
// React 17+ 中这个 stopPropagation 确实能拦住 document 上的
// 原生监听器(事件不会再从 root 冒泡到 document)
// 但更稳妥的做法是像上面 handleDocumentClick 那样用 contains()
// 判断——不依赖"弹窗内每处点击都记得 stopPropagation"这种脆弱约定
e.stopPropagation();
}}
>
<ul>
<li>选项 1</li>
<li>选项 2</li>
</ul>
</div>
)}
</div>
);
}
场景 2:Portal 中的事件冒泡
Portal 是另一个让人困惑的事件场景。React 的合成事件按照 Fiber 树(而非 DOM 树)冒泡:
function ModalWithPortal() {
const [count, setCount] = useState(0);
return (
// React 合成事件会从 Portal 冒泡到这里
// 即使 Portal 的 DOM 在 document.body 下
<div onClick={() => setCount(c => c + 1)}>
<p>点击次数: {count}</p>
{createPortal(
<button>
点我(在 Portal 中,但 React 事件会冒泡到父组件)
</button>,
document.body
)}
</div>
);
// 点击 Portal 中的按钮,count 会增加!
// 因为 React 沿 Fiber 树冒泡,而非 DOM 树
}
这个行为是有意为之。React 团队认为,组件的事件传播应该遵循组件树的结构,而不是 DOM 的物理位置。Portal 只是改变了 DOM 的渲染位置,不应该改变组件的逻辑关系。
深度洞察:Portal 事件冒泡的设计体现了 React 的一个核心原则:虚拟 DOM 树的语义优先于物理 DOM 树的结构。在 React 的世界观中,组件树才是"真实"的,DOM 只是一种渲染输出。这与 React Native 能以同一套组件模型渲染到完全不同的原生 UI 是同一个思想的延伸。
14.7.4 Passive 事件与滚动性能
React 在注册某些高频事件时使用了 { passive: true } 选项,这是与浏览器滚动性能密切相关的优化:
// 对于 touchstart、touchmove、wheel 等事件
// React 注册为 passive 监听器以提升滚动性能
function addEventBubbleListenerWithPassiveFlag(
target: EventTarget,
eventType: string,
listener: Function,
passive: boolean
): Function {
target.addEventListener(eventType, listener, {
capture: false,
passive, // passive: true 意味着不会调用 preventDefault
});
return listener;
}
当监听器被标记为 passive: true 时,浏览器知道该监听器不会调用 preventDefault(),因此可以立即开始滚动而无需等待 JavaScript 执行完毕。这意味着在 React 中直接通过 onTouchMove 调用 e.preventDefault() 来阻止滚动不会生效:
// ❌ 这不会阻止滚动(React 注册为 passive)
function NoScrollByReact() {
return (
<div
onTouchMove={(e) => {
e.preventDefault(); // 无效!浏览器会忽略
}}
>
内容
</div>
);
}
// ✅ 使用原生事件监听并设置 { passive: false }
function NoScrollByNative() {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current!;
const handler = (e: TouchEvent) => {
e.preventDefault(); // 有效
};
el.addEventListener('touchmove', handler, { passive: false });
return () => el.removeEventListener('touchmove', handler);
}, []);
return <div ref={ref}>内容</div>;
}
14.8 事件系统的架构全景
让我们用一张完整的架构图来总结 React 事件系统的各个组成部分及其关系:
graph TB
subgraph "初始化阶段(createRoot 时)"
A["listenToAllSupportedEvents()"] --> B["遍历 allNativeEvents"]
B --> C["listenToNativeEvent()"]
C --> D["addTrappedEventListener()"]
D --> E["createEventListenerWrapperWithPriority()"]
E --> F1["dispatchDiscreteEvent<br/>(click, keydown...)"]
E --> F2["dispatchContinuousEvent<br/>(scroll, mousemove...)"]
E --> F3["dispatchEvent<br/>(其他事件)"]
end
subgraph "运行时阶段(事件触发时)"
G["原生事件触发"] --> H["root 上的监听器"]
H --> I["设置 UpdatePriority"]
I --> J["getClosestInstanceFromNode()<br/>DOM → Fiber"]
J --> K["dispatchEventForPluginEventSystem()"]
K --> L["extractEvents()<br/>各插件提取合成事件"]
L --> M["accumulateSinglePhaseListeners()<br/>收集 Fiber 路径上的监听器"]
M --> N["processDispatchQueue()<br/>按序执行"]
N --> O["用户的事件回调<br/>(onClick, onChange...)"]
O --> P["setState / dispatch"]
P --> Q["ensureRootIsScheduled()"]
end
subgraph "事件插件"
P1["SimpleEventPlugin"]
P2["ChangeEventPlugin"]
P3["EnterLeaveEventPlugin"]
P4["SelectEventPlugin"]
P5["BeforeInputEventPlugin"]
end
L --> P1
L --> P2
L --> P3
L --> P4
L --> P5
style A fill:#e7f5ff,stroke:#333
style G fill:#ff6b6b,stroke:#333,color:#fff
style Q fill:#da77f2,stroke:#333,color:#fff
图 14-4:React 事件系统的完整架构
14.9 React 19 events 目录的真实尺寸(v19.0.0)
以下行数以 tag v19.0.0 为准(packages/react-dom-bindings/src/events/ 顶层 23 个 .js + events/plugins/ 6 个 .js)。行数会随 commit 微变,读作量级账本即可。
事件系统核心 23 文件 3831 行——
| 文件 | 行(约) | 角色 |
|---|---|---|
DOMPluginEventSystem.js |
1019 | 本章 §14.5 的主路径——dispatchEvent 编排 + 插件依次 extractEvents + 沿 Fiber 树收集监听器 |
ReactDOMEventReplaying.js |
600 | 事件重放——并发渲染下、离散事件若命中暂停边界先入队、resume 后重放(React 17 已有此文件,18 随 createRoot 默认启用) |
SyntheticEvent.js |
602 | SyntheticEvent 基类 + 14 个具体子类(含 Mouse / Keyboard / Touch / Pointer / Wheel / Animation / Transition / Toggle 等) |
ReactDOMEventListener.js |
393 | dispatchEvent 入口、createEventListenerWrapperWithPriority 的三优先级包装(§14.4.2) |
DOMEventProperties.js |
141 | DOM 事件名 → React 事件名映射表 |
DOMEventNames.js |
131 | DOMEventName 类型定义 + 全部已知事件名 |
TopLevelEventTypes.js |
98 | EventSystemFlags / TopLevelEventType 类型 |
getVendorPrefixedEventName.js |
96 | webkitTransitionEnd 等浏览器前缀处理 |
getListener.js |
78 | 根据 fiber + 事件名取 props 上的 onClick 回调 |
EventRegistry.js / ReactDOMControlledComponent.js / FallbackCompositionState.js / ReactDOMUpdateBatching.js / EventListener.js 等 |
≤ 75 | 各种小工具 |
事件插件 6 文件约 1631 行——比 §14.3.1 列举的多 1 个——
| 插件 | 行(约) | 角色 |
|---|---|---|
BeforeInputEventPlugin.js |
460 | beforeinput 事件——IME 输入兼容(本组插件中体量最大) |
ChangeEventPlugin.js |
344 | input/textarea/select 的 change 事件归一化(§14.3.3) |
SimpleEventPlugin.js |
233 | 一对一映射的简单事件(§14.3.2) |
FormActionEventPlugin.js |
212 | React 19 新增——<form action={fn}> 拦截 submit |
SelectEventPlugin.js |
209 | onSelect 事件(依赖文本选择状态) |
EnterLeaveEventPlugin.js |
173 | mouseEnter / mouseLeave 的非冒泡合成(DOM 原生不冒泡) |
两条容易被旧版本资料带偏的事实——
- 是 6 个插件不是 5——
FormActionEventPlugin是 React 19 配合 Server Actions 加的——但它不调用registerEvents()(v19.0.0 的DOMPluginEventSystem.js约 L90-94 里只有前 5 个)——因为它不需要在 root 上额外注册 listener、只在extractEvents(约第 176 行)的 submit 路径里被调用 - 官方早就想去掉 SimpleEventPlugin 这个概念 ——同文件维护注释已表达过:一对一映射的事件其实可以不走插件层直接派发——但这是历史包袱、不动比改动安全
架构启示——DOMPluginEventSystem.js 单文件约 1000 行量级,把"分发编排"集中到一处、各插件只负责"识别 + 提取"——是 React 把"调度"和"业务"切开的范式。这也是为什么本章 §14.5 用一节专门讲它。
14.10 相对 v19.0.0 的后续演进(main 快照)
v19.0.0 之后的 main 会继续演进。以下是相对 19 GA 值得记住的方向性变化(行数会随 commit 浮动,不追求个位精确):
- 新增第 7 个插件
ScrollEndEventPlugin(约 200 行量级)——为onScrollEnd做 polyfill:老浏览器缺原生scrollend时用 scroll + timeout 去抖模拟。证明 React 愿意为单个 DOM 事件单独建插件文件——兼容性逻辑与其它事件完全不共享 SyntheticToggleEvent在 v19.0.0 就已存在;SyntheticSubmitEvent(为 submit 暴露submitter)是 19.0 之后才加入的。注意SyntheticPointerEvent、SyntheticWheelEvent、SyntheticAnimationEvent、SyntheticTransitionEvent不是 19 新货——v18 的SyntheticEvent.js里全都能找到getEventPriority的 Discrete 列表比 §14.4.1 删减示意宽得多——但这不是 19 新变化——约 50 个 Discrete 事件的格局从 React 18.0 就定了。19 的真实增量主要是beforetoggle(外加toggle从 Continuous 移入 Discrete)。不要把"Discrete 列表扩大一倍"记在 19 头上queuedPointers/queuedPointerCaptures并非 main 才加——v19.0.0 的ReactDOMEventReplaying.js里就已存在(甚至可追溯到更早版本)
一句话总结——events 目录在 v19.0.0 约 3900 行核心 + 1630 行插件 ≈ 5530 行 Flow/JS;后续 main 再叠加 ScrollEnd 等插件后仍是同一量级。这就是浏览器事件模型与 React 并发模型之间的全部翻译层。
14.11 事件系统与 Fiber / Scheduler 的三处交汇
前面 §14.5 讲"分发链路"只到"收集监听器"为止。真正让事件系统与 React 其他核心机制咬合起来的,是事件回调之后发生的事——setState 入队、scheduleUpdateOnFiber、ensureRootIsScheduled 这一连串调用。本节把三处交汇点明确标注出来,给读者一条能和第 3 章 Fiber、第 4 章 Scheduler、第 9 章并发调度对上的索引。
交汇一:DOM → Fiber 的反查(呼应 ch03 §3.x stateNode 设计)
原生事件的 event.target 是 DOMNode——它怎么变成一个 Fiber?答案在 §14.5.1 已经讲了:每个 HostComponent 创建时,React 在 DOM 上写了两个字段——
node[internalInstanceKey] = hostInst; // __reactFiber$xxx → Fiber
node[internalPropsKey] = props; // __reactProps$xxx → props
这两个字段是 ch03 讲 stateNode 时被刻意留下的后门——stateNode 是 Fiber 指向 DOM 的"正向指针"、internalInstanceKey 是 DOM 指回 Fiber 的"反向指针"、两者配对让事件分发可以不依赖 Virtual DOM 查询、直接 O(1) 拿到对应 Fiber。
顺带澄清一个容易讹传的因果:key 变了要销毁重建,不是因为反向指针"写死了改不了"——__reactFiber$ 只是个普通的 expando 属性,技术上随时可以改写(双缓存下 fiber 与 alternate 交替,getClosestInstanceFromNode 本来就要处理指针指向配对旧 fiber 的情况)。销毁重建是协调层 identity 语义的设计选择:key 声明"这还是不是同一个东西",key 变了 React 就按全新节点处理,state、effect、DOM 一并重来——这是 diff 算法的规则,与事件系统的反向指针无关。
交汇二:setCurrentUpdatePriority(呼应 ch09 §9.x Lane 调度)
§14.4.2 已经讲了 dispatchDiscreteEvent 会在调用业务回调前把 currentUpdatePriority 设为 DiscreteEventPriority。这个全局变量才是事件优先级真正起作用的地方——
// react-reconciler/src/ReactFiberWorkLoop.js(v19.0.0)
// scheduleUpdateOnFiber 与 requestUpdateLane 都在本文件,不在 ReactFiberReconciler.js
function scheduleUpdateOnFiber(root, fiber, lane) {
// lane 的来源?看 useState 的 dispatch:
// const lane = requestUpdateLane(fiber);
}
export function requestUpdateLane(fiber) {
const updateLane = getCurrentUpdatePriority(); // ← 就是这里!
if (updateLane !== NoLane) return updateLane;
// ...fallback 到 DefaultLane
}
换句话说——事件的类型 → getEventPriority 返回 DiscreteEventPriority → dispatchDiscreteEvent 里 setCurrentUpdatePriority → useState 里 requestUpdateLane 读到 SyncLane → fiber.lanes |= SyncLane → 渲染在微任务里同步出。这条链从用户手指按下鼠标开始、走过事件系统、走过 Hook 层、走到调度器,中间没有一步是"异步跨越"——全靠一个全局变量 currentUpdatePriority 串起来。这就是 React 18 并发模型里最朴素的那根"主线程优先级"。
交汇三:ensureRootIsScheduled 的微任务融合(呼应第 4 章 Scheduler / 第 9 章并发入口)
事件回调结束后 React 怎么启动渲染?不是在回调里同步调 performWorkOnRoot——而是走 ensureRootIsScheduled 把当前整批更新塞进一个微任务。
sequenceDiagram
participant UI as 用户点击
participant Dispatch as dispatchDiscreteEvent
participant Hook as useState dispatch
participant Reconc as scheduleUpdateOnFiber
participant Task as ensureRootIsScheduled
participant MT as microtask queue
participant Work as performWorkOnRoot
UI->>Dispatch: click
Dispatch->>Dispatch: setCurrentUpdatePriority(Discrete)
Dispatch->>Hook: 业务回调里调 setCount
Hook->>Hook: requestUpdateLane() → SyncLane
Hook->>Reconc: scheduleUpdateOnFiber(SyncLane)
Reconc->>Task: ensureRootIsScheduled(root)
Task->>MT: queueMicrotask(flushSyncWorkOnAllRoots)
Note over Hook,Reconc: 业务继续调 setFlag<br/>再走一遍上面、但 ensureRootIsScheduled<br/>发现已经有 microtask 就 no-op
Dispatch->>Dispatch: finally: restore priority
MT->>Work: 事件回调全部跑完后、微任务出队
Work->>Work: render + commit 一次
图 14-5:一次 click 触发 N 个 setState 只渲染一次的完整时序
这就是 React 18 "全局自动批处理"(Automatic Batching)的技术实现——不是在事件回调外层包 batchedUpdates、而是让所有 setState 去合并到同一个 microtask。事件系统在这里的角色是:设置好优先级、把业务回调同步跑完、让回调里的所有 setState 都通过 ensureRootIsScheduled 融合到同一个 microtask——不管有几个 setState、不管它们在哪一层。
14.12 React 17 → 18 → 19 事件优先级的三次变化
事件优先级不是 React 一开始就有的。它在三个版本里各演进了一步——每次演进都对应一个更大的架构转向。把这三步放在一起看、才能理解今天的优先级模型是怎么长出来的。
React 17:三级事件优先级的雏形
React 17 的事件系统就已经有三级优先级了、但叫法不同——那时叫 DiscreteEvent、UserBlockingEvent、ContinuousEvent。容易记错的是:Lane 模型此时已经存在(lanes 重构 2020 年合入、随 React 17.0 发布),但 17 默认跑在 legacy 模式下、更新几乎全程同步,事件优先级更多是事件系统内部的约定——决定"派发时机"(比如离散事件派发前要先 flush 掉之前挂起的离散更新),并没有成为渲染调度真正吃到的输入。
React 18:事件优先级 → Lane 的直接映射(本章 §14.4 主题)
React 18 做了两件事——
- 把事件优先级从三个字符串常量改成直接用 Lane 值定义——
DiscreteEventPriority = SyncLane、ContinuousEventPriority = InputContinuousLane、DefaultEventPriority = DefaultLane - 让 concurrent root 成为默认——这些 Lane 从此真正驱动可中断、可抢占的调度
这一下、事件优先级从"纸面约定"变成了"渲染调度器真正吃到的输入"。滚动里的 setState 从此真的会被打到 InputContinuousLane、会被更高优先级的 click(SyncLane)抢占。
还有一件配套的大事——全局自动批处理(§14.4.3 和 §14.11 交汇三)。React 17 只在 React 事件回调里批处理、18 把批处理扩到所有 setState(setTimeout、Promise.then、fetch 回调都包括)。这依赖事件系统和调度器协作、只有在 Lane 模型 + ensureRootIsScheduled 微任务合并双双到位后才能做到。
React 19 / main:名单早已定型、只做边际补充
一个流传很广的误读是"React 19 把 Discrete 列表扩大了一倍"。对照源码即可证伪:auxclick、contextmenu、reset、selectionchange、hashchange、popstate、fullscreenchange、volumechange、ratechange、seeked 这些事件在 React 18.0 的 Discrete 分支里就已经全部在列(v18.2.0 的 ReactDOMEventListener.js 可查、共约 50 个)——§14.4.1 贴的 20 个是删减示意、不是 18 的全集。React 19 在这份名单上的真实改动只有两处边际调整:新增 beforetoggle(配合 <dialog> / popover 支持落地)、并把 toggle 从 Continuous 挪进 Discrete。
判断标准从 18 起就没变过——Discrete 适用于用户单次主动决策触发的事件。所以 seeked、volumechange 这类乍看像"连续"的事件、语义上是用户拖完进度条/调完音量松手的那一瞬、从 18.0 起就在 Discrete。React 19 时期事件系统的真正增量不在优先级名单、而在新交互原语:FormActionEventPlugin(Server Actions 的 <form action={fn}>)与 19.1 的 ScrollEndEventPlugin(§14.13)。
演进逻辑复盘——
- React 17 事件优先级只是内部约定、不影响渲染调度
- React 18 事件优先级变成调度器输入、滚动真的让路,Discrete 名单也在此时定型
- React 19 名单只做边际补充、增量转向表单 Action 与 scrollend 这类新交互原语
这条路径和整个 React 并发模型的演进完全一致——17 铺路、18 落地、19 打磨——本专栏第 4 章(Scheduler)与第 9 章(并发 / Lane 映射)其实也是这个节奏。事件系统是"入口"、Lane 是"货架"、Fiber 是"货物"、Scheduler 是"收银员"——三个版本的演进就是让这条流水线从"各走各的"变成"真正同步工作"的过程。
14.13 ScrollEndEventPlugin:一个插件独占一个事件的动机
§14.10 新列出的 ScrollEndEventPlugin(217 行)是个值得单独说说的案例——它只处理一个事件 scrollend、却需要独立一个插件文件。为什么不合并进 SimpleEventPlugin?
答案藏在浏览器兼容性里——scrollend 是 2023 年才进入各大浏览器的新事件、Safari 更是到 16.4 才开始支持。即便 2026 年的今天、仍有相当比例的用户在用不支持原生 scrollend 的浏览器。React 如果想让开发者安心写 onScrollEnd 而不必关心底层、就必须自己做 polyfill——监听 scroll、用 setTimeout 去抖、在滚动停止一小段时间后合成一个 scrollend 事件(源码常量 DEBOUNCE_TIMEOUT = 200,即 200ms)。
这种"polyfill 级"逻辑的特点是——
- 状态复杂——要维护"当前是否在滚动"、"setTimeout 的 token"、"是否有原生 scrollend 已经触发过"三个状态
- 需要特殊分发路径——ScrollEnd 的 listener 收集用
accumulateTargetOnly = !inCapturePhase、意思是"冒泡阶段只在 target 节点收"——因为原生 scrollend 不冒泡、React 为了语义一致也不让它冒泡 - 监听的原生事件和暴露的 React 事件是 N-to-1——源码通过
registerTwoPhaseEvent('onScrollEnd', [...])注册的原生依赖是scroll/scrollend/touchstart/touchcancel/touchend/mousedown/mouseup(没有touchmove/wheel),对外只暴露onScrollEnd
这种状态复杂度塞进 SimpleEventPlugin 会把后者搞脏。独立文件的真正代价不过是 200 多行 + 一次 import——收益却是让 SimpleEventPlugin 保持"纯映射"的优雅。这也印证了前面讲的架构启示——插件系统的价值,就是让复杂性有地方可去、而不是被迫集中到一个巨型 switch 里。
顺带一个细节——去抖常量 DEBOUNCE_TIMEOUT 取 200ms,源码注释解释得很实在:滚动减速时原生 scroll 事件的频率会变得很低,这个值要高到不至于把"还在慢慢滚"误判成"已停止"、又要低到不至于让合成的 scrollend 来得太迟。
14.14 本章小结
React 的合成事件系统是一个精妙的工程作品。它在浏览器原生事件模型之上构建了一层完整的抽象,实现了以下目标:
- 跨浏览器一致性:通过 SyntheticEvent 和标准化接口,消除了浏览器差异
- 性能优化:事件委托到 root 容器,避免了大量的 addEventListener 调用
- 优先级控制:事件类型与 Lane 优先级的映射,让不同交互获得不同的调度待遇
- 多实例隔离:从 document 迁移到 root 容器,解决了微前端场景的事件冲突
- 渲染器无关:事件的收集和分发基于 Fiber 树而非 DOM 树,支持 Portal 等抽象
- 渐进式简化:从 React 17 到 19,不断移除历史包袱,让事件系统更轻量
理解事件系统的核心在于理解这条链路:原生事件 → root 监听器 → 优先级设定 → Fiber 节点定位 → 插件提取 → 监听器收集 → 分发执行 → 调度更新。每个环节都与 React 的其他核心系统(Fiber 树、调度器、Lane 模型)紧密协作。
思考题
-
React 为什么选择在 root 容器上一次性注册所有事件,而不是在组件首次使用时按需注册? 按需注册在某些场景下可以减少初始化开销,但会引入什么问题?试从事件系统的确定性和可预测性角度分析。
-
考虑以下场景:一个
onScroll事件处理器中调用了setState,同时一个onClick处理器中也调用了setState。 如果两个事件在同一帧内触发,React 如何决定这两个更新的执行顺序?它们会被批量处理吗? -
Portal 中的事件按 Fiber 树冒泡而非 DOM 树冒泡。 这个设计在大多数场景下是合理的,但你能构造一个它会导致反直觉行为的场景吗?如果 React 改为按 DOM 树冒泡,又会破坏哪些现有的使用模式?
-
事件系统的多项简化发生在不同大版本。 事件池化停用、
onScroll不再冒泡等发生在 React 17;React 19 对 Discrete 清单与遗留兼容主要是边际调整/延续(例如beforetoggle纳入 Discrete),不宜写成“19 继续大幅收敛并删掉 IE 路径”——ChangeEventPlugin的propertychangepolyfill 在 v19.0.0 仍保留。假设你正在维护一个大型应用从 17/18 升级到 19,列出需要按实际变更版本分别检查的事件相关点,并说明如何用自动化脚本识别仍调用e.persist()、依赖 scroll 冒泡或混用 document 级委托假设的代码。