深入探究 React 原生事件的工作原理
本文对应的 react 版本是 18.2.0
ReactDOM 是 react 的 dom 渲染器,它从 react-dom/client 中导出
当我们调用 ReactDOM.createRoot 方法时,createRoot 函数会调用 listenToAllSupportedEvents 方法
listenToAllSupportedEvents 函数定义在 react-dom-bindings/src/events/DOMPluginEventSystem 文件中
当 DOMPluginEventSystem 文件被执行时,react 会进行事件注册:
SimpleEventPlugin.registerEvents();
EnterLeaveEventPlugin.registerEvents();
ChangeEventPlugin.registerEvents();
SelectEventPlugin.registerEvents();
BeforeInputEventPlugin.registerEvents();
可以看到,react 将事件分为五类,分别由不同的事件插件进行处理:
SimpleEventPlugin处理简单事件,例如onClickEnterLeaveEventPlugin处理鼠标进出事件,例如onMouseEnterChangeEventPlugin处理修改事件,例如onChangeSelectEventPlugin处理选择事件,例如onSelectBeforeInputEventPlugin处理输入前事件,例如onBeforeInput
这些事件插件负责将浏览器原生事件转换为 react 事件,并将其分发到正确的组件中
事件注册前准备
下面以 SimpleEventPlugin 为例,讲解事件注册的流程。了解了 SimpleEventPlugin.registerEvents,其他四个事件也就更容易理解了。
虽然这个函数名叫做 registerEvents,但实际上这个函数还没到注册事件的步骤,它只是准备事件名。为此,这个函数会提供三个变量(见【获取所有事件】)。
简单事件
简单事件指只依赖自身的事件,例如 onClick 只依赖 click。
有些事件依赖其他事件,例如 onMouseEnter 依赖 ["mouseout", "mouseover"]。react 这么做简化了开发者自己监听事件的步骤。否则,开发者自己实现 onMouseEnter 这种事件,需要分别监听 mouseout 和 mouseover 事件。
获取所有事件
react 中的事件都是硬编码的,保存在 simpleEventPluginEvents 变量中
const simpleEventPluginEvents = ["click", "mousedown", "mouseenter", ...];
遍历 simpleEventPluginEvents 列表,react 将事件名处理成 domEventName 和 reactEventName,也就是 react 事件名和 dom 事件名的对应关系。
domEventName:dom事件名全小写,例如mousedown、mouseenter等reactEventName:react事件名是驼峰形式,例如onClick、onMouseDown等
在 react 中,为了处理 dom 事件,react 定义了一些与原生 dom 事件名对应的 react 事件名。
这些事件名以 on 开头,比如 onClick、onFocus 等。同时,react 还定义了一些事件之间的依赖关系,即某些事件需要依赖于其他事件才能正常工作
为了实现这些功能,react 使用了三个变量(详细的事件名在文章底部):
topLevelEventsToReactNames:这个变量是一个Map,保存着原生dom事件名和对应的react事件名之间的映射关系。例如,click事件对应着onClick事件。一共有75个映射关系registrationNameDependencies:这个变量是一个普通的对象,保存着react事件名和依赖事件名之间的关系。例如,onClick事件依赖于click事件。一共有166个依赖关系。allNativeEvents:这个变量是一个Set,保存了所有原生dom事件名。一共有81个事件名。
特殊处理
这里有 7 个事件不在 SimpleEventPluginEvents 变量中,因为它们是需要特殊处理的。
这些事件包括:
onAnimationEndonAnimationIterationonAnimationStartonDoubleClickonFocusonBluronTransitionEnd
其中,与 Animation 和 Transition 相关的事件分别是 AnimationEvent 和 TransitionEvent。
由于浏览器兼容性的问题,react-dom 通过函数 getVendorPrefixedEventName 来实现对它们的兼容性处理(源码)
另外,对于 onDoubleClick、onFocus 和 onBlur 这三个事件,它们的 reactEventName 与对应的 domEventName 不同,因此需要特殊处理:
onDoubleClick对应的domEventName是dbclickonFocus对应的domEventName是focusinonBlur对应的domEventName是focusout
这些细节处理有助于确保 react 应用程序在不同浏览器上的正确运行
事件注册
在 react 中,事件注册是非常重要的,因为它关系到组件的交互和性能
react 事件注册来自于 listenToAllSupportedEvents 函数
事件注册分为 3 种情况:
- 绑定在
document - 绑定在
div#root - 绑定在目标元素
target
从
react@18开始,事件绑定在页面根元素中(也就是div#root),不再绑定在document上
绑定在 document
只有 selectionchange 事件是绑定在 document
绑定在 target
这些事件是不会冒泡的(不需要委托),它们的事件是绑定在事件事件发生的元素身上,这部分事件有普通事件和媒体事件组成,都是硬编码在代码中
- mediaEventTypes:
play、pause - nonDelegatedEvents:
load、scroll
绑定在 div#root
从 allNativeEvents 中排除掉 nonDelegatedEvents 事件,剩下的事件都是绑定在 div#root 上
注册
在事件注册前判断浏览器是否支持 passive,如果支持,则将 passive 设置为true,浏览器永远不会调用 event.preventDefault(),用于提升性能
这个属性用于 touchstart、touchmove、wheel 事件中
然后调用函数分别注册事件,源码
addEventBubbleListener:冒泡事件addEventCaptureListener:捕获事件addEventCaptureListenerWithPassiveFlag:捕获事件,passive为trueaddEventBubbleListenerWithPassiveFlag: 冒泡事件,passive为true
在注册事件时事件监听器 react 会经过一系列的处理,最后返回一个 (e) => { .... }
react 处理的这一步,我们叫做合成事件
事件优先级
react 将事件优先级分为 4 种:
- 离散事件优先级,例如:点击事件,
input输入等触发的更新任务,优先级最高SyncLane - 连续事件优先级,例如:滚动事件,拖动事件等,连续触发的事件 优先级次之,为
InputContinuousLane - 默认事件优先级,例如:
setTimeout触发的更新任务,为DefaultLane - 闲置事件优先级,优先级最低,为
IdleLane
react 在事件注册时根据事件名设置不同的优先级,getEventPriority
特殊处理了 message 事件(它的优先级是根据 Scheduler 回调来调度的)
事件队列(合成事件)
这一部分是 react-dom 的核心,react 这么做的目的是为了解决各浏览器之间的差异
事件队列 dispatchQueue 包含两个参数:
event:对应的是react合成事件,见【收集合成事件】listeners[]:目标节点对应的事件监听器,从目标节点开始,一直到祖先节点,见【收集事件监听器】
收集合成事件
合成事件 SyntheticEvent是 react 最核心的一部分
提取合成事件也分为 5 个插件:
SimpleEventPlugin.extractEvents(...)EnterLeaveEventPlugin.extractEvents(...)ChangeEventPlugin.extractEvents(...)SelectEventPlugin.extractEvents(...)BeforeInputEventPlugin.extractEvents(...)
其中 EnterLeaveEventPlugin、ChangeEventPlugin、SelectEventPlugin、BeforeInputEventPlugin 插件的提取事件只在冒泡阶段执行
这些函数内部,react 都会创建一个基本的合成事件,然后再根据事件名分成若干种合成事件
这些合成事件都是由 createSyntheticEvent 函数创建的
react 按照事件名分成了 12 种合成事件:
- SyntheticEvent
- SyntheticKeyboardEvent
- SyntheticFocusEvent
- SyntheticMouseEvent
- SyntheticDragEvent
- SyntheticTouchEvent
- SyntheticAnimationEvent
- SyntheticTransitionEvent
- SyntheticUIEvent
- SyntheticWheelEvent
- SyntheticClipboardEvent
- SyntheticPointerEvent
- SyntheticCompositionEvent
- SyntheticInputEvent
合成事件有一些基本属性(其他属性对应不同的对应不同的实例,可以点击上面查看源码):
_reactName:react事件名_targetInst:target对应的fibertype:原生事件类型,比如clicknativeEvent:原生事件信息,eventtarget:原生节点,比如onClickpreventDefault:react实现stopPropagation:react实现persist:react实现isPersistent:react实现
除了这些属性之外,react 把 nativeEvent 中信息都提取到了合成事件中,通过下面这段代码实现
// Interface 是 react 定义的各种合成事件
// nativeEvent 是原生事件
for (const propName in Interface) {
if (!Interface.hasOwnProperty(propName)) {
continue;
}
const normalize = Interface[propName];
// normalize 如果存在,说明 propName 对应的属性在合成事件中是一个函数
// react 这么设计的原因是为了消除不同浏览器之间的差异
// 见[WheelEventInterface 事件]
if (normalize) {
this[propName] = normalize(nativeEvent);
} else {
this[propName] = nativeEvent[propName];
}
}
WheelEventInterface
这里用 WheelEventInterface 事件来说明 react 为什么要定义 normalize 源码
比如 wheel 事件中的 deltaY 属性:
- 在
webkit中是wheelDeltaY - 在
IE9以下是wheelDelta
react 把这些可能存在兼容问题的属性值写成了函数,没有兼容问题的属性值写成了 0
当代码执行到这里时,react 会根据它自身的判断来达到消除浏览器之间的差异
收集事件监听器
遍历目标节点(target)到祖先节点(div#root),找到所有注册了该事件类型的监听器,将它们存储在一个数组中:
listeners = [targetListener, ..., rootListener]
监听器的属性包括:
instance:dom节点对应的fiberlistener:监听器函数currentTarget:目标节点target
在收集监听器的函数中,react 从目标节点开始向上遍历,instance.return 得到的值是当前节点的父节点
// 目标节点
let instance = targetFiber;
while (instance !== null) {
// 用 vite 创建的项目
// stateNode: img, tag: 5
// stateNode: a , tag: 5
// stateNode: div, tag: 5
// stateNode: div.app, tag: 5
// stateNode: null, tag: 5
// stateNode: null, tag: 8
// stateNode: FiberRootNode, tag: 3
// tag 类型是 WorkTag,文件:packages/react-reconciler/src/ReactWorkTags.js
// stateNode 与 fiber 相关的 dom
const { stateNode, tag } = instance;
// 省略若干代码 ...
const listener = getListener(instance, reactEventName);
if (listener != null) {
listeners.push(
createDispatchListener(instance, listener, lastHostComponent)
);
}
// 省略若干代码 ...
// 当前节点的父节点
instance = instance.return;
}
当监听器和合成事件都准备好后,将他们放入 dispatchQueue 队列中
执行事件队列
执行事件队列分为冒泡和捕获阶段
在上一节中我们得到事件监听器是这样存储的
listeners = [targetListener, ..., rootListener]
再执行事件队列时,需要判断当前处于什么阶段:
- 如果当前是捕获阶段,从根节点开始(也就是从队列的最后一个开始执行)
- 如果当前是冒泡节点,从目标节点开始(也就是从队列的第一个开始执行)
在执行回调函数时,React 会记录错误并继续执行应用程序,它确保了在 React 组件中发生的错误不会导致整个应用程序崩溃
总结
- 在调用
listenToAllSupportedEvents函数时,react-dom-bindings/src/events/DOMPluginEventSystem被执行,初始化事件挂载相关的参数topLevelEventsToReactNamesregistrationNameDependenciesallNativeEvents
- 事件注册,将事件挂载到对应的节点
- 处理事件监听器
- 合成事件
- 事件监听器收集
- 事件触发时执行事件
一个不理解的地方
事件队列是一个数组 Array<DispatchEntry>

我测试了多种事件,dispatchQueue 都没有出现多项的情况,冒泡或者捕获的事件时存放在 listeners 属性中的,所以我不知道在什么情况下会 dispatchQueue 出现多项
react 中事件名映射
-
allNativeEvents
-
topLevelEventsToReactNames
-
registrationNameDependencies

转载自:https://juejin.cn/post/7210375522512109623