文章

合成事件系统原理

合成事件系统原理

一句话概括

React 不在每个 DOM 上绑定事件,而是在根容器上统一监听,通过 Fiber 树模拟事件的捕获-冒泡传播路径。这种”合成事件”机制统一了跨浏览器行为、减少了内存占用,并且天然与 Fiber 的并发渲染协调。

核心知识点

1. 事件委托:一个监听器管所有

1
2
3
4
5
6
7
8
9
10
11
12
// 你不会在每个 <button> 上 addEventListener
// React 只在 rootNode 上注册一次:
// rootNode.addEventListener('click', dispatchEvent, false);

function App() {
  return (
    <div onClick={() => console.log('container')}>
      <button onClick={() => console.log('btn')}>点击</button>
    </div>
  );
}
// 点击 button:事件冒泡到 rootNode → React 从 Fiber 树找到 button 和 div 的回调 → 依次执行

React 17 把委托点从 document 改到 rootNode,解决多 React 实例共存时的事件顺序冲突。每个 createRoot(container) 独立管理自己的事件系统。

2. 事件插件的三层架构

1
2
3
4
5
第一层:原生事件 → 浏览器冒泡到 rootNode
第二层:事件插件 → SimpleEventPlugin(click/keydown)
                 → ChangeEventPlugin(onChange)
                 → BeforeInputEventPlugin(中文输入)
第三层:调度执行 → 创建 SyntheticEvent → 沿 Fiber 链遍历收集回调 → 按捕获→目标→冒泡执行

每个事件名都有专属插件。onChange 最特殊——React 把它从原生”失焦后触发”改成了”每次输入都触发”,背后靠 compositionstart/compositionend 判断中文输入法状态。

3. e.stopPropagation() vs e.nativeEvent.stopPropagation()

1
2
3
4
5
6
7
8
9
10
11
12
13
function Demo() {
  return (
    <div onClick={() => console.log('React 父容器')}>
      <button onClick={e => {
        e.stopPropagation();                    // 只阻止 React 事件链
        // e.nativeEvent.stopPropagation();     // 阻止原生事件冒泡
      }}>点我</button>
    </div>
  );
}
// e.stopPropagation() → "React 父容器" 不会打印
// 但 div 上如果通过 addEventListener 绑了原生事件 → 仍然会触发!
// 因为 React 的所有事件都注册在 rootNode,不是在每个 div/button 上

e.stopPropagation() 实际上是设置 _isPropagationStopped = true,React 遍历 Fiber 父链时检查到就停止。它管不了原生 DOM 冒泡。

4. React 17 移除事件池

1
2
3
4
5
6
7
8
9
// React 16:异步读 e 会报 null
function handleClick(e) {
  setTimeout(() => console.log(e.type), 100); // null!需要 e.persist()
}

// React 17+:不需要了,每次创建新事件对象
function handleClick(e) {
  setTimeout(() => console.log(e.type), 100); // "click" ✅
}

移除原因:(1) 开发者总忘 e.persist(),Bug 频发 (2) 现代浏览器 GC 足够快,池化收益微乎其微 (3) 池中闲置对象反而占内存。

5. React onChange 的跨浏览器魔法

1
2
3
4
5
6
7
8
9
10
// 原生 onchange:失焦后才触发
// 原生 oninput:每次键盘事件都触发(包括中文输入中间状态)
// React onChange:每次值变化触发,中文输入完成才触发一次

function Input() {
  return <input onChange={e => {
    // 输入法拼音阶段不触发,选字完成后才触发
    console.log(e.target.value);
  }} />;
}

React 内部通过监听 compositionstart / compositionend 来判断输入法是否在进行中——这是你无需关心浏览器差异的真正原因。

其实你每天都在用

  • 表单 onChange:每次打字”实时更新 state”,实际是 React 把多个浏览器事件(input/change/composition)统一成了一个 onChange
  • 事件对象阻止默认行为:React 里 e.preventDefault() 在所有浏览器行为一致,不用写 e.returnValue = false 之类的 IE 兼容代码
  • 大列表点击不用操心内存:1000 个 <li> 上的 onClick 实际只在 rootNode 上有 1 个监听器,不像原生那样 1000 个监听器
  • React DevTools 显示事件:开发者工具能显示每个 Fiber 上绑定的事件,本质是读 React 的 props 而非 DOM 属性

常见误解

  • ❌ 误区:「e.stopPropagation() 能阻止原生事件冒泡」 不能!它只阻止 React Fiber 链上父组件回调的执行。要阻止原生冒泡用 e.nativeEvent.stopPropagation()

  • ❌ 误区:「React 在组件卸载时自动清除事件监听」 它不需要清除——因为事件是注册在 rootNode 上的,rootNode 不会卸载。React 只是不再为已卸载的 Fiber 收集回调。

  • ❌ 误区:「合成事件和原生事件执行顺序是固定的」 不是。原生事件(addEventListener 绑的)在冒泡阶段先于 React(因为 React 监听在 rootNode,它们先经过中间的元素)。Vue 事件因为是直接绑在元素上的,顺序不同。

  • ❌ 误区:「e.persist() 还有用」 React 17 起 e.persist() 是空操作,只为向后兼容保留。SyntheticEvent 现在每次都是全新对象。

一句话总结

合成事件 = 根容器委托 + 事件插件分派 + Fiber 树路径模拟——React 用这一套机制让你用 onClick 就像用原生事件一样自然,但背后少了几百个事件监听器和无数跨浏览器兼容代码。

本文由作者按照 CC BY 4.0 进行授权