陌上人如玉
公子世无双

4.9 合成事件系统

4.9 合成事件系统

一句话:React 自己实现了一套合成事件(SyntheticEvent)系统——事件统一委托到根容器、跨浏览器行为一致、与 React 的更新机制协同;React 17 起委托目标从 document 改为根容器。

一、合成事件的实现原理

三个核心部分:

① 事件委托(Event Delegation)
   React 不为每个元素绑监听器,而是在【根容器】上绑定「所有支持的事件类型」

② 合成事件对象(SyntheticEvent)
   在原生事件触发后,React 创建一个「跨浏览器一致」的事件对象传给处理函数
   它提供统一的 API:preventDefault / stopPropagation / target / currentTarget…

③ 插件化的分发
   React 用「插件系统」处理不同事件类别(SimpleEvent / ChangeEvent / EnterLeave…)
   → 决定「哪些内部监听器需要被触发」以及「事件对象如何构建」
// 你写的代码
<div onClick={handleClick} />

// React 内部大致做了这些(简化):
// ① 在根容器上监听 click(一次性)
const rootContainer = document.getElementById('root');
rootContainer.addEventListener('click', dispatchEvent);       // React 16 是在 document 上

// ② click 冒泡到根容器 → React 找到「目标 fiber」并构建合成事件
function dispatchEvent(nativeEvent) {
  const target = nativeEvent.target;
  const targetFiber = getClosestInstanceFromNode(target);      // 从 DOM 找到对应 fiber
  const syntheticEvent = createSyntheticEvent(nativeEvent);     // 包装成合成事件

  // ③ 收集「从 target 到 root 的整条路径上所有 onClick」
  const listeners = collectListeners(targetFiber, 'click');

  // ④ 按「捕获 → 冒泡」的顺序依次调用
  invokeListeners(listeners, syntheticEvent);
}

二、事件委托到根容器

React 16 及之前:所有监听挂在 document 上
React 17 及之后:挂在【根容器】上(createRoot 传入的那个 DOM 节点)

const root = createRoot(document.getElementById('root'));
                          ↑ 事件委托到这个容器
React 17 改变委托目标的原因:

① 支持「多版本 React 共存」
   微前端/渐进式升级时,不同区域可能是不同版本的 React。
   都挂在 document 上会互相干扰(事件被多次处理);挂在各自的根上则互不影响。

② 更贴近原生语义
   「事件不应该冒泡出根容器」变成现实——因为监听器就在根上,
   根容器之外的「原生」监听器会先收到事件。

③ 多个根渲染互不影响
   document.body 上挂多个 createRoot,事件不会串。
// 可观察的差异示例
document.addEventListener('click', () => console.log('document 上的原生监听'));

function App() {
  return <button onClick={() => console.log('React onClick')}>点我</button>;
}

// React 16:点击时
//   → document 上的原生监听【后】执行(因为 React 的监听也在 document,
//     但 React 的处理在事件冒泡到 document 后才分发)→ 顺序容易混淆
// React 17+:点击时
//   → document 上的原生监听【先】执行(它比根容器更外层)
//   → 然后才是 React 的分发
// 这个差异在「混用 React 事件与原生监听」时很关键。

三、事件分发的完整流程

点击 <button>(它在 <div> 里,div 是根容器的子节点):

① 浏览器派发原生 click 事件(target = button)
② 事件冒泡到根容器 → React 的「根监听器」被触发
③ React 从 event.target 找到对应的 fiber 节点
④ 从该 fiber 开始,沿「return(父)」指针一路向上,收集:
   · 所有 onClick(冒泡阶段)
   · 所有 onClickCapture(捕获阶段)—— 分别收集
⑤ 创建合成事件对象(包装原生事件)
⑥ 按「捕获阶段(由外到内)」执行,再按「冒泡阶段(由内到外)」执行
⑦ 处理完后,如果事件是「离散的」→ 用同步优先级 flush(保证更新及时)
   如果是「连续的」(如 mousemove)→ 用较低优先级
// 捕获与冒泡的执行顺序(React 的合成事件里两者都支持)
<div onClickCapture={() => console.log('1. div 捕获')} onClick={() => console.log('4. div 冒泡')}>
  <button onClickCapture={() => console.log('2. button 捕获')} onClick={() => console.log('3. button 冒泡')}>
    点我
  </button>
</div>
// 输出:1 → 2 → 3 → 4(与原生一致)

四、事件与更新优先级

React 会根据「事件类型」决定更新的优先级(见 4.8):

① 离散事件(discrete):click、keydown、input…
   → SyncLane(同步执行,保证「点击后界面立刻响应」)

