陌上人如玉
公子世无双

12.10 为什么要合成事件

12.10 为什么要合成事件

一句话:合成事件(SyntheticEvent)是 React 自己实现的一套跨浏览器一致的事件系统——它把事件统一委托到根容器,既解决了「浏览器差异」,又带来「性能与调度」上的好处;React 17 起委托目标从 document 改为根容器。

一、结论先行

问题:React 为什么要自己做一套事件系统(合成事件)?

一句话回答:
  三个理由——「跨浏览器一致性」「性能(事件委托,不为每个元素绑监听)」
  「与 React 的更新机制协同(批处理 + 优先级)」。

⭐ 三个理由的关键词:
① 一致性:屏蔽浏览器差异,统一 API
② 性能:委托到根容器(1000 个列表项只有 1 个监听器)
③ 协同:让「事件处理里的 setState」能被正确批处理与分优先级

二、合成事件是什么

function Button() {
  const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
    console.log(e.type);           // 'click'(合成事件的属性)
    console.log(e.target);         // 实际触发的最深元素
    console.log(e.currentTarget);  // 绑定监听器的元素
    console.log(e.nativeEvent);    // ⭐ 原生事件对象
    e.preventDefault();            // 统一 API
    e.stopPropagation();           // 统一 API
  };

  return <button onClick={handleClick}>点我</button>;
}
⭐ 它是什么:
  原生事件的「跨浏览器包装」——提供统一的属性与方法
  (preventDefault / stopPropagation / target / currentTarget / ...)

⚠️ 它不是「简单的 Proxy」:
  · 它是 React 自己实现的事件对象(有「合成事件池」的历史,React 17 已移除)
  · 它绑定的位置不是元素,而是【根容器】
  · 它的「冒泡路径」是 React 自己按「组件树」算出来的

三、为什么要用它(三个理由展开)

理由一:跨浏览器一致性

// 早期浏览器差异(现在已大幅收敛,但历史上是真实痛点)
// IE8:需要 attachEvent,事件对象在 window.event
// 其它:addEventListener,事件对象作为参数
// 各种浏览器对 event.target / preventDefault / stopPropagation 的实现都有差异

// ✅ React 提供统一 API(开发者不用关心浏览器差异)
e.preventDefault();
e.stopPropagation();
e.target;
e.currentTarget;
⭐ React 内部做的事:
① 用「插件系统」处理不同事件类别
   (SimpleEvent / ChangeEvent / EnterLeave / SelectEvent / ...)
② 统一事件对象的属性名与行为
③ 处理「焦点/输入/选择」等「跨浏览器行为不一致」的事件

⭐ 现代意义:
   浏览器差异虽已收敛,但「统一 API + 统一语义」依然有价值
   (尤其是「React 自己定义了一些差异」,如 onChange 等价于原生 input)。

理由二:事件委托(性能)

// 直觉写法:1000 行的列表,每行都有 onClick
{list.map((item) => (
  <tr key={item.id} onClick={() => handleSelect(item.id)}>
    ...
  </tr>
))}
❌ 如果「为每个元素绑监听器」:
   → 1000 个监听器(内存 + 绑定/解绑开销)
   → 列表增删时要「手动绑定/解绑」(在 React 里被隐藏了,但机制上需要)

✅ React 的方式:
   → 只在【根容器】上绑「每种事件类型」一个监听器(如 click 只需要 1 个)
   → 事件冒泡到根容器后,React 从 event.target 找到对应 fiber
   → 再从该 fiber 沿「return(父)指针」向上收集所有同类型的处理函数
   → 依次调用

⭐ 好处:
① 监听器数量与「DOM 元素数量」解耦(只与「事件类型数量」相关)
② 「动态增删的元素」自动生效(因为监听在根上)
③ 「React 的 stopPropagation」可以在「收集阶段」就生效(不用真的依赖 DOM 冒泡)
⭐ 一个常见的误解要澄清:
   「React 用事件委托 = 更快」
   → 更准确地说:它「减少了监听器数量与内存」,但「分发时要遍历 fiber 链」
     所以「性能收益」主要在「大量元素 + 频繁增删」的场景,
     而不是「每次点击都更快」。

理由三:与更新机制协同(批处理与优先级)

function Counter() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount((c) => c + 1);
    setCount((c) => c + 1);
    // ✅ 会被批处理成「一次渲染」
    // (React 知道「这次更新发生在事件处理里」)
  };

  return <button onClick={handleClick}>{count}</button>;
}
⭐ 两个协同点:

