Skip to main content

合成事件

React 的合成事件(SyntheticEvent)是前端面试中极高频的考点。

面试高频问题概览:

  • 为什么要设计合成事件?它和原生事件有什么关系?
  • React 17 在事件机制上做了什么重大改变?为什么?
  • 原生事件与合成事件混用时,执行顺序是怎样的?
  • React 里如何正确阻止事件冒泡?

1. JS 事件机制回顾(前置)

理解合成事件前,先回顾原生事件机制。

事件触发三阶段

  1. 捕获阶段:从 window 往事件触发处传播,遇到捕获监听(addEventListener(type, fn, true))会触发。
  2. 目标阶段:传播到事件触发处,触发目标元素的回调。
  3. 冒泡阶段:从触发处往 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. 为什么要使用合成事件?

  1. 跨浏览器兼容:React 顶层抹平了各个浏览器之间的事件机制差异(比如 IE 的 attachEvent),对外提供统一的 API 和事件对象。
  2. 性能优化(事件委托):React 并不会把事件处理函数直接绑定到真实的 DOM 节点上,而是利用事件委托,把同类事件统一绑定到最外层容器上。这样不仅节省内存,还能在组件频繁挂载/卸载时避免频繁地操作 DOM 添加/移除监听器。
  3. 更完善的事件能力:统一的合成事件对象,配合 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 的事件机制其实就是一个典型的事件代理,基本原理如下:

  1. 事件注册:初始化根节点(Root)时,在根节点上绑定所有原生事件的监听器并分配优先级。
  2. 事件触发与收集:当用户点击一个按钮时,原生事件冒泡到 Root 节点被拦截。React 会根据当前点击的 event.target 找到对应的 Fiber 节点。
  3. 构造执行队列:React 从这个 Fiber 节点开始,一路向上遍历到 Root 节点,收集沿途所有组件的 onClickonClickCapture 属性。
    • 收集到的捕获事件(onClickCapture)组成一个数组,倒序执行(从父到子)。
    • 收集到的冒泡事件(onClick)组成一个数组,正序执行(从子到父)。
  4. 合成与执行:构造统一的 SyntheticEvent 对象作为参数,依次执行队列中的回调函数。

经典真题:混合事件输出顺序

如果原生 DOM 事件和 React 合成事件混用,执行顺序是怎样的?

<div ref={parentRef} onClick={parentBubble} onClickCapture={parentCapture}>
<button ref={childRef} onClick={childBubble} onClickCapture={childCapture}>
点击
</button>
</div>

假设外部还在 document 和原生 DOM 上绑了监听。

执行顺序(React 17+)

  1. 原生捕获document 原生捕获 -> root 容器原生捕获 -> div 原生捕获 -> button 原生捕获。
  2. React 合成事件收集与执行:事件到达 root 时,React 接管。
    • 触发收集到的 React 捕获队列:parentCapture -> childCapture
    • 触发收集到的 React 冒泡队列:childBubble -> parentBubble
  3. 原生冒泡button 原生冒泡 -> div 原生冒泡 -> root 原生冒泡 -> document 原生冒泡。

注意:在 React 16 中,合成事件是在原生 document 冒泡阶段才触发的,所以 16 版本中原生冒泡永远早于 React 合成事件。而 17+ 版本因为委托在 root 上,顺序更符合直觉。

5. 阻止事件冒泡的正确姿势

关键认知:React 合成事件的 stopPropagation 不是原生事件对象的 stopPropagation;合成事件是在被委托节点(16 是 document,17+ 是 root)的事件回调里触发的,原生事件的执行先于合成事件。

  1. 不要混用 React 事件和原生事件(最省心)。
  2. e.stopPropagation() 阻止合成事件的冒泡:React 执行事件队列时若判断 e.isPropagationStopped()true,会 break 跳出队列循环。
  3. 第 2 步不能阻止委托节点上多个相同原生事件的依次执行,需借助原生事件 e.nativeEvent.stopImmediatePropagation()
function handleClick(e) {
e.stopPropagation(); // 阻止 React 合成事件冒泡
e.nativeEvent.stopImmediatePropagation(); // 阻止委托节点上其他原生监听
}

<button onClick={handleClick}></button>;
  1. 若混用了 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 上的媒体事件(如 onplayonpausedocument 不具备,需在这些标签上单独绑定。

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;
}