点击进入React源码调试仓库。
为什么要本人实现一套事件机制
因为fiber机制的特点,生成一个fiber节点时,它对应的dom节点有可能还未挂载,onClick这样的事件处理函数作为fiber节点的prop,也就不能间接被绑定到实在的DOM节点上。
为此,React提供了一种“顶层注册,事件收集,对立触发”的事件机制。
所谓“顶层注册”,其实是在root元素上绑定一个对立的事件处理函数。“事件收集”指的是事件触发时(实际上是root上的事件处理函数被执行),结构合成事件对象,依照冒泡或捕捉的门路去组件中收集真正的事件处理函数。“对立触发”产生在收集过程之后,对所收集的事件逐个执行,并共享同一个合成事件对象。这里有一个重点是绑定到root上的事件监听并非咱们写在组件中的事件处理函数,留神这个区别,下文会提到。
以上是React事件机制的简述,这套机制躲避了无奈将事件间接绑定到DOM节点上的问题,并且可能很好地利用fiber树的层级关系来生成事件执行门路,进而模仿事件捕捉和冒泡,另外还带来两个十分重要的个性:
- 对事件进行归类,能够在事件产生的工作上蕴含不同的优先级
- 提供合成事件对象,抹平浏览器的兼容性差别
本文会对事件机制进行具体解说,贯通一个事件从注册到被执行的生命周期。
事件注册
与之前版本不同,React17的事件是注册到root上而非document,这次要是为了渐进降级,防止多版本的React共存的场景中事件零碎发生冲突。
当咱们为一个元素绑定事件时,会这样写:
<div onClick={() => {/*do something*/}}>React</div>
这个div节点最终要对应一个fiber节点,onClick则作为它的prop。当这个fiber节点进入render阶段的complete阶段时,名称为onClick的prop会被辨认为事件进行解决。
function setInitialDOMProperties(
tag: string,
domElement: Element,
rootContainerElement: Element | Document,
nextProps: Object,
isCustomComponentTag: boolean,
): void {
for (const propKey in nextProps) {
if (!nextProps.hasOwnProperty(propKey)) {
...
} else if (registrationNameDependencies.hasOwnProperty(propKey)) {
// 如果propKey属于事件类型,则进行事件绑定
ensureListeningTo(rootContainerElement, propKey, domElement);
}
}
}
}
registrationNameDependencies是一个对象,存储了所有React事件对应的原生DOM事件的汇合,这是辨认prop是否为事件的根据。如果是事件类型的prop,那么将会调用ensureListeningTo去绑定事件。
接下来的绑定过程能够概括为如下几个关键点:
- 依据React的事件名称寻找该事件依赖,例如onMouseEnter事件依赖了mouseout和mouseover两个原生事件,onClick只依赖了click一个原生事件,最终会循环这些依赖,在root上绑定对应的事件。例如组件中为onClick,那么就会在root上绑定一个click事件监听。
- 根据组件中写的事件名辨认其属于哪个阶段的事件(冒泡或捕捉),例如
onClickCapture
这样的React事件名称就代表是须要事件在捕捉阶段触发,而onClick
代表事件须要在冒泡阶段触发。 - 依据React事件名,找出对应的原生事件名,例如
click
,并依据上一步来判断是否须要在捕捉阶段触发,调用addEventListener
,将事件绑定到root元素上。 - 若事件须要更新,那么先移除事件监听,再从新绑定,绑定过程反复以上三步。
通过这一系列过程,事件监听器listener最终被绑定到root元素上。
// 依据事件名称,创立不同优先级的事件监听器。
let listener = createEventListenerWrapperWithPriority(
targetContainer,
domEventName,
eventSystemFlags,
listenerPriority,
);
// 绑定事件
if (isCapturePhaseListener) {
...
unsubscribeListener = addEventCaptureListener(
targetContainer,
domEventName,
listener,
);
} else {
...
unsubscribeListener = addEventBubbleListener(
targetContainer,
domEventName,
listener,
);
}
事件监听器listener是谁
下面提到的绑定事件的时候,绑定到root上的事件监听函数是listener,然而这个listener并不是咱们间接在组件里写的事件处理函数。通过下面的代码可知,listener是createEventListenerWrapperWithPriority
的调用后果
为什么要创立这么一个listener,而不是间接绑定写在组件里的事件处理函数呢?
其实createEventListenerWrapperWithPriority
这个函数名曾经说出了答案:根据优先级创立一个事件监听包装器。有两个重点:优先级和事件监听包装器。这里的优先级是指事件优先级(对于事件优先级的具体介绍请移步React中的优先级 )。
事件优先级是依据事件的交互水平划分的,优先级和事件名的映射关系存在于一个Map构造中。createEventListenerWrapperWithPriority
会依据事件名或者传入的优先级返回不同级别的事件监听包装器。
总的来说,会有三种事件监听包装器:
- dispatchDiscreteEvent: 解决离散事件
- dispatchUserBlockingUpdate:解决用户阻塞事件
- dispatchEvent:解决间断事件
这些包装器是真正绑定到root上的事件监听器listener,它们持有各自的优先级,当对应的事件触发时,调用的其实是这个蕴含优先级的事件监听。
透传事件执行阶段标记
到这里咱们先梳理一下,root上绑定的是这个持有优先级的事件监听,触发它会使组件中实在的事件得以触发。但到目前为止有一点并未包含在内,也就是事件执行阶段的辨别。组件中注册事件尽管能够以事件名 + “Capture”后缀的模式辨别未来的执行阶段,但这和真正执行事件其实是两回事,所以当初关键在于如何将注册事件时显式申明的执行阶段真正落实到执行事件的行为上。
对于这一点咱们能够关注createEventListenerWrapperWithPriority
函数中的其中一个入参:eventSystemFlags。它是事件零碎的一个标记,记录事件的各种标记,其中一个标记就是IS_CAPTURE_PHASE,这表明了以后的事件是捕捉阶段触发。当事件名含有Capture后缀时,eventSystemFlags会被赋值为IS_CAPTURE_PHASE。
之后在以优先级创立绑定到root上的事件监听时,eventSystemFlags会作为它执行时的入参,传递进去。因而,在事件触发的时候就能够晓得组件中的事件是以冒泡或是捕捉的程序执行。
function dispatchDiscreteEvent(
domEventName,
eventSystemFlags,
container,
nativeEvent,
) {
...
discreteUpdates(
dispatchEvent,
domEventName,
eventSystemFlags, // 传入事件执行阶段的标记
container,
nativeEvent,
);
}
小结
当初咱们应该能分明两点:
- 事件处理函数不是绑定到组件的元素上的,而是绑定到root上,这和fiber树的构造特点无关,即事件处理函数只能作为fiber的prop。
- 绑定到root上的事件监听不是咱们在组件里写的事件处理函数,而是一个持有事件优先级,并能传递事件执行阶段标记的监听器。
目前,注册阶段的工作曾经实现,上面会讲一讲事件是如何被触发的,让咱们从绑定到root上的监听器切入,看看它做了什么。
事件触发 – 事件监听器listener做了什么
它做的事件能够用一句话概括:负责以不同的优先级权重来触发真正的事件流程,并传递事件执行阶段标记(eventSystemFlags)。
比方一个元素绑定了onClick事件,那么点击它的时候,绑定在root上的listener会被触发,会最终使得组件中的事件被执行。
也就是说绑定到root上的事件监听listener只是相当于一个传令官,它依照事件的优先级去安顿接下来的工作:事件对象的合成、将事件处理函数收集到执行门路、 事件执行,这样在前面的调度过程中,scheduler能力获知当前任务的优先级,而后开展调度。
如何将优先级传递进来?
利用scheduler中的runWithPriority
函数,通过调用它,将优先级记录到利用scheduler中,所以调度器能力在调度的时候晓得当前任务的优先级。runWithPriority
的第二个参数,会去安顿下面提到的三个工作。
以用户阻塞的优先级级别为例:
function dispatchUserBlockingUpdate(
domEventName,
eventSystemFlags,
container,
nativeEvent,
) {
...
runWithPriority(
UserBlockingPriority,
dispatchEvent.bind(
null,
domEventName,
eventSystemFlags,
container,
nativeEvent,
),
);
}
dispatchUserBlockingUpdate调用runWithPriority,并传入UserBlockingPriority优先级,这样就能够将UserBlockingPriority的优先级记录到Scheduler中,后续React计算各种优先级都是基于这个UserBlockingPriority优先级。
除了传递优先级,它做的其它重要的事件就是触发事件对象的合成、将事件处理函数收集到执行门路、 事件执行这三个过程,也就是到了事件的执行阶段。root上的事件监听最终触发的是dispatchEventsForPlugins
。
这个函数体可看成两局部:事件对象的合成和事件收集 、 事件执行,涵盖了上述三个过程。
function dispatchEventsForPlugins(
domEventName: DOMEventName,
eventSystemFlags: EventSystemFlags,
nativeEvent: AnyNativeEvent,
targetInst: null | Fiber,
targetContainer: EventTarget,
): void {
const nativeEventTarget = getEventTarget(nativeEvent);
const dispatchQueue: DispatchQueue = [];
// 事件对象的合成,收集事件到执行门路上
extractEvents(
dispatchQueue,
domEventName,
targetInst,
nativeEvent,
nativeEventTarget,
eventSystemFlags,
targetContainer,
);
// 执行收集到的组件中真正的事件
processDispatchQueue(dispatchQueue, eventSystemFlags);
}
dispatchEventsForPlugins
函数中事件的流转有一个重要的载体:dispatchQueue,它承载了本次合成的事件对象和收集到事件执行门路上的事件处理函数。
listeners是事件执行门路,event是合成事件对象,收集组件中真正的事件到执行门路,以及事件对象的合成通过extractEvents实现。
事件对象的合成和事件的收集
到这里咱们应该分明,root上的事件监听被触发会引发事件对象的合成和事件的收集过程,这是为真正的事件触发做筹备。
合成事件对象
在组件中的事件处理函数中拿到的事件对象并不是原生的事件对象,而是通过React合成的SyntheticEvent
对象。它解决了不同浏览器之间的兼容性差别。形象成对立的事件对象,解除开发者的心智累赘。
事件执行门路
当事件对象合成结束,会将事件收集到事件执行门路上。什么是事件执行门路呢?
在浏览器的环境中,若父子元素绑定了雷同类型的事件,除非手动干涉,那么这些事件都会依照冒泡或者捕捉的程序触发。
在React中也是如此,从触发事件的元素开始,根据fiber树的层级构造向上查找,累加下级元素中所有雷同类型的事件,最终造成一个具备所有雷同类型事件的数组,这个数组就是事件执行门路。通过这个门路,React本人模仿了一套事件捕捉与冒泡的机制。
下图是事件对象的包装和收集事件(冒泡的门路为例)的大抵过程
因为不同的事件会有不同的行为和解决机制,所以合成事件对象的结构和收集事件到执行门路须要通过插件实现。一共有5种Plugin:SimpleEventPlugin,EnterLeaveEventPlugin,ChangeEventPlugin,SelectEventPlugin,BeforeInputEventPlugin。它们的使命齐全一样,只是解决的事件类别不同,所以外部会有一些差别。本文只以SimpleEventPlugin
为例来解说这个过程,它解决比拟通用的事件类型,比方click、input、keydown
等。
以下是SimpleEventPlugin中结构合成事件对象并收集事件的代码。
function extractEvents(
dispatchQueue: DispatchQueue,
domEventName: DOMEventName,
targetInst: null | Fiber,
nativeEvent: AnyNativeEvent,
nativeEventTarget: null | EventTarget,
eventSystemFlags: EventSystemFlags,
targetContainer: EventTarget,
): void {
const reactName = topLevelEventsToReactNames.get(domEventName);
if (reactName === undefined) {
return;
}
let EventInterface;
switch (domEventName) {
// 赋值EventInterface(接口)
}
// 结构合成事件对象
const event = new SyntheticEvent(
reactName,
null,
nativeEvent,
nativeEventTarget,
EventInterface,
);
const inCapturePhase = (eventSystemFlags & IS_CAPTURE_PHASE) !== 0;
if (/*...*/) {
...
} else {
// scroll事件不冒泡
const accumulateTargetOnly =
!inCapturePhase &&
domEventName === 'scroll';
// 事件对象散发 & 收集事件
accumulateSinglePhaseListeners(
targetInst,
dispatchQueue,
event,
inCapturePhase,
accumulateTargetOnly,
);
}
return event;
}
创立合成事件对象
这个对立的事件对象由SyntheticEvent
函数结构而成,它本人遵循W3C的标准又实现了一遍浏览器的事件对象接口,这样能够抹平差别,而原生的事件对象只不过是它的一个属性(nativeEvent)。
// 结构合成事件对象
const event = new SyntheticEvent(
reactName,
null,
nativeEvent,
nativeEventTarget,
EventInterface,
);
收集事件到执行门路
这个过程是将组件中真正的事件处理函数收集到数组中,期待下一步的批量执行。
先看一个例子,指标元素是counter,父级元素是counter-parent。
class EventDemo extends React.Component{
state = { count: 0 }
onDemoClick = () => {
console.log('counter的点击事件被触发了');
this.setState({
count: this.state.count + 1
})
}
onParentClick = () => {
console.log('父级元素的点击事件被触发了');
}
render() {
const { count } = this.state
return <div
className={'counter-parent'}
onClick={this.onParentClick}
>
<div
onClick={this.onDemoClick}
className={'counter'}
>
{count}
</div>
</div>
}
}
当点击counter时,父元素上的点击事件也会被触发,相继打印出:
'counter的点击事件被触发了'
'父级元素的点击事件被触发了'
实际上这是将事件以冒泡的程序收集到执行门路之后导致的。收集的过程由accumulateSinglePhaseListeners
实现。
accumulateSinglePhaseListeners(
targetInst,
dispatchQueue,
event,
inCapturePhase,
accumulateTargetOnly,
);
函数外部最重要的操作无疑是收集事件到执行门路,为了实现这一操作,须要在fiber树中从触发事件的源fiber节点开始,向上始终找到root,造成一条残缺的冒泡或者捕捉的门路。同时,沿途路过fiber节点时,依据事件名,从props中获取咱们真正写在组件中的事件处理函数,push到门路中,期待下一步的批量执行。
上面是该过程精简后的源码
export function accumulateSinglePhaseListeners(
targetFiber: Fiber | null,
dispatchQueue: DispatchQueue,
event: ReactSyntheticEvent,
inCapturePhase: boolean,
accumulateTargetOnly: boolean,
): void {
// 依据事件名来辨认是冒泡阶段的事件还是捕捉阶段的事件
const bubbled = event._reactName;
const captured = bubbled !== null ? bubbled + 'Capture' : null;
// 申明寄存事件监听的数组
const listeners: Array<DispatchListener> = [];
// 找到指标元素
let instance = targetFiber;
// 从指标元素开始始终到root,累加所有的fiber对象和事件监听。
while (instance !== null) {
const {stateNode, tag} = instance;
if (tag === HostComponent && stateNode !== null) {
const currentTarget = stateNode;
// 事件捕捉
if (captured !== null && inCapturePhase) {
// 从fiber中获取事件处理函数
const captureListener = getListener(instance, captured);
if (captureListener != null) {
listeners.push(
createDispatchListener(instance, captureListener, currentTarget),
);
}
}
// 事件冒泡
if (bubbled !== null && !inCapturePhase) {
// 从fiber中获取事件处理函数
const bubbleListener = getListener(instance, bubbled);
if (bubbleListener != null) {
listeners.push(
createDispatchListener(instance, bubbleListener, currentTarget),
);
}
}
}
instance = instance.return;
}
// 收集事件对象
if (listeners.length !== 0) {
dispatchQueue.push(createDispatchEntry(event, listeners));
}
}
无论事件是在冒泡阶段执行,还是捕捉阶段执行,都以同样的程序push到dispatchQueue的listeners中,而冒泡或者捕捉事件的执行程序不同是因为清空listeners数组的程序不同。
留神,每次收集只会收集与事件源雷同类型的事件,比方子元素绑定了onClick,父元素绑定了onClick和onClickCapture:
<div
className="parent"
onClick={onClickParent}
onClickCapture={onClickParentCapture}
>
父元素
<div
className="child"
onClick={onClickChild}
>
子元素
</div>
</div>
那么点击子元素时,收集的将是onClickChild
和 onClickParent
。
收集的后果如下
合成事件对象如何参加到事件执行过程
下面咱们说过,dispatchQueue的构造如上面这样
[
{
event: SyntheticEvent,
listeners: [ listener1, listener2, ... ]
}
]
event就代表着合成事件对象,能够将它认为是这些listeners共享的一个事件对象。当清空listeners数组执行到每一个事件监听函数时,这个事件监听能够扭转event上的currentTarget,也能够调用它下面的stopPropagation办法来阻止冒泡。event作为一个共享资源被这些事件监听生产,生产的行为产生在事件执行时。
事件执行
通过事件和事件对象收集的过程,失去了一条残缺的事件执行门路,还有一个被共享的事件对象,之后进入到事件执行过程,从头到尾循环该门路,顺次调用每一项中的监听函数。这个过程的重点在于事件冒泡和捕捉的模仿,以及合成事件对象的利用,如下是从dispatchQueue中提取出事件对象和工夫执行门路的过程。
export function processDispatchQueue(
dispatchQueue: DispatchQueue,
eventSystemFlags: EventSystemFlags,
): void {
const inCapturePhase = (eventSystemFlags & IS_CAPTURE_PHASE) !== 0;
for (let i = 0; i < dispatchQueue.length; i++) {
// 从dispatchQueue中取出事件对象和事件监听数组
const {event, listeners} = dispatchQueue[i];
// 将事件监听交由processDispatchQueueItemsInOrder去触发,同时传入事件对象供事件监听应用
processDispatchQueueItemsInOrder(event, listeners, inCapturePhase);
}
// 捕捉谬误
rethrowCaughtError();
}
模仿冒泡和捕捉
冒泡和捕捉的执行程序是不一样的,然而当初在收集事件的时候,无论是冒泡还是捕捉,事件都是间接push到门路里的。那么执行程序的差别是如何体现的呢?答案是循环门路的程序不一样导致了执行程序有所不同。
首先回顾一下dispatchQueue中的listeners中的事件处理函数排列程序:触发事件的指标元素的事件处理函数排在第一个,下层组件的事件处理函数顺次往后排。
<div onClick={onClickParent}>
父元素
<div onClick={onClickChild}>
子元素
</div>
</div>
listeners: [ onClickChild, onClickParent ]
从左往右循环的时候,指标元素的事件先触发,父元素事件顺次执行,这与冒泡的程序一样,那捕捉的程序天然是从右往左循环了。模仿冒泡和捕捉执行事件的代码如下:
其中判断事件执行阶段的根据inCapturePhase,它的起源在下面的透传透传事件执行阶段标记的内容里曾经提到过。
function processDispatchQueueItemsInOrder(
event: ReactSyntheticEvent,
dispatchListeners: Array<DispatchListener>,
inCapturePhase: boolean,
): void {
let previousInstance;
if (inCapturePhase) {
// 事件捕捉倒序循环
for (let i = dispatchListeners.length - 1; i >= 0; i--) {
const {instance, currentTarget, listener} = dispatchListeners[i];
if (instance !== previousInstance && event.isPropagationStopped()) {
return;
}
// 执行事件,传入event对象,和currentTarget
executeDispatch(event, listener, currentTarget);
previousInstance = instance;
}
} else {
// 事件冒泡正序循环
for (let i = 0; i < dispatchListeners.length; i++) {
const {instance, currentTarget, listener} = dispatchListeners[i];
// 如果事件对象阻止了冒泡,则return掉循环过程
if (instance !== previousInstance && event.isPropagationStopped()) {
return;
}
executeDispatch(event, listener, currentTarget);
previousInstance = instance;
}
}
}
至此,咱们写在组件中的事件处理函数就被执行掉了,合成事件对象在这个过程中充当了一个公共角色,每个事件执行时,都会查看合成事件对象,有没有调用阻止冒泡的办法,另外会将以后挂载事件监听的元素作为currentTarget挂载到事件对象上,最终传入事件处理函数,咱们得以获取到这个事件对象。
总结
源码中事件零碎的代码量很大,我能活着进去次要是带着这几个问题去看的代码:绑定事件的过程是怎么样的、事件零碎和优先级的分割、真正的事件处理函数到底如何执行的。
总结一下事件机制的原理:因为fiber树的特点,一个组件如果含有事件的prop,那么将会在对应fiber节点的commit阶段绑定一个事件监听到root上,这个事件监听是持有优先级的,这将它和优先级机制分割了起来,能够把合成事件机制当作一个协调者,负责去协调合成事件对象、收集事件、触发真正的事件处理函数这三个过程。
欢送扫码关注公众号,发现更多技术文章
发表回复