学完这篇,你能搞清 React 合成事件到底把监听器挂在了哪里、一个 onClick 从点击到回调中间经过了什么、以及为什么有时候你的 stopPropagation 看起来"失灵"了。
第一步:先确认原生事件委托的基本盘
浏览器的事件流分三段:捕获(从 window 往下到目标)→ 目标 → 冒泡(从目标往上回 window)。大多数场景用的是冒泡阶段。
事件委托就是利用冒泡:不给 1000 个列表项各绑一个监听器,只在它们的父节点绑一个,靠 `e.target` 判断是哪个子元素被点了。
list.addEventListener('click', (e) => {
const item = e.target.closest('li[data-id]');
if (!item) return;
console.log('点击了', item.dataset.id);
});
好处有两个:一是监听器数量从 N 变 1,二是后动态插入的 li 也不用重新绑。
第二步:React 干的就是同一件事,只是挂载点变了
React 不会给每个 `<button onClick>` 单独绑 addEventListener,它只在根节点上为每种事件类型注册一个监听器,事件冒到根节点后,React 顺着 Fiber 树找到对应的组件回调,依次执行(模拟捕获和冒泡)。
这个包装出来的事件对象就是 SyntheticEvent,它抹平了浏览器差异,并提供了和原生一致的关键方法:`preventDefault()`、`stopPropagation()`、`target`、`currentTarget`。真正的事件对象在 `e.nativeEvent` 里。
关键差异在挂载点:
- React 16 及以前:挂在 `document` 上
- React 17 及以后(含 18/19):挂在 `createRoot` 的 root 容器上
注意:这个改动是破坏性的。如果你的代码依赖"在 document 上监听 click",React 17 之后执行顺序会反过来。
第三步:写个 demo 把顺序打出来
import { createRoot } from 'react-dom/client';
const rootEl = document.getElementById('root');
createRoot(rootEl).render(
<button onClick={() => console.log('React onClick')}>点我</button>
);
// render 之后再加两处原生监听
rootEl.addEventListener('click', () => console.log('root 容器(原生)'));
document.addEventListener('click', () => console.log('document(原生)'));
点击按钮,在 React 17+ 下输出顺序是:
React onClick
root 容器(原生)
document(原生)
原因很清楚:React 的委托监听也挂在 `rootEl` 上,且注册时间更早,同一元素同一阶段按注册先后依次执行;冒泡继续往上,才轮到 document。
第四步:理解 stopPropagation 的边界
`e.stopPropagation()` 在合成事件里会同步调用 `e.nativeEvent.stopPropagation()`,所以它能阻止事件继续往上冒泡。
但有两个坑:
- 它拦不住同一元素、同一阶段的其他监听器。 在 root 容器上你自己也绑了 click,React 回调里调 stopPropagation,你的那个监听照样执行——要拦住得用 `stopImmediatePropagation()`。
- React 16 的失效感更明显。 那时 React 的监听就在 document 上,你在 document 上另绑的监听和它同元素同级,stopPropagation 完全挡不住,顺序只由注册先后决定。React 17+ 因为监听下移到了 root 容器,`stopPropagation` 反而能真的挡住 document 上的监听。
注意:如果项目里有第三方库在 document 上做全局点击关闭弹层,这个差异会直接导致"弹层点不开"或"关不掉"。
第五步:合成事件对象的两个使用要点
- 异步里别用 `e.currentTarget`。 事件派发结束后它会被置空,需要异步使用就先 `const el = e.currentTarget` 存起来。`e.target` 和 `e.nativeEvent` 相对安全,但建议一并存变量。
- `e.persist()` 已经不需要了。 React 16 有事件池化,回调结束会回收事件对象,所以要 persist;React 17 起移除了池化,直接异步用也保留数据。
第六步:什么时候该绕开 React,用原生监听
- 监听不冒泡的事件,比如 `scroll`、`mouseenter`(React 自己用捕获模拟了,但自定义场景仍需注意)
- 和地图、富文本、拖拽库混用,它们内部直接操作 DOM,用原生 `addEventListener` 更可控
- 超长列表要在"最近公共父级"上做委托,而不是绑到 root
注意:在 `useEffect` 里加监听,一定要返回清理函数 `removeEventListener`,否则热更新或组件重挂会重复绑定,回调执行多次。
小结
- React 的事件本质就是事件委托:一种事件类型,根节点上只注册一个监听器。
- 挂载点:React 16 在 `document`,React 17+ 在 root 容器,这是执行顺序变化的根源。
- 合成事件不是假事件,它由原生事件驱动,`e.nativeEvent` 随时可取。
- `stopPropagation` 只阻止向上冒泡,拦不住同元素同阶段的其他监听器。
- 需要跨库协作、监听非冒泡事件时,直接用原生 `addEventListener`,并记得清理。