各位老铁,大家好!欢迎来到今天的“React 事件宇宙”深度巡演。我是你们的领航员,今天咱们不聊业务逻辑,不聊组件拆分,咱们要聊点更“底层”、更“硬核”,甚至有点像在解剖青蛙的活儿。
今天我们要探讨的主题是:React 合成事件中的 stopPropagation 是如何让一个点击事件在半路“刹车”的?
你肯定用过 e.stopPropagation()。对吧?当你点一个按钮,不想让父级容器也收到这个点击事件时,你就像个尽职的交警,挥舞着手臂喊:“停!别往上传了!”
但在 React 里,事情没那么简单。React 并不是直接把事件“贴”在 DOM 节点上的(那是原生 DOM 的做法)。React 是个精明的“包工头”,它搞了一套“合成事件系统”。这就好比你们公司里,老板(React)不直接跟每个员工(DOM 节点)说话,而是派了个秘书(合成事件)去传达消息。
那么问题来了:当秘书喊“停”的时候,老板到底是怎么听见的?老板又是怎么真的让消息不再往上走的?
别急,咱们这就穿上防弹衣,钻进 React 的源码里,把这层窗户纸捅破。
第一部分:为什么我们需要“合成”这层?
首先,咱们得明白 React 为什么不直接用 addEventListener。如果 React 在每个 <button> 上都挂一个监听器,那要是你页面上有 1000 个按钮,React 就得挂 1000 个监听器。这就像是你去饭店吃饭,服务员不直接把菜端上来,而是每来一个客人,他就得去厨房跟大厨说一遍“我要吃这个”。效率低,而且容易忘。
React 的策略是事件委托。它只在最外层挂一个监听器,比如在 document 或者 root 容器上。
// 假设这是 React 的 root
<div id="root">
<button onClick={handleClick}>点我</button>
<button onClick={handleClick}>点我</button>
<button onClick={handleClick}>点我</button>
{/* 上面有 1000 个 button */}
</div>
在 React 的世界里,所有的事件都被挂载到了 root 上。当用户点击那个按钮时,事件从原生 DOM 层冒泡上来,到了 root。React 的监听器捕获到这个事件,然后 React 内部开始进行一场“寻人游戏”:它要在自己的 Fiber 树里找到是谁触发了这个事件。
找到之后,React 会构建一个事件队列,然后从内到外(从子组件到父组件)依次分发。
核心问题来了: 这个“分发”的过程,就是冒泡发生的地方。而 stopPropagation 的作用,就是在这个分发过程中,截断这个队列。
第二部分:源码追踪 – 事件是如何被“捕获”并“分发”的
咱们把目光投向 React 源码。别被那些文件名吓到了,咱们只看关键逻辑。主要的逻辑在 ReactEventListener.js 和 EventPluginHub.js 这两个文件里。
1. 触发点:handleTopLevel
当原生事件冒泡到 root,React 的监听器被触发,进入 ReactEventListener.js 的 handleTopLevel 函数。
// 伪代码:ReactEventListener.js
function handleTopLevel(topLevelType, nativeEvent) {
// 1. React 把原生事件转换成自己的事件对象
const nativeEventTarget = getEventTarget(nativeEvent);
// 2. 找到对应的 React Fiber 节点(也就是你的组件实例)
const targetInst = ReactFiberReconciler.getInstanceFromNode(nativeEventTarget);
// 3. 创建事件配置和监听器列表
const dispatchConfig = EventPluginHub.getDispatchConfig(topLevelType);
const listeners = EventPluginHub.getListeners(targetInst, dispatchConfig);
// 4. 构建事件队列
const event = new SyntheticEvent(dispatchConfig, targetInst, nativeEvent);
// 5. 核心分发循环开始!
// 这里的 propagationPhase 是 'bubbled'(冒泡阶段)
EventPluginHub.executeDispatchesAtPhase(event, targetInst, 'bubbled');
}
注意第 5 行。executeDispatchesAtPhase 是个重头戏。它不仅仅处理当前的节点,它还会顺着树往上找父节点。
2. 分发逻辑:executeDispatchesAtPhase
这个函数会遍历事件队列。队列里存的是什么?存的是从当前点击节点一直到 root 的所有组件实例。
// 伪代码:EventPluginHub.js
function executeDispatchesAtPhase(event, targetInst, propagationPhase) {
// 获取该组件的所有监听器(比如你的 onClick)
const listeners = getListenerMapForEvent(event.dispatchConfig, targetInst);
// 开始遍历队列
// 注意:这里的逻辑简化了,实际上 React 会维护一个冒泡路径数组
for (let i = 0; i < event.dispatchQueue.length; i++) {
const dispatchEntry = event.dispatchQueue[i];
const { listener, instance } = dispatchEntry;
// 执行监听器函数!
// 这时候,你的 handleEvent 或者 onClick 被调用了
executeDispatch(event, dispatchConfig, listener, instance);
// --- 关键时刻到了! ---
// 检查事件是否被停止冒泡了
if (event.isPropagationStopped) {
// 如果被停了,直接 return,后面的父组件一个都不执行
return;
}
}
}
这里就是 stopPropagation 发挥作用的地方。注意那个 if (event.isPropagationStopped)。
第三部分:SyntheticEvent 的内部构造
现在咱们得看看 SyntheticEvent 这个类到底是个什么构造函数。它是 React 给原生事件穿的一层“马甲”。
// 伪代码:SyntheticEvent.js
class SyntheticEvent {
constructor(dispatchConfig, targetInst, nativeEvent) {
this.dispatchConfig = dispatchConfig; // 事件配置
this.dispatchQueue = []; // 事件队列
this.targetInst = targetInst; // 目标组件
this.nativeEvent = nativeEvent; // 原生事件对象
// 核心标志位!
this.isPropagationStopped = false;
}
// 你调用的 stopPropagation 方法
stopPropagation() {
// 1. 把标志位设为 true
this.isPropagationStopped = true;
// 2. 尝试调用原生事件的 stopPropagation
// 虽然 React 想要完全控制,但有时候它得顺从浏览器
if (this.nativeEvent.stopPropagation) {
this.nativeEvent.stopPropagation();
}
}
}
第四部分:揭秘 stopPropagation 的完整执行链
好了,咱们把这三块拼起来,还原一下 stopPropagation 被调用时的完整旅程。
假设你有一个这样的组件结构:
<div onClick={handleParentClick}>
<button onClick={handleChildClick}>点击我</button>
</div>
场景: 你点击了 <button>。
- 原生事件触发:浏览器生成了一个
MouseEvent,开始冒泡。 - React 捕获:React 在
root上捕获到这个事件,调用handleTopLevel。 - 构建队列:React 知道,这次点击涉及到的 React 组件有:
Button和div。它把这两个组件实例放进了event.dispatchQueue。 - 分发循环开始:
- React 取出
Button组件。 - 找到它的
onClick属性,也就是handleChildClick。 - 执行
handleChildClick(e)。
- React 取出
- 用户代码介入:
在handleChildClick里,你写了:const handleChildClick = (e) => { console.log('子组件收到'); e.stopPropagation(); // 你按下刹车了! }; - React 层拦截:
- React 执行
e.stopPropagation()。 SyntheticEvent的isPropagationStopped被设置为true。- React 继续执行
executeDispatch后面的逻辑。
- React 执行
- 判断:
executeDispatchesAtPhase函数里的if (event.isPropagationStopped)条件成立。
结果:return。循环终止。React 不会去执行div的handleParentClick。 - 原生层反馈:
因为SyntheticEvent内部调用了this.nativeEvent.stopPropagation(),所以原生 DOM 事件也会停止冒泡。如果div上也有原生监听器,它也收不到这个事件了。
总结一下流程图:
graph TD
User[用户点击] --> NativeEvent[原生 DOM 事件]
NativeEvent --> ReactListener[React 监听器捕获]
ReactListener --> BuildQueue[构建事件队列]
BuildQueue --> Loop[开始循环分发]
Loop --> DispatchChild[分发子组件事件]
DispatchChild --> UserCode[用户代码执行]
UserCode --> StopProp[调用 e.stopPropagation]
StopProp --> SetFlag[设置 isPropagationStopped = true]
SetFlag --> CheckFlag{检查标志位}
CheckFlag -->|true| Break[中断循环 return]
CheckFlag -->|false| DispatchParent[分发父组件事件]
Break --> End[事件处理结束]
DispatchParent --> End
第五部分:那些坑爹的“事件池”和“闭包”
虽然原理很清晰,但实际开发中,stopPropagation 经常让你怀疑人生。为什么?因为 React 有两个著名的“坑”:
1. 事件池
在 React 16 和 17 时期,为了性能,React 使用了事件池技术。这意味着每次生成一个 SyntheticEvent 对象,它都是从池子里复用的。
如果你在 useEffect 或者 setTimeout 里使用 stopPropagation,你可能会遇到大麻烦。
useEffect(() => {
const handleClick = (e) => {
e.stopPropagation();
// 这里你捕获了 e
};
document.addEventListener('click', handleClick);
return () => {
document.removeEventListener('click', handleClick);
};
}, []); // 依赖数组为空
问题在哪?
当你点击时,handleClick 被调用,e.stopPropagation() 执行了,isPropagationStopped 被设为 true。
但是,React 在处理完这次事件后,把 e 对象回收到了池子里。下次(如果还有下次)再调用 handleClick 时,e 是个“新”对象,isPropagationStopped 又变回了 false!
React 18 的改进:
React 18 引入了自动批处理和事件池的优化(主要是 Event Pool 的移除或重构),大部分情况下这个问题缓解了,但在某些特定场景下(如非 React 管理的事件监听器),你依然要小心闭包捕获的老旧事件对象。
2. passive: true
这是现代浏览器为了提升滚动性能引入的一个属性。
// React 默认生成的监听器通常不是 passive 的
// 但如果你在原生事件里写:
document.addEventListener('click', handleClick, { passive: true });
当你设置 passive: true 时,浏览器承诺:“这个监听器不会调用 preventDefault() 也不会调用 stopPropagation()。”
如果监听器试图调用 stopPropagation(),浏览器会直接忽略它。
后果:
如果你在 passive: true 的监听器里调用了 e.stopPropagation(),React 的 SyntheticEvent 可能会捕获到这个调用,设置标志位,但由于原生事件已经承诺了不冒泡,React 也没办法真正阻止原生冒泡了。这会导致 React 的合成事件系统和原生事件系统出现“不同步”。
第六部分:stopImmediatePropagation 的“霸道”之处
除了 stopPropagation,React 还有一个方法叫 stopImmediatePropagation。这货比 stopPropagation 还狠。
stopPropagation 只是告诉 React:“别再往上分发给我的父组件了”。
stopImmediatePropagation 是告诉 React 和浏览器:“别再分发给我同级的其他监听器了”。
在 React 里,每个组件上的事件监听器是唯一的,所以 stopImmediatePropagation 和 stopPropagation 的效果看起来差不多。但在原生 DOM 中,如果一个父元素上有两个监听器,stopImmediatePropagation 会阻止第二个执行。
第七部分:深挖源码细节 – 为什么要 break?
回到 EventPluginHub.js。你可能会问,为什么 React 不直接修改 event.nativeEvent 的属性来阻止冒泡,或者把事件队列截断?
其实 React 两种都做了,但核心逻辑还是标志位 + 循环中断。
这种设计模式在 React 中非常常见。它把“状态改变”和“副作用执行”分离开来。
- 状态改变:
isPropagationStopped = true。这是瞬间完成的,非常快。 - 副作用执行:
executeDispatch。这是比较重的操作(函数调用、状态更新)。
如果 React 不在循环里检查标志位,而是等到分发完所有事件后再去检查,那就晚了,父组件已经执行了。
这种“即时反馈”的机制保证了性能。一旦你喊了“停”,React 就立刻停止计算,不浪费 CPU 去执行父组件的渲染逻辑。
第八部分:总结与吐槽
好了,老铁们,咱们来复盘一下今天的“解剖课”。
React 的 stopPropagation 之所以能工作,是因为它构建了一个虚拟的事件分发队列。这个队列是 React 内部维护的,完全由它控制。
当你在代码里写 e.stopPropagation() 时,你实际上是在对 React 说:“嘿,兄弟,把那个 isPropagationStopped 标志位给我打开!”
然后 React 的 dispatchEvent 循环在每一次迭代时都会看一眼这个标志位。如果灯亮了,它就立刻扔下接力棒,不再往上跑。
这就像什么呢?
这就好比你在公司里,你点了一个外卖。外卖小哥(原生事件)骑车到了楼下,准备往楼上送。
你(React)派了个保安(合成事件)去接应。
保安看着外卖小哥,说:“等等,我不想要这单了。”
保安在单子上打了个叉(设置标志位)。
外卖小哥把单子递给下一层的保安,下一层保安一看:“哎哟,这单上打叉了,别送了,直接退回吧。”
于是,外卖小哥就真的停在了楼下,没上楼,也没通知你楼上的老板。
这就是 stopPropagation 的本质:在中间层拦截,并通知所有中间层都停止传递。
最后给个代码示例,展示一下完整的“刹车”过程:
import React, { useState } from 'react';
const Parent = () => {
const [count, setCount] = useState(0);
const handleParentClick = () => {
console.log('父组件被点击了!');
setCount(c => c + 1);
};
return (
<div onClick={handleParentClick} style={{ padding: 20, border: '2px solid red' }}>
<h2>我是父组件 (点击次数: {count})</h2>
<Child />
</div>
);
};
const Child = () => {
const [childCount, setChildCount] = useState(0);
const handleChildClick = (e) => {
console.log('子组件被点击了!');
setChildCount(c => c + 1);
// 这里就是刹车点
e.stopPropagation();
console.log('我喊停了!');
};
return (
<button
onClick={handleChildClick}
style={{ padding: 10, margin: 10, background: 'blue', color: 'white' }}
>
我是子组件 (点击次数: {childCount})
</button>
);
};
export default function StopPropagationDemo() {
return (
<div>
<h1>React stopPropagation 演示</h1>
<Parent />
</div>
);
}
运行这个代码,点击蓝色的按钮。你会发现,父组件的 handleParentClick 不会执行,父组件的 count 不会增加,父组件的边框也不会闪烁。
这就是 React 合成事件系统的魔力。它用一套统一的 API(合成事件),屏蔽了不同浏览器(IE 的 attachEvent, Chrome 的 addEventListener)之间的差异,并允许我们在中间层通过简单的标志位控制事件的流向。
所以,下次当你觉得事件“不听话”的时候,别光顾着在控制台查报错,看看是不是 stopPropagation 的标志位没打上,或者是不是 passive 监听器把你给坑了。
好了,今天的讲座就到这里。希望你们下次再看到 stopPropagation 时,不再觉得它只是一个简单的函数调用,而是看到了背后那个正在疯狂打叉的 React 保安。咱们下回见!