合成事件
React 的合成事件(SyntheticEvent)是前端面试中极高频的考点。
面试高频问题概览:
- 为什么要设计合成事件?它和原生事件有什么关系?
- React 17 在事件机制上做了什么重大改变?为什么?
- 原生事件与合成事件混用时,执行顺序是怎样的?
- React 里如何正确阻止事件冒泡?
1. JS 事件机制回顾(前置)
理解合成事件前,先回顾原生事件机制。
事件触发三阶段
- 捕获阶段:从
window往事件触发处传播,遇到捕获监听(addEventListener(type, fn, true))会触发。 - 目标阶段:传播到事件触发处,触发目标元素的回调。
- 冒泡阶段:从触发处往
window回传,遇到冒泡监听(第三个参数为false,默认)会触发。
事件委托
事件冒泡到父元素时会触发父元素上的同类监听,事件委托正是依赖冒泡实现的:把子元素的事件统一委托到父元素监听。优势是 统一管理事件 和 节省内存开销。
<ul id="ul">
<li>1</li>
<li>2</li>
<li>3</li>
</ul>
<script>
// 事件委托:只在父级绑定一个监听
const ul = document.querySelector("#ul");
ul.addEventListener("click", (event) => {
console.log(event.target); // 真正点击的 li
});
</script>
常用方法
e.stopPropagation():阻止事件继续捕获或冒泡。e.preventDefault():阻止默认行为(如链接跳转、表单提交)。e.stopImmediatePropagation():阻止冒泡,且阻止同一元素上其他相同类型监听的执行。
2. 为什么要使用合成事件?
- 跨浏览器兼容:React 顶层抹平了各个浏览器之间的事件机制差异(比如 IE 的
attachEvent),对外提供统一的 API 和事件对象。 - 性能优化(事件委托):React 并不会把事件处理函数直接绑定到真实的 DOM 节点上,而是利用事件委托,把同类事件统一绑定到最外层容器上。这样不仅节省内存,还能在组件频繁挂载/卸载时避免频繁地操作 DOM 添加/移除监听器。
- 更完善的事件能力:统一的合成事件对象,配合 React 的批量更新、优先级调度。
3. React 16 vs React 17/18 的重大区别
面试必考:React 17 在事件机制上做了什么重大改变?
在 React 16 及以前,所有的事件都会被委托到全局的
document对象上。 这会导致一个严重问题:如果同一个页面上有多个 React 版本共存(如微前端架构),或者 React 和 jQuery 混用,阻止事件冒泡(e.stopPropagation())会失效或产生冲突。在 React 17 及以后,事件委托的节点从
document更改为了 渲染 React 树的根节点(root容器)。 这完美解决了微前端下多个 React 实例的事件冲突问题。
4. 合成事件的执行机制与收集过程
React 的事件机制其实就是一个典型的事件代理,基本原理如下:
- 事件注册:初始化根节点(Root)时,在根节点上绑定所有原生事件的监听器并分配优先级。
- 事件触发与收集:当用户点击一个按钮时,原生事件冒泡到 Root 节点被拦截。React 会根据当前点击的
event.target找到对应的 Fiber 节点。 - 构造执行队列:React 从这个 Fiber 节点开始,一路向上遍历到 Root 节点,收集沿途所有组件的
onClick和onClickCapture属性。- 收集到的捕获事件(
onClickCapture)组成一个数组,倒序执行(从父到子)。 - 收集到的冒泡事件(
onClick)组成一个数组,正序执行(从子到父)。
- 收集到的捕获事件(
- 合成与执行:构造统一的
SyntheticEvent对象作为参数,依次执行队列中的回调函数。
经典真题:混合事件输出顺序
如果原生 DOM 事件和 React 合成事件混用,执行顺序是怎样的?
<div ref={parentRef} onClick={parentBubble} onClickCapture={parentCapture}>
<button ref={childRef} onClick={childBubble} onClickCapture={childCapture}>
点击
</button>
</div>
假设外部还在 document 和原生 DOM 上绑了监听。
执行顺序(React 17+):
- 原生捕获:
document原生捕获 ->root容器原生捕获 ->div原生捕获 ->button原生捕获。 - React 合成事件收集与执行:事件到达
root时,React 接管。- 触发收集到的 React 捕获队列:
parentCapture->childCapture - 触发收集到的 React 冒泡队列:
childBubble->parentBubble
- 触发收集到的 React 捕获队列:
- 原生冒泡:
button原生冒泡 ->div原生冒泡 ->root原生冒泡 ->document原生冒泡。
注意:在 React 16 中,合成事件是在原生
document冒泡阶段才触发的,所以 16 版本中原生冒泡永远早于 React 合成事件。而 17+ 版本因为委托在root上,顺序更符合直觉。
5. 阻止事件冒泡的正确姿势
关键认知:React 合成事件的 stopPropagation 不是原生事件对象的 stopPropagation;合成事件是在被委托节点(16 是 document,17+ 是 root)的事件回调里触发的,原生事件的执行先于合成事件。
- 不要混用 React 事件和原生事件(最省心)。
e.stopPropagation()阻止合成事件的冒泡:React 执行事件队列时若判断e.isPropagationStopped()为true,会break跳出队列循环。- 第 2 步不能阻止委托节点上多个相同原生事件的依次执行,需借助原生事件
e.nativeEvent.stopImmediatePropagation():
function handleClick(e) {
e.stopPropagation(); // 阻止 React 合成事件冒泡
e.nativeEvent.stopImmediatePropagation(); // 阻止委托节点上其他原生监听
}
<button onClick={handleClick}></button>;
- 若混用了 React 事件和(非委托节点上的)原生事件还想阻止,可通过判断
e.target是否在某个根容器内来手动拦截:
function contains(root, node) {
while (node) {
if (node === root) return true;
node = node.parentNode;
}
return false;
}
document.body.addEventListener("click", function (e) {
if (contains(root, e.target)) return; // 点击发生在 React 树内,跳过
// ...处理树外点击,如关闭弹窗
});
6. 事件池 (Event Pooling) 废弃
在 React 16 中,为了复用对象、节省内存,合成事件对象被放在“事件池”中管理:React 启动时为每种合成事件分配内存池,用完即回收复用。这带来一个坑——在异步回调(如 setTimeout)中读取 e.target 会因对象被回收而报错,必须提前调用 e.persist()。
在 React 17 中,事件池机制已被彻底废弃。现代浏览器的 GC 性能已足够好,开发者可以随时在异步代码中安全地读取事件对象。
7. 深入:React 16 源码实现(了解)
以下为 React 16 旧架构的源码级流程,帮助理解底层;17+ 实现细节有变化,但「注册 → 收集 → 派发」的思想一脉相承。
7.1 事件注册
React 解析 jsx props 时取出绑定的事件,通过事件 map 找到原生事件类型,再用 listenTo 把事件代理到 document 上。listenTo 会区分 trapCapturedEvent(捕获)与 trapBubbledEvent(冒泡)注册,本质就是 addEventListener 第三个参数为 true/false。
scroll/focus/blur等用捕获注册;其余默认冒泡。video、audio、iframe、object上的媒体事件(如onplay、onpause)document不具备,需在这些标签上单独绑定。
7.2 存储事件回调
React 通过 EventPluginHub 统一管理事件存储:
var listenerBank = {};
var getDictionaryKey = function (inst) {
return "." + inst._rootNodeID;
};
var EventPluginHub = {
putListener: function (inst, registrationName, listener) {
var key = getDictionaryKey(inst);
var bankForRegistrationName =
listenerBank[registrationName] || (listenerBank[registrationName] = {});
bankForRegistrationName[key] = listener;
},
};
以事件类型 registrationName 为一级 key、组件标识 _rootNodeID 为二级 key 存储回调。执行事件时即可通过两个 key 取到触发元素及父级元素的回调。
7.3 事件执行
通过 ReactEventListener.dispatchEvent 进行事件分发,并以事务方式批量更新:
var ReactEventListener = {
dispatchEvent: function (topLevelType, nativeEvent) {
ReactUpdates.batchedUpdates(handleTopLevelImpl, bookKeeping);
},
};
// react 事务更新:简单理解为设置 isBatching 后调用 handleTopLevel
export function batchedUpdates(fn, bookkeeping) {
if (isBatching) {
return fn(bookkeeping);
}
isBatching = true;
try {
return _batchedUpdatesImpl(fn, bookkeeping);
} finally {
isBatching = false;
const controlledComponentsHavePendingUpdates = needsStateRestore();
if (controlledComponentsHavePendingUpdates) {
_flushInteractiveUpdatesImpl();
restoreStateIfNeeded();
}
}
}
核心执行函数 handleTopLevel 会向上收集触发元素的所有父级,再逐个生成合成事件并执行:
function handleTopLevel(bookKeeping) {
let targetInst = bookKeeping.targetInst; // 获取触发对象
let ancestor = targetInst;
// do-while 找到触发元素的所有父级元素,依次加入 ancestors
do {
if (!ancestor) {
bookKeeping.ancestors.push(ancestor);
break;
}
const root = findRootContainerNode(ancestor); // 查找根元素
if (!root) break; // 根不存在说明已从 react tree 移除
bookKeeping.ancestors.push(ancestor);
ancestor = getClosestInstanceFromNode(root); // 最近的父元素
} while (ancestor);
for (let i = 0; i < bookKeeping.ancestors.length; i++) {
targetInst = bookKeeping.ancestors[i];
runExtractedEventsInBatch(
bookKeeping.topLevelType,
targetInst,
bookKeeping.nativeEvent,
getEventTarget(bookKeeping.nativeEvent),
);
}
}
runExtractedEventsInBatch 生成并合并合成事件,得到回调队列,最后 executeDispatchesInOrder 按顺序执行(遇到 isPropagationStopped() 即 break,实现合成事件冒泡阻断):
export function executeDispatchesInOrder(event, simulated) {
const dispatchListeners = event._dispatchListeners;
const dispatchInstances = event._dispatchInstances;
if (Array.isArray(dispatchListeners)) {
for (let i = 0; i < dispatchListeners.length; i++) {
if (event.isPropagationStopped()) break; // 阻止 react 事件冒泡
executeDispatch(
event,
simulated,
dispatchListeners[i],
dispatchInstances[i],
);
}
} else if (dispatchListeners) {
executeDispatch(event, simulated, dispatchListeners, dispatchInstances);
}
event._dispatchListeners = null;
event._dispatchInstances = null;
}