7.1 合成事件系统(SyntheticEvent)
直觉锚定
想象一个国际会议,与会者说不同语言。主办方给每人配了同声传译耳机------不管发言人说中文、英文还是日文,你听到的都是统一语言。
React 的 SyntheticEvent 就是这个传译系统:
- 不同语言 = 不同浏览器的原生事件对象(IE 和 Chrome 的事件属性有差异)
- 传译耳机 = SyntheticEvent 封装原生事件,提供统一 API
- 你听到的统一语言 = 你在 React 里拿到的事件对象
e
但 SyntheticEvent 不只是"翻译"------它还是事件委托机制的调度对象,携带"哪个 Fiber 应该处理这个事件"的信息。
问题背景
原生 DOM 事件处理有一个性能问题:1000 个 <button onClick={...}> 就是绑定 1000 个监听器。每个占内存,绑定/解绑有开销。
React 的解决方案:不在每个元素上绑定事件,而在根节点统一监听,通过冒泡分发。这就是事件委托(Event Delegation)。
⚠️ 常见先入为主的误解: 很多人以为 SyntheticEvent 是原生事件的"深拷贝"。实际上它是代理(Proxy 模式) ------持有原生事件的引用,大部分属性通过代理读取。React 16 有事件池(Event Pooling)复用对象,React 17 已移除。
核心数据结构
SyntheticEvent 对象
ini
// react@18.3.1 · packages/react-dom/src/events/SyntheticEvent.js
function SyntheticEvent(nativeEvent) {
this.nativeEvent = nativeEvent; // 原生事件引用(不是拷贝)
this.target = nativeEvent.target;
this.type = nativeEvent.type;
this.bubbles = nativeEvent.bubbles;
// ...
// 传播控制标志
this.isPropagationStopped = false;
this.isDefaultPrevented = false;
}
关键属性:
| 属性 | 说明 |
|---|---|
nativeEvent |
原生 DOM 事件的引用 |
isPropagationStopped |
是否调用了 stopPropagation |
_reactName |
React 事件名(如 onClick) |
_targetInst |
事件目标对应的 Fiber 实例 |
事件类型映射
React 把 JSX 中的 onClick、onChange 等映射到原生事件类型:
arduino
simpleEventPlugin → onClick → 原生 'click'
onChange → 原生 'change'
onSubmit → 原生 'submit'
focusEventPlugin → onFocus → 原生 'focusin'(注意:不是 focus,因为 focus 不冒泡)
keyboardEventPlugin → onKeyDown → 原生 'keydown'
运行流程
从用户点击到 React 事件处理的完整链路
scss
① 用户点击 <button onClick={handleClick}>
│ 浏览器在 button 上触发原生 click 事件
│
▼
② 事件冒泡到根节点(React 17+ 是 root DOM container)
│
▼
③ React 根节点监听器触发
│ DOMPluginEventSystem.js · dispatchEvent
│
▼
④ 根据 nativeEvent.target 找到对应 Fiber
│ getClosestInstanceFromNode(target)
│ → 从 DOM 节点向上找,找到挂了 __reactFiber$ 的节点
│ → 返回对应 Fiber 实例
│
▼
⑤ 从 target Fiber 沿 return 链向上收集事件处理器
│ accumulateTwoPhaseListeners(targetInst)
│
│ 收集结果(冒泡顺序):
│ Fiber(button) → 有 onClick → 收集 handleClick
│ Fiber(div) → 有 onClick → 收集 parentClick
│
▼
⑥ 创建 SyntheticEvent,依次调用处理器
│ handleClick(syntheticEvent) ← 先执行
│ parentClick(syntheticEvent) ← 再执行
│
│ 如果某处理器调用了 e.stopPropagation()
│ → isPropagationStopped = true → 后续不再执行
dispatchEvent 的核心伪代码
ini
// 简化自 DOMPluginEventSystem.js
function dispatchEvent(nativeEvent) {
// ① 找 target 对应的 Fiber
var targetInst = getClosestInstanceFromNode(nativeEvent.target);
// ② 创建 SyntheticEvent
var syntheticEvent = createSyntheticEvent(nativeEvent);
// ③ 收集所有 onClick 处理器(从 target 向上)
var dispatchQueue = [];
accumulateTwoPhaseListeners(targetInst, dispatchQueue);
// ④ 依次执行
for (var i = 0; i < dispatchQueue.length; i++) {
var listeners = dispatchQueue[i];
for (var j = 0; j < listeners.length; j++) {
listeners[j](syntheticEvent);
if (syntheticEvent.isPropagationStopped) return;
}
}
}
stopPropagation 的实现------和原生的区别
ini
SyntheticEvent.prototype.stopPropagation = function() {
this.isPropagationStopped = true;
// 注意:没有调用 nativeEvent.stopPropagation()!
};
React 的 stopPropagation 只阻止 React 事件传播,不阻止原生事件冒泡。 React 事件和原生事件是两层独立的传播机制。
设计动机与权衡
为什么用 SyntheticEvent 而不是直接用原生事件?
- 跨浏览器一致性 :IE 的
event.keyCodevs 标准的event.key;IE 的event.srcElementvsevent.target - 事件委托性能:N 个元素只需 1 个监听器
- 与 Fiber 集成 :SyntheticEvent 携带
_targetInst(Fiber 信息),React 可关联到组件实例
React 17 移除事件池的原因:
React 16 中 SyntheticEvent 被池化复用------回调执行完后属性被清空。导致经典 bug:
javascript
function handleClick(e) {
console.log(e.target); // ✓ 同步访问正常
setTimeout(() => {
console.log(e.target); // ✗ null!事件已被回收
}, 0);
}
React 17 移除池化。现代 JS 引擎 GC 足够快,池化收益不再明显,而且造成了大量开发者困惑。
次级误解和边界
误解 1:"SyntheticEvent 和原生事件完全一样"
不是。e.stopPropagation() 只阻止 React 层传播,不阻止原生冒泡。某些不常用属性需要通过 e.nativeEvent 访问。
误解 2:"React 不支持捕获阶段"
支持。用 onClickCapture 而不是 onClick。执行顺序:先从根到 target 收集 Capture 监听器执行,再从 target 到根收集 Bubble 监听器执行。
误解 3:"e.persist() 还需要用"
React 17+ 不需要了。e.persist() 变成了空操作,保留只是为了向后兼容。
现在我们知道了 SyntheticEvent 是原生事件的代理层,React 在根节点统一监听所有事件,通过冒泡分发到对应的 Fiber 组件处理,传播控制靠 isPropagationStopped 标志而非原生冒泡。但这里有一个问题:React 16 的"根节点"是 document,而 React 17+ 换成了 root DOM container。为什么要换?换完解决了什么问题?
这就是 7.2 事件委托的挂载点变化(React 16 vs 17) 要回答的事情。
7.2 事件委托的挂载点变化(React 16 vs 17)
直觉锚定
想象一个写字楼的快递收发。以前所有快递都统一送到物业大堂(document),由前台分发给各公司。问题是:楼里有两家公司(两个 React 应用),前台只有一个人,A 公司的快递员喊了一句"拒收",B 公司的快递也被拦下了。
React 17 的改变:每家公司自己设前台(root DOM container),各收各的,互不干扰。
问题背景
React 16 把所有事件监听器挂在 document 上。单个 React 应用没有问题,但遇到以下场景就出 bug:
xml
<body>
<div id="micro-app-1"> ← React 应用 1
<button onClick={handleClick1} />
</div>
<div id="micro-app-2"> ← React 应用 2
<button onClick={handleClick2} />
</div>
</body>
两个应用都在 document 上注册了 click 监听器。点击 app-1 的 button 时:
javascript
冒泡路径:button → #micro-app-1 → body → document
到达 document 时:
app-1 的监听器触发 ✓
app-2 的监听器也触发 ✗(不在 app-2 里点的,但监听器在 document 上)
更严重的问题:如果 app-1 的事件处理器调用了原生 e.stopPropagation()(阻止冒泡到 document),两个应用的事件都不会触发------因为冒泡在到达 document 之前就被截断了。
这在微前端架构中是致命问题。
核心变化
就一行代码的区别:
javascript
// React 16:挂在 document
document.addEventListener('click', dispatchEvent);
// React 17+:挂在 root DOM container
rootContainerElement.addEventListener('click', dispatchEvent);
javascript
// react@18.3.1 · packages/react-dom/src/events/EventListener.js
// listenToAllSupportedEvents 注册时传入的是 rootContainerElement
function listenToAllSupportedEvents(rootContainerElement) {
// 遍历所有事件类型,注册到 rootContainerElement 上
listenToNativeEvent('click', rootContainerElement, false); // bubble
listenToNativeEvent('click', rootContainerElement, true); // capture
listenToNativeEvent('change', rootContainerElement, false);
// ...
}
变化后的行为
less
页面结构(React 17+):
<body>
<div id="app-1"> ← 事件监听器挂在 #app-1 上
<button onClick={handleClick1} />
</div>
<div id="app-2"> ← 事件监听器挂在 #app-2 上
<button onClick={handleClick2} />
</div>
</body>
点击 app-1 的 button:
冒泡路径:button → #app-1 → body → document
到达 #app-1 时:app-1 的 dispatchEvent 触发 → React 处理 handleClick1 ✓
继续冒泡到 body、document:无 app-2 的监听器 → 不影响 app-2 ✓
微前端场景的解决
ini
app-1 调用 e.stopPropagation()(React 层面):
→ isPropagationStopped = true
→ React 内部不再向父组件传播
→ 但原生事件仍冒泡到 #app-1 → body → document
→ app-2 完全不受影响 ✓
app-1 调用 e.nativeEvent.stopPropagation()(原生层面):
→ 冒泡在 #app-1 处被阻止
→ 不会到达 document
→ 但 app-2 的监听器在 #app-2 上,不在冒泡路径上 → 仍不受影响 ✓
设计动机与权衡
为什么 React 16 选 document?
当时的设计考虑是:document 是所有 DOM 节点的最终祖先,保证任何位置的事件都能冒泡到。而且 document 只有一个,监听器注册/清理简单。
当时微前端还不是主流场景,React 团队没预料到多个 React 实例共存的需求。
为什么不是每个元素各自绑定?
那样就回到原生 DOM 的做法了------1000 个元素 1000 个监听器。事件委托的核心优势就是一个监听器覆盖所有子元素。挂在 root container 是在"性能"和"隔离性"之间的最优平衡点。
权衡:
| 方案 | 监听器数量 | 隔离性 | 性能 |
|---|---|---|---|
| 每个元素各绑 | N 个 | 完全隔离 | 差 |
| 挂 document(React 16) | 1 个 | 无隔离 | 好 |
| 挂 root container(React 17+) | 每个应用 1 个 | 应用级隔离 | 好 |
次级误解和边界
误解 1:"这个变化影响我现有代码"
大部分不受影响。唯一的例外:如果你的代码直接在 document 或 window 上注册了原生事件监听器,并且依赖 React 事件在 document 上触发的时序。这种代码本来就不规范。
误解 2:"React 17 的 stopPropagation 行为变了"
没变。React 的 stopPropagation 一直都只控制 React 层传播。变化的是挂载点从 document 变成 root container------这意味着原生事件冒泡到 root container 时就被 React 捕获了,而不是到 document。冒泡路径更短了,反而更好预测。
误解 3:"Portal 的事件传播也变了"
Portal 的事件传播不受挂载点影响。Portal 的 DOM 在别处,但事件仍然冒泡到 React 树中的祖先(不是 DOM 树中的祖先)。这个行为在 React 16 和 17+ 中一致。
现在我们知道了 React 17 把事件挂载点从 document 改成了 root container,解决了多 React 应用共存的问题。但这里有一个更实际的问题:当 React 事件和原生事件混用在一起时,它们的执行顺序是什么?stopPropagation 到底拦的是哪一层?
这就是 7.3 React 事件的执行顺序 要回答的事情。
7.3 React 事件的执行顺序
直觉锚定
想象一栋楼有两个对讲系统:物业对讲 (原生事件)和公司内线(React 事件)。访客按门铃时,信号先走物业系统逐层上报,当信号到达某层的公司前台时,前台再启动公司内线系统,按部门顺序通知。
关键:两套系统是串行的,不是并行的。公司内线在物业信号到达前台那一刻才开始运转。
映射:
- 物业对讲 = 原生 DOM 事件冒泡
- 公司前台 = root container 上的 React dispatchEvent
- 公司内线 = React 内部从 target Fiber 向上传播事件
- 串行 = 原生冒泡到 root container 后,React 事件才开始处理
问题背景
实际项目中经常需要混用原生事件和 React 事件------比如用 useEffect 注册原生监听器处理全局事件,同时组件上也有 onClick。这时候执行顺序不是直觉能猜对的:
javascript
function App() {
useEffect(() => {
document.addEventListener('click', () => console.log('A'));
}, []);
return (
<div onClick={() => console.log('B')}>
<button
onClick={() => console.log('C')}
ref={el => el?.addEventListener('click', () => console.log('D'))}
>
Click
</button>
</div>
);
}
点击按钮,输出顺序是什么?要回答这个问题,需要理解两套事件系统的精确交互点。
核心规则
整个执行过程只有一条规则:
原生事件沿着 DOM 树冒泡。当冒泡到 root container 时,React 的 dispatchEvent 被触发,然后 React 按自己的顺序执行所有 React 事件处理器。
拆解为三步:
- 原生事件从 target 开始冒泡
- 冒泡到 root container 时,React 接管------收集所有 React 事件处理器并执行
- 原生事件继续冒泡到 body、document
运行流程
上面例子的完整执行链路
less
用户点击 <button>
│
│ 原生 click 事件触发,冒泡开始
│
▼ ① target 阶段:button 上的原生监听器
│ → console.log('D') ← button ref 注册的原生 click
│
▼ ② 冒泡到 div:无原生监听器,跳过
│
▼ ③ 冒泡到 root container(#root)
│ React 的 dispatchEvent 被触发!
│ │
│ ├── 找到 target Fiber(button)
│ ├── 沿 return 链向上收集 React onClick 处理器
│ │ button Fiber → onClick → console.log('C')
│ │ div Fiber → onClick → console.log('B')
│ │
│ └── 依次执行:
│ → console.log('C') ← button React onClick
│ → console.log('B') ← div React onClick
│
▼ ④ 冒泡到 body:无监听器,跳过
│
▼ ⑤ 冒泡到 document
│ → console.log('A') ← useEffect 注册的原生 click
│
最终输出:D → C → B → A
用图表示
css
DOM 树: 事件传播方向:
document ← ⑤ document 原生监听 (A)
│
body ← ④ 无监听器
│
#root ← ③ React dispatchEvent 触发
│ React 内部执行:C → B
div ← ② 无原生监听器
│
button ← ① button 原生监听 (D) [target 阶段]
stopPropagation 在不同层的影响
| 调用位置 | 调用什么 | 效果 |
|---|---|---|
| button 原生监听器 | e.stopPropagation() |
原生冒泡在 button 停止 → D 之后全部不执行(C、B、A 都不触发) |
| button React onClick | e.stopPropagation() |
React 层传播停止 → D、C 执行,B 不执行,但原生冒泡继续 → A 仍执行 |
| div React onClick | e.stopPropagation() |
无意义(已经是最后一个 React 处理器) → D、C、B、A 全执行 |
| document 原生监听 | e.stopPropagation() |
无意义(已经到了冒泡顶端) → D、C、B、A 全执行 |
关键理解:
- 原生的
stopPropagation()拦的是原生冒泡链------如果拦在 root container 之前,React 的 dispatchEvent 根本不会被触发 - React 的
e.stopPropagation()拦的是React 层传播------原生冒泡不受影响
Capture 阶段的顺序
加上 Capture 后,完整顺序:
ini
<div onClickCapture={() => console.log('1')}
onClick={() => console.log('4')}>
<button
onClickCapture={() => console.log('2')}
onClick={() => console.log('3')}
/>
</div>
// 输出:1 → 2 → 3 → 4
React 的 traverseTwoPhase 处理顺序:
php
① Capture 阶段:从根 Fiber 到 target Fiber
root Fiber → div onClickCapture → console.log('1')
button Fiber → onClickCapture → console.log('2')
② Bubble 阶段:从 target Fiber 到根 Fiber
button Fiber → onClick → console.log('3')
root Fiber → div onClick → console.log('4')
注意:React 的 Capture 和原生 Capture 是两回事。 React 的 Capture 只是 "先收集从根到 target 的处理器并先执行",实际触发时机仍然是在 root container 的原生冒泡监听器里。
设计动机与权衡
为什么不把 React 事件注册在 Capture 阶段来保证更早执行?
React 在 root container 上同时注册了 capture 和 bubble 两个监听器:
arduino
listenToNativeEvent('click', rootContainerElement, false); // bubble phase
listenToNativeEvent('click', rootContainerElement, true); // capture phase
capture 监听器处理 React 的 Capture 事件(onClickCapture),bubble 监听器处理 Bubble 事件(onClick)。这保证了 React 的 Capture 事件在原生冒泡体系中确实是 capture 阶段触发的------比同一节点上的 bubble 监听器更早。
次级误解和边界
误解 1:"React 事件和原生事件是交替执行的"
不是。原生事件全部先冒泡完到 root container ,然后 React 一次性按内部顺序执行所有 React 处理器,之后原生事件继续冒泡。不是"原生一个、React 一个"交替。
误解 2:"e.nativeEvent.stopPropagation() 和 e.stopPropagation() 效果一样"
完全不同:
e.stopPropagation()→ 只设 React 的标志位isPropagationStopped = true,原生冒泡继续e.nativeEvent.stopPropagation()→ 拦截原生冒泡,后续所有监听器(包括 root container 上的 React dispatchEvent)都不会触发
误解 3:"useEffect 里注册的 document 监听器一定最后执行"
不一定。取决于注册在冒泡阶段还是捕获阶段。如果注册了 capture 监听器:
javascript
document.addEventListener('click', handler, true); // capture
它会在原生冒泡的最早期(capture 阶段从 document 往下传播时)执行,比 target 阶段的原生监听器还早。
模块 7「事件系统」3 个考点全部讲完。进入题目考核。
题 1
javascript
function App() {
useEffect(() => {
document.addEventListener('click', () => console.log('A'));
}, []);
return (
<div onClick={() => console.log('B')}>
<button
onClick={() => console.log('C')}
ref={el => el?.addEventListener('click', () => console.log('D'))}
>
Click
</button>
</div>
);
}
点击按钮后,输出顺序是什么?请解释每一步为什么在那个位置执行。
题 2
在上面的代码基础上,如果把 button 的原生监听器改为:
javascript
ref={el => el?.addEventListener('click', (e) => {
console.log('D');
e.stopPropagation();
})}
输出会变成什么?为什么?
题 3
React 17+ 为什么把事件挂载点从 document 改成 root container?这个变化解决了什么具体问题?