② 连续事件(continuous):mousemove、scroll、drag…
   → InputContinuousLane(较低,避免拖动时丢帧)

③ 其它(如网络回调)
   → DefaultLane

这也是「为什么点击里的 setState 表现得像『同步』」的原因之一
(它用的高优先级车道,会尽快 flush)。

五、onChange 的映射(高频考点)

⚠️ React 的 onChange ≠ 原生的 change

原生:
  · input 事件 → 每次输入都触发
  · change 事件 → 文本框失焦后才触发

React:
  · onChange → 等价于原生的 input 事件(每次输入都触发)
  · 需要在「失焦时」处理 → 用 onBlur
元素 React 的 onChange 对应原生事件
<input type="text"> input
<textarea> input
<input type="checkbox/radio"> click(React 内部转换,读 e.target.checked)
<select> change(也监听 input)
<input type="file"> change(无法受控)
// React 的实现思路(简化):
// 监听原生 input/change 事件,然后「统一」派发一个合成 onChange
// 这也是为什么 React 的 onChange 对「所有表单元素」都能用同样的写法

<input onChange={(e) => console.log(e.target.value)} />         // 每次输入
<input onBlur={(e) => console.log(e.target.value)} />           // 失焦时

六、与原生事件的关系

① e.nativeEvent —— 拿到原生事件对象
   onClick={(e) => {
     console.log(e);                 // 合成事件
     console.log(e.nativeEvent);     // 原生 MouseEvent
     console.log(e.nativeEvent.target);
   }}

② 原生监听器与 React 事件都会触发(除非阻止)
   · React 的 stopPropagation 【不会】阻止「绑在根容器/根容器之外的」原生监听器
     (因为那些监听器在 React 的分发逻辑之外)
   · 原生事件的 stopPropagation 会影响 React(React 依赖原生冒泡到根容器)

③ 需要「原生行为」时(如 passive、capture 选项、非标准事件)用 ref 手动绑定
   useEffect(() => {
     const el = ref.current!;
     const onWheel = (e: WheelEvent) => { /* 需要 preventDefault 且 passive:false */ };
     el.addEventListener('wheel', onWheel, { passive: false });
     return () => el.removeEventListener('wheel', onWheel);
   }, []);
// 一个实际的坑:点击外部关闭
// ⚠️ 不能用合成事件(React 委托在根容器上,判断「外部」的语义不清晰)
// ✅ 用原生监听
useEffect(() => {
  const onPointerDown = (e: PointerEvent) => {
    if (!ref.current?.contains(e.target as Node)) onClose();
  };
  document.addEventListener('pointerdown', onPointerDown);
  return () => document.removeEventListener('pointerdown', onPointerDown);
}, [onClose]);

七、事件池(React 16 的历史)

// React 16:事件对象会被「池化复用」→ 异步访问拿到 null
const handleClick = (e) => {
  setTimeout(() => {
    console.log(e.type);        // ❌ React 16 里是 null
  }, 0);
};

// React 16 的解法
e.persist();                    // 阻止回收

// React 17+:事件池已移除 → 异步访问完全正常
// e.persist() 保留为空操作(兼容旧代码)
版本 事件池
React 16 及之前 ✅ 有(异步访问会得到 null,需要 e.persist())
React 17+ ❌ 已移除(e.persist() 是空操作)

八、常见坑

// ① 认为「React 事件是原生事件的简单包装」
// → 它是「委托在根容器 + 自己实现分发 + 合成对象」的完整系统

// ② 对「根容器之外的原生监听」用 stopPropagation
<button onClick={(e) => { e.stopPropagation(); }} />
// ⚠️ 这不会阻止 document/window 上的原生监听

// ③ 用「合成事件」实现点击外部关闭
// ✅ 用原生监听 + ref.contains

// ④ 认为「React 16 里异步拿不到 e 是 bug」
// → 那是事件池(为了性能复用对象),React 17 已移除

// ⑤ 写成小写的事件名
<button onclick={handle} />       // ⚠️ React 不识别(会警告)
<button onClick={handle} />       // ✅

// ⑥ 在 onChange 里期待「失焦才触发」
// → React 的 onChange 是「实时」的(等价原生 input)

// ⑦ 认为 onFocus/onBlur 不冒泡
// → 在 React 里它们【会冒泡】(内部用 focusin/focusout 语义)

// ⑧ 认为 onScroll 会冒泡
// → React 的 onScroll 不冒泡,需要绑在具体的滚动容器上

// ⑨ 需要 passive 监听却用合成事件(React 无法直接指定)
// ⑩ 在事件处理里做「重计算」阻塞了高优先级的更新
// ⑪ 用 e.target 与 e.currentTarget 混用(前者是「最初触发的元素」)
// ⑫ 在异步回调里访问 e.currentTarget(会变成 null)
setTimeout(() => console.log(e.currentTarget), 0);   // null
// ✅ 先取出:const el = e.currentTarget;

