Skip to content

第14章 合成事件系统

本章要点

  • 事件委托的演进:从 React 16 的 document 委托到 React 17+ 的 root 容器委托
  • SyntheticEvent 的设计哲学:跨浏览器一致性与性能的平衡
  • 事件插件系统(Event Plugin System)的架构与注册机制
  • 事件优先级模型:Discrete、Continuous、Default 三级优先级与 Lane 的映射
  • listenToAllSupportedEvents:React 如何在挂载时一次性注册所有事件监听
  • dispatchEvent 的完整链路:从原生事件到 React 回调的调度过程
  • React 19 中事件系统的简化:移除事件池化与遗留兼容逻辑

如果你在 React 组件上写过 onClickonChangeonScroll,你可能从未思考过一个问题:这些事件处理器实际上并没有绑定在你期望的那个 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 支持的所有原生事件名——clickkeydownscrollpointerdown 等等。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;
}

基于 VitePress 构建