点击进入React源码调试仓库。javascript
因为fiber机制的特色,生成一个fiber节点时,它对应的dom节点有可能还未挂载,onClick这样的事件处理函数做为fiber节点的prop,也就不能直接被绑定到真实的DOM节点上。
为此,React提供了一种“顶层注册,事件收集,统一触发”的事件机制。java
所谓“顶层注册”,实际上是在root元素上绑定一个统一的事件处理函数。“事件收集”指的是事件触发时(其实是root上的事件处理函数被执行),构造合成事件对象,按照冒泡或捕获的路径去组件中收集真正的事件处理函数。“统一触发”发生在收集过程以后,对所收集的事件逐一执行,并共享同一个合成事件对象。这里有一个重点是绑定到root上的事件监听并不是咱们写在组件中的事件处理函数,注意这个区别,下文会提到。react
以上是React事件机制的简述,这套机制规避了没法将事件直接绑定到DOM节点上的问题,而且可以很好地利用fiber树的层级关系来生成事件执行路径,进而模拟事件捕获和冒泡,另外还带来两个很是重要的特性:git
本文会对事件机制进行详细讲解,贯穿一个事件从注册到被执行的生命周期。github
与以前版本不一样,React17的事件是注册到root上而非document,这主要是为了渐进升级,避免多版本的React共存的场景中事件系统发生冲突。segmentfault
当咱们为一个元素绑定事件时,会这样写:数组
<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去绑定事件。
接下来的绑定过程能够归纳为以下几个关键点:app
onClickCapture
这样的React事件名称就表明是须要事件在捕获阶段触发,而onClick
表明事件须要在冒泡阶段触发。click
,并根据上一步来判断是否须要在捕获阶段触发,调用addEventListener
,将事件绑定到root元素上。通过这一系列过程,事件监听器listener最终被绑定到root元素上。dom
// 根据事件名称,建立不一样优先级的事件监听器。 let listener = createEventListenerWrapperWithPriority( targetContainer, domEventName, eventSystemFlags, listenerPriority, ); // 绑定事件 if (isCapturePhaseListener) { ... unsubscribeListener = addEventCaptureListener( targetContainer, domEventName, listener, ); } else { ... unsubscribeListener = addEventBubbleListener( targetContainer, domEventName, listener, ); }
上面提到的绑定事件的时候,绑定到root上的事件监听函数是listener,然而这个listener并非咱们直接在组件里写的事件处理函数。经过上面的代码可知,listener是createEventListenerWrapperWithPriority
的调用结果
为何要建立这么一个listener,而不是直接绑定写在组件里的事件处理函数呢?
其实createEventListenerWrapperWithPriority
这个函数名已经说出了答案:依据优先级建立一个事件监听包装器。有两个重点:优先级和事件监听包装器。这里的优先级是指事件优先级(关于事件优先级的详细介绍请移步React中的优先级 )。
事件优先级是根据事件的交互程度划分的,优先级和事件名的映射关系存在于一个Map结构中。createEventListenerWrapperWithPriority
会根据事件名或者传入的优先级返回不一样级别的事件监听包装器。
总的来讲,会有三种事件监听包装器:
这些包装器是真正绑定到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上的监听器切入,看看它作了什么。
它作的事情能够用一句话归纳:负责以不一样的优先级权重来触发真正的事件流程,并传递事件执行阶段标志(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上,这个事件监听是持有优先级的,这将它和优先级机制联系了起来,能够把合成事件机制看成一个协调者,负责去协调合成事件对象、收集事件、触发真正的事件处理函数这三个过程。
欢迎扫码关注公众号,发现更多技术文章