九、面试延伸

  1. 「什么是合成事件?React 为什么要自己实现一套事件系统?」
    合成事件是 React 在原生事件之上封装的跨浏览器一致的事件对象。React 自己实现它的原因有三个:① 跨浏览器一致性——统一属性与行为(preventDefault、事件对象的属性名等),屏蔽历史差异;② 性能与事件委托——把事件统一委托到根容器,1000 个列表项的点击只需一个监听器,也避免了为动态节点反复绑定;③ 与更新机制协同——React 知道「这次更新发生在事件里」,从而正确批处理、并按事件类型分配优先级(点击是 SyncLane)。

  2. 「React 的事件绑在哪里?React 17 有什么变化?」
    React 把事件委托到根容器上(createRoot 传入的 DOM 节点)。React 16 及之前挂在 document,React 17 起改到根容器。改的原因:① 支持多版本 React 共存(微前端、渐进式升级时不会互相干扰);② 更贴近原生语义(「事件不应冒泡出根容器」成立);③ 多个根渲染互不影响。

  3. 「React 的事件分发是怎样的?」
    原生事件冒泡到根容器 → React 从 event.target 找到对应的 fiber → 从该 fiber 沿「return(父)指针」向上收集所有同类型监听器(捕获与冒泡分别收集)→ 创建合成事件对象 → 先执行捕获阶段(外→内)、再执行冒泡阶段(内→外)。此外 React 会按事件类型分配优先级(离散事件 Sync、连续事件 InputContinuous),并在处理完后按优先级 flush 更新。

  4. 「React 的 onChange 和原生 change 是同一个事件吗?」
    不是。React 的 onChange 等价于原生的 input 事件(每次输入都触发),而原生的 change 对文本框来说是失焦后才触发。这是 React 有意为之的设计——让受控组件能实时拿到值。如果需要「失焦才处理」,应该用 onBlur。此外 checkbox/radio 的 onChange 内部基于 click,要读 e.target.checked。

  5. 「e.nativeEvent 是什么?」
    是原生事件对象。合成事件是原生事件的包装层,e.nativeEvent 让你能拿到未被包装的原始对象(如需要访问 React 没暴露的属性、或判断 e.nativeEvent.isComposing 处理中文输入)。一般的跨浏览器操作(preventDefault、stopPropagation、target)用合成事件就够了。

  6. 「React 的 stopPropagation 能阻止原生监听吗?」
    不能。React 的 e.stopPropagation() 阻止的是「合成事件体系内的传播」(React 自己维护的虚拟冒泡路径与监听器收集)。而绑在 document 或根容器之外的原生监听器在 React 的分发逻辑之外,不受影响。反过来,原生事件里的 stopPropagation 会影响 React(因为 React 依赖原生事件冒泡到根容器)。这个差异在「点击外部关闭」这类交互里很关键。

  7. 「onFocus/onBlur/onScroll 在 React 里会冒泡吗?」
    onFocus 和 onBlur 会冒泡——原生 focus/blur 不冒泡(原生用 focusin/focusout),但 React 特意让它们冒泡以便用事件委托统一处理表单焦点;onScroll 不冒泡(与原生一致),必须绑在具体的滚动容器上。这些「与原生不一致」的细节是合成事件的常见考察点。

  8. 「React 16 里为什么在 setTimeout 中拿不到事件对象?」
    因为 React 16 实现了事件池(event pooling)——为了减少对象创建开销,事件对象在回调执行完后会被「回收复用」(属性被清空),所以异步访问得到 null。当时的解法是调用 e.persist() 阻止回收。React 17 起事件池已移除,异步访问完全正常(e.persist() 保留为空操作以兼容旧代码)。

一句话速记

React 用「合成事件」自己实现了一套事件系统:事件委托到根容器(React 17 起从 document 改为 root,为了多版本共存与更贴近原生语义)、跨浏览器一致、按事件类型分配更新优先级(点击 = SyncLane);分发流程是「原生冒泡到根 → 从 target 找 fiber → 沿 return 收集监听 → 捕获(外→内)→ 冒泡(内→外)」;e.nativeEvent 才是原生对象,React 的 stopPropagation 拦不住根容器之外的原生监听;onChange 等价于原生 input(失焦用 onBlur)、onFocus/onBlur 会冒泡、onScroll 不冒泡;事件池在 React 17 已移除。

赞(0) 打赏
未经允许不得转载:陌上寒 » 4.9 合成事件系统

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