
一句话: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;
九、面试延伸
- 「什么是合成事件?React 为什么要自己实现一套事件系统?」
合成事件是 React 在原生事件之上封装的跨浏览器一致的事件对象。React 自己实现它的原因有三个:① 跨浏览器一致性——统一属性与行为(preventDefault、事件对象的属性名等),屏蔽历史差异;② 性能与事件委托——把事件统一委托到根容器,1000 个列表项的点击只需一个监听器,也避免了为动态节点反复绑定;③ 与更新机制协同——React 知道「这次更新发生在事件里」,从而正确批处理、并按事件类型分配优先级(点击是 SyncLane)。 -
「React 的事件绑在哪里?React 17 有什么变化?」
React 把事件委托到根容器上(createRoot传入的 DOM 节点)。React 16 及之前挂在document,React 17 起改到根容器。改的原因:① 支持多版本 React 共存(微前端、渐进式升级时不会互相干扰);② 更贴近原生语义(「事件不应冒泡出根容器」成立);③ 多个根渲染互不影响。 -
「React 的事件分发是怎样的?」
原生事件冒泡到根容器 → React 从event.target找到对应的 fiber → 从该 fiber 沿「return(父)指针」向上收集所有同类型监听器(捕获与冒泡分别收集)→ 创建合成事件对象 → 先执行捕获阶段(外→内)、再执行冒泡阶段(内→外)。此外 React 会按事件类型分配优先级(离散事件 Sync、连续事件 InputContinuous),并在处理完后按优先级 flush 更新。 -
「React 的
onChange和原生change是同一个事件吗?」
不是。React 的onChange等价于原生的input事件(每次输入都触发),而原生的change对文本框来说是失焦后才触发。这是 React 有意为之的设计——让受控组件能实时拿到值。如果需要「失焦才处理」,应该用onBlur。此外checkbox/radio的onChange内部基于click,要读e.target.checked。 -
「
e.nativeEvent是什么?」
是原生事件对象。合成事件是原生事件的包装层,e.nativeEvent让你能拿到未被包装的原始对象(如需要访问 React 没暴露的属性、或判断e.nativeEvent.isComposing处理中文输入)。一般的跨浏览器操作(preventDefault、stopPropagation、target)用合成事件就够了。 -
「React 的
stopPropagation能阻止原生监听吗?」
不能。React 的e.stopPropagation()阻止的是「合成事件体系内的传播」(React 自己维护的虚拟冒泡路径与监听器收集)。而绑在document或根容器之外的原生监听器在 React 的分发逻辑之外,不受影响。反过来,原生事件里的stopPropagation会影响 React(因为 React 依赖原生事件冒泡到根容器)。这个差异在「点击外部关闭」这类交互里很关键。 -
「
onFocus/onBlur/onScroll在 React 里会冒泡吗?」
onFocus和onBlur会冒泡——原生focus/blur不冒泡(原生用focusin/focusout),但 React 特意让它们冒泡以便用事件委托统一处理表单焦点;onScroll不冒泡(与原生一致),必须绑在具体的滚动容器上。这些「与原生不一致」的细节是合成事件的常见考察点。 -
「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 已移除。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!