① 【批处理】
   React 的事件分发会「包一层批处理上下文」——
   事件处理函数执行完后统一 flush 更新
   (这也是「为什么事件处理里的多次 setState 只渲染一次」的原因)

② 【优先级】
   React 按「事件类型」分配更新优先级:
   · 离散事件(click、keydown、input)→ SyncLane(高优先级,立即响应)
   · 连续事件(mousemove、scroll、drag)→ InputContinuousLane(较低)
   → 这就是「点击里的 setState 表现得像『同步』」的原因之一

⚠️ 这也是 React 17 改变「委托目标」的一个动机:
   让「事件优先级」与「根容器」绑定(而不是与 document 绑定)。

四、事件绑在哪里(React 17 的变化)

// React 17 及之后
const root = createRoot(document.getElementById('root')!);
//                                    ↑ 事件委托【到这个根容器】
版本 委托目标 原因
React 16 及之前 document 早期设计
React 17 及之后 根容器(createRoot 的容器) 支持多版本共存 / 更贴近原生语义 / 多根互不影响
⭐ React 17 改变委托目标的三个原因:

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

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

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

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

// React 16:点击时,document 上的原生监听【后】执行(顺序易混淆)
// React 17+:document 上的原生监听【先】执行(它在根容器之外)
//   → 混合使用「React 事件」与「原生监听」时要注意这个顺序差异

五、事件分发流程(完整)

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

① 浏览器派发原生 click 事件(target = button)
② 事件冒泡到根容器 → React 的「根监听器」被触发
③ React 从 event.target 找到对应的【fiber 节点】
④ 从该 fiber 开始,沿「return(父)」指针向上收集:
   · 所有 onClick(冒泡阶段)
   · 所有 onClickCapture(捕获阶段)—— 分别收集
⑤ 创建【合成事件对象】(包装原生事件)
⑥ 按「捕获阶段(由外到内)」执行,再按「冒泡阶段(由内到外)」执行
⑦ 处理完后按「事件类型」决定更新优先级并 flush
// ✅ 捕获与冒泡的顺序(与原生一致)
<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

六、与原生事件的关系

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

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

// ③ 原生事件的 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 的事件委托在根容器上,
   「点击是否在外部」的语义与「React 的冒泡路径」不匹配。
   ✅ 应该用「原生监听 + ref.contains」:
useEffect(() => {
  const onPointerDown = (e: PointerEvent) => {
    if (!ref.current?.contains(e.target as Node)) onClose();
  };
  document.addEventListener('pointerdown', onPointerDown);
  return () => document.removeEventListener('pointerdown', onPointerDown);
}, [onClose]);

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

⚠️ React 的 onChange ≠ 原生的 change

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

React:
· onChange → 等价于原生的 input 事件(每次输入都触发)
· 需要「失焦时」处理 → 用 onBlur
元素 React 的 onChange 对应原生
<input type="text"> input
<textarea> input
<input type="checkbox/radio"> click(读 e.target.checked)
<select> change(也监听 input)
<input type="file"> change(且无法受控)
⭐ 为什么 React 要这么做?
   为了让「受控组件」能用「统一的写法」实时拿到值——
   如果 onChange 等价于原生 change(失焦才触发),
   受控组件就没法「实时响应用户输入」了。

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

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

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

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

九、常见追问

Q: 合成事件是「提前创建的」还是「事件触发时才创建」?
A: 「触发时才创建」(在分发的第 ⑤ 步)。
   所以它有过「事件池(复用对象)」的优化历史(React 17 移除,
   因为「现代浏览器创建对象的成本已可接受」,而池化带来的心智负担更大)。

Q: React 的 onFocus/onBlur 会冒泡吗?
A: 会。原生 focus/blur 不冒泡(所以原生用 focusin/focusout),
   但 React 特意让它们【冒泡】,以便用事件委托统一处理表单焦点。

Q: onScroll 会冒泡吗?
A: 不会(与原生一致)→ 必须绑在具体的滚动容器上。

Q: 为什么「点击外部关闭」要用原生监听?
A: 因为 React 的事件在「根容器」上分发,
   「判断点击是否在组件外部」需要「真实的 DOM 事件与 contains」,
   用原生监听最直接(且能处理「拖拽到外部松开」等边界)。

Q: React 的 stopPropagation 与原生有什么不同?
A: React 的它「只在合成事件体系内生效」——
   阻止的是「React 自己收集到的监听器序列」的后续执行;
   它【不会】阻止「绑在 document/根容器之外的原生监听器」。
   反过来,原生事件的 stopPropagation 会影响 React(React 依赖原生冒泡)。

Q: 合成事件会影响性能吗?
A: 有两个方向:
   · 好处:监听器数量大幅减少(委托),内存更省
   · 代价:每次事件要「创建合成事件对象 + 遍历 fiber 链收集监听」
   总体上「在大量元素的场景」收益更明显。

Q: React 19 的事件系统有变化吗?
A: 没有大的变化(机制与 React 17/18 一致:委托到根容器)。
   主要变化在「表单 Actions」上(<form action> 的提交处理)。

Q: 在「非 React 的 DOM」上能收到 React 事件吗?
A: 不能(React 的事件依赖「fiber 树」——
   只有「由 React 渲染的元素」才有对应的 fiber 与监听器)。
   用 createPortal 渲染到 body 的元素仍然属于 React 组件树 →
   依然能收到 React 事件(见 1.9)。

十、常见坑

// ① 写成小写的事件名(React 不认识)
<button onclick={handle} />                      // ⚠️

// ② 直接调用函数(渲染时执行)
<button onClick={handleClick()} />               // ❌
<button onClick={() => handleClick(id)} />       // ✅ 需要传参时包一层

// ③ 异步里访问 currentTarget(会变 null)
setTimeout(() => console.log(e.currentTarget), 0);   // null
// ✅ 先取出:const el = e.currentTarget;

// ④ 用合成事件实现「点击外部关闭」
// ⑤ 用 stopPropagation 试图阻止「document 上的原生监听」(无效)
// ⑥ 认为「onFocus/onBlur 不冒泡」(React 里会冒泡)
// ⑦ 认为「onScroll 会冒泡」(不会)
// ⑧ 在 onChange 里期待「失焦才触发」(React 的 onChange 是实时的)
// ⑨ 中文输入时用 onKeyDown 判 Enter(被输入法组合打断)
onKeyDown={(e) => { if (e.key === 'Enter') submit(); }}   // ⚠️
onKeyDown={(e) => { if (e.nativeEvent.isComposing) return; if (e.key === 'Enter') submit(); }}   // ✅

// ⑩ 需要 passive 监听却用合成事件(React 无法直接指定)
// ⑪ 在列表里用内联箭头函数(导致 memo 失效)
// ⑫ 忘记「React 17 改变了委托目标」(在「React 与原生事件混用」时会踩坑)
// ⑬ 认为「合成事件的 stopPropagation 能阻止所有传播」(不能阻止根之外的原生监听)
// ⑭ 在事件处理里做「重计算」阻塞了高优先级的更新

十一、面试延伸

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

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

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

  4. 「e.nativeEvent 是什么?什么时候用?」
    它是未被 React 包装的原生事件对象。一般场景用合成事件就够了(preventDefault/stopPropagation/target 都是跨浏览器统一的);需要原生对象时用它,典型场景:① 判断 e.nativeEvent.isComposing(处理中文输入法的组合状态);② 访问「React 没暴露的原生属性」(如某些指针事件的 pressure);③ 与「需要真实原生事件的第三方库」集成。

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

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

  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 自己实现的「跨浏览器一致的事件对象」,三个理由:① 一致性(统一 API)② 性能(事件委托到根容器,1000 个列表项只有 1 个监听器)③ 与更新机制协同(批处理 + 按事件类型分优先级);委托目标在 React 16 是 document、React 17+ 改成根容器(为了多版本共存、更贴近原生语义、多根互不影响);分发流程是「原生冒泡到根 → 从 target 找 fiber → 沿 return 收集监听 → 捕获(外→内)→ 冒泡(内→外)」;e.nativeEvent 才是原生对象(isComposing 处理中文输入);React 的 stopPropagation 拦不住根容器之外的原生监听(「点击外部关闭」要用原生监听 + ref.contains);onChange 等价于原生 input(失焦用 onBlur)、onFocus/onBlur 会冒泡、onScroll 不冒泡;事件池在 React 17 已移除。

赞(0) 打赏
未经允许不得转载:陌上寒 » 12.10 为什么要合成事件

评论 抢沙发

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

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

微信扫一扫

支付宝扫一扫