主题
事件机制和合成事件
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 中:
- 可以在异步回调里直接访问
SyntheticEvent的属性; - 不再需要显式调用
e.persist(); 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。
两个“冒泡”
- 原生事件冒泡:在真实 DOM 树中,从 target 到 root。
- 合成事件分发(模拟冒泡):在虚拟组件树中,从源组件到其所有父组件。这一步只是 React 在 JS 层模拟传播,不会触发 DOM 的第二轮冒泡。
如果在合成事件上调用
stopPropagation(),React 会阻止后续节点接收该合成事件,并同步阻止原生事件继续往上冒泡。
