Skip to content

事件机制和合成事件

React 的事件委托

React 将事件委托应用到了整个应用层面:它并不会在每个 DOM 节点上独立绑定监听器,而是把监听器挂在一个统一的顶层节点上。

监听位置的演进:

  • React 17 之前:所有事件监听器统一注册在 document 上。
  • React 17 及之后:监听器注册在 React 根节点(root container)上。

    这样可以更好地支持 微前端 和多个 React 版本并存的场景,避免冲突。

无论组件层级多深,事件都会沿着 React 管理的 Fiber 冒泡回到根节点,由唯一的监听器处理。原生事件被捕获后,React 会将其包装成 SyntheticEvent(合成事件)对象,并在组件树中分发。

SyntheticEvent 的作用

  • 跨平台一致性:合成事件统一了不同浏览器之间的事件属性和行为,提供一致的 API。
  • 事件命名与行为规范:例如 onChange 在 React 中抽象为“受控输入变化”,而不是浏览器原生 change 的触发时机。
  • 与原生事件的桥梁SyntheticEvent 仍然暴露 nativeEvent,在需要底层能力时可以访问原生事件对象。

事件对象生命周期的演进

  • React 16 及之前:为了减少对象创建带来的 GC 开销,React 会复用事件对象(event pooling)。回调执行完毕后,合成事件的字段会被清空,因此异步访问会报错。这就是 e.persist() 最初存在的原因。
  • React 17 开始:事件池被彻底移除。React 17 和 18 中,合成事件不再被复用,也不会在回调结束后被清空。e.persist() 成为一个 no-op(调用了也不会改变行为)。

因此,在 React 18 中:

  1. 可以在异步回调里直接访问 SyntheticEvent 的属性;
  2. 不再需要显式调用 e.persist()
  3. e.persist() 虽然仍存在,但仅为了历史兼容,不会产生任何效果。

React 18 中的异步访问最佳实践

即便不再有事件池,仍建议在异步逻辑中“取值即用”,而不是把整个事件对象传来传去:

js
function handleClick(e) {
  // 直接异步访问在 React 18 中是安全的
  setTimeout(() => {
    console.log(e.type) // "click"
    console.log(e.currentTarget.dataset.id)
  }, 100)
}

更稳妥的做法是,从事件对象中提取你真正需要的字段:

js
function handleClick(e) {
  const { type } = e
  const { value } = e.target

  setTimeout(() => {
    console.log(type)
    console.log(value)
  }, 100)
}

这种写法更利于代码可读性,也能避免在事件对象结构未来发生变化时引入潜在风险。

React 访问原生事件

如果需要底层的原生事件对象,可以使用 e.nativeEvent

两个“冒泡”

  1. 原生事件冒泡:在真实 DOM 树中,从 target 到 root。
  2. 合成事件分发(模拟冒泡):在虚拟组件树中,从源组件到其所有父组件。这一步只是 React 在 JS 层模拟传播,不会触发 DOM 的第二轮冒泡。

如果在合成事件上调用 stopPropagation(),React 会阻止后续节点接收该合成事件,并同步阻止原生事件继续往上冒泡。

基于 MIT 许可发布