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;
}

图 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);
}

基于 VitePress 构建