深入探究 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
处理简单事件,例如onClick
EnterLeaveEventPlugin
处理鼠标进出事件,例如onMouseEnter
ChangeEventPlugin
处理修改事件,例如onChange
SelectEventPlugin
处理选择事件,例如onSelect
BeforeInputEventPlugin
处理输入前事件,例如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
变量中,因为它们是需要特殊处理的。
这些事件包括:
onAnimationEnd
onAnimationIteration
onAnimationStart
onDoubleClick
onFocus
onBlur
onTransitionEnd
其中,与 Animation
和 Transition
相关的事件分别是 AnimationEvent
和 TransitionEvent
。
由于浏览器兼容性的问题,react-dom
通过函数 getVendorPrefixedEventName
来实现对它们的兼容性处理(源码)
另外,对于 onDoubleClick
、onFocus
和 onBlur
这三个事件,它们的 reactEventName
与对应的 domEventName
不同,因此需要特殊处理:
onDoubleClick
对应的domEventName
是dbclick
onFocus
对应的domEventName
是focusin
onBlur
对应的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
为true
addEventBubbleListenerWithPassiveFlag
: 冒泡事件,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
对应的fiber
type
:原生事件类型,比如click
nativeEvent
:原生事件信息,event
target
:原生节点,比如onClick
preventDefault
: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
节点对应的fiber
listener
:监听器函数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
被执行,初始化事件挂载相关的参数topLevelEventsToReactNames
registrationNameDependencies
allNativeEvents
- 事件注册,将事件挂载到对应的节点
- 处理事件监听器
- 合成事件
- 事件监听器收集
- 事件触发时执行事件
一个不理解的地方
事件队列是一个数组 Array<DispatchEntry>
我测试了多种事件,dispatchQueue
都没有出现多项的情况,冒泡或者捕获的事件时存放在 listeners
属性中的,所以我不知道在什么情况下会 dispatchQueue
出现多项
react 中事件名映射
-
allNativeEvents
-
topLevelEventsToReactNames
-
registrationNameDependencies
转载自:https://juejin.cn/post/7210375522512109623