起因其实是我用了 React Swiper 库。该库虽然挂着 React 的名头,但其实完全是由 Swiper.js 包装而成的,因而内里的事件机制仍旧是基于原生的。这导致我发现,我无法正常地将事件从 React 组件中阻止冒泡到 Swiper 的部分。
为了解决这个问题,很快,我从文档中查阅到,React 中的事件是由自己合成的“合成事件”,事件上的 e.nativeEvent
才是真正的原生事件。于是,我就尝试在事件处理函数中执行 e.nativeEvent.stopPropagation()
,却仍旧无法阻止冒泡。
要搞清楚出现这个问题的原因,就需要了解 React 的事件机制。
React 的事件机制是基于事件委托的。所有 React 的事件监听实际都是被绑定在 document 上的。
例子
本文其实就是对 React 事件机制部分的代码按照执行顺序解读。使用的例子代码如下:
1 | // App.js |
1 | // Child.js |
事件处理函数的绑定
从 setInitialDOMProperties
开始
1 | function setInitialDOMProperties( |
该方法处理实例的 props。registrationNameModules
的内容为:
可以看出,如果 propKey
为 registrationNameModules
中的 key,则该 propKey
为事件相关,进入到对事件的处理步骤中。
就本文的例子而言,此时,我们的 propKey
应该为 onClick
。
ensureListeningTo
1 | function ensureListeningTo(rootContainerElement, registrationName) { |
传入该方法的 rootContainerElement
即为 #root
,即 React app 挂载的节点。registrationName
为 onClick
。
该方法首先判断 rootContainerElement
是否为 document 或 documentFragmemt,是则直接使用,不是则找到该节点对应的 document。
接着,调用 listenTo
,传入参数 registrationName
(onClick
),与 doc
(document)。
listenTo
1 | export function listenTo( |
该方法用于指定相应事件的事件处理方法应在冒泡阶段还是捕获阶段触发。注意,此时的提到的“事件处理方法”是指绑定在 document 上用于分发事件的方法,而不是你在 React 组件中写的那个对应的事件处理方法。
scroll
、focus
、blur
、cancel
、close
事件在捕获阶段处理,也就是执行 trapCapturedEvent
方法。invalid
、submit
、reset
以及媒体相关的事件不作处理。除此之外,其他所有时间都是在冒泡阶段处理的,也就是执行上文代码中的 trapBubbledEvent
方法。
同时可以注意一下,此处使用了 isListening
来记录对应处理方法是否已经被绑定在了 document 上。因为 React 是将事件监听绑定在 document 上的,也就是事件委托模式,所以同样的一种事件只需要在 document 上绑定一次即可。
trapBubbledEvent
1 | export function trapBubbledEvent( |
trapBubbledEvent
其实就是在 document 上绑定了一个于冒泡阶段触发的方法 dispatch
。根据该事件是否是交互事件(即用户做交互的相关事件),dispatch
方法可能是 dispatchInteractiveEvent
或 dispatchEvent
。dispatchInteractiveEvent
实际只是先行做了一些处理,比如事件的优先级等,最终还是会调用 dispatchEvent
方法。
这样,将 dispatchEvent
方法绑定在了 document 上,并将 topLevelType
,即字符串 click
作为参数传入方法,我们的事件绑定阶段就结束了。
接下来,如果你点击了“请点击”字样,就会触发 document 上绑定的 dispatchEvent
方法。
事件处理函数的执行
事件分发
dispatchEvent
点击“请点击”之后,立即触发 document 上绑定的 dispatchEvent
。
1 | export function dispatchEvent( |
传入的 topLevelType
即字符串 click
,nativeEvent
是原生事件。
通过 getEventTarget
从原生事件中取到触发事件的 DOM 节点(nativeEvent.target
),然后通过 getClosestInstanceFromNode
取得该 DOM 节点对应的 fiberNode。DOM 节点对应的 fiberNode 是被提前记录在节点的属性中的。
如果该 DOM 节点没有对应 fiberNode,或该 fiberNode 没有被安装在页面或文档片段中,则不做任何处理。判断 fiberNode 有没有被正确安装的方式是,不断寻找该 fiberNode 的 return
关系的 fiberNode,直到找到 HostRoot
。如果找不到,则说明该 fiberNode 没有被正确安装。
如果该 fiberNode 状态正常,则将 topLevelType
(对应浏览器的事件名)、nativeEvent
(浏览器原生事件)、targetInst
(触发事件的 DOM 节点对应的 fiberNode)整合起来,作为一个 bookKeeping
,并附上一个 ancestors
属性,该属性值为一个空数组。关于这个属性,我们下面细说。
batchedUpdates
整合好一个 bookKeeping
之后,我们将该 bookKeeping
与方法 handleTopLevel
一起传入方法 batchedUpdates
,执行 batchedUpdates
。
1 | export function batchedUpdates(fn, bookkeeping) { |
可以看到,最终调用了 fn
,fn
则为 handleTopLevel
。
handleTopLevel
1 | function handleTopLevel(bookKeeping) { |
关于这个方法,我有一个奇妙的个人想法。
首先,看上半部分,也就是给 ancestor
填值的部分。你会发现,这部分代码首先会将触发事件的 DOM 元素对应的 fiberNode 本身放入 ancestor
列表中,然后取 HostRoot
,也就是页面上的 #root
,对 #root
执行 getClosestInstanceFromNode
,而这个方法对 #root
执行必然会返回 null
。
1 | /** |
这样,就会导致 handleTopLevel
方法中给 ancestor
列表里塞的值,永远都只有一个,就是触发事件元素对应的那个 fiberNode。对,就一个,就是这个。所以注释里写的什么“保存 DOM 结构”根本不对,你列表里就保存了这么一个 fiberNode,保存啥结构了你就保存……
考虑到这段代码不同行提交记录的时间差距相当大,我估计这是一个没改干净的历史遗留问题。跑着正常,但意思看着是不对的。
所以后面的循环也变得毫无意义,其实就是对触发事件的那一个 fiberNode 做的操作。做的操作是什么呢?也就是调用 runExtractedEventsInBatch
。
runExtractedEventsInBatch
1 | export function runExtractedEventsInBatch( |
这段代码分为两部分重要操作。第一部分是利用 extractEvents
方法生成并提取合成事件,需要传入事件名、触发事件 DOM 元素对应的 fiberNode,原生事件,以及触发事件的 DOM 元素。
让我们先看看这个方法。
提取合成事件
获取当前类型事件应执行的 extractEvents
(提取事件方法)
extractEvents
1 | function extractEvents( |
plugins
总共有五种,分别是 SimpleEventPlugin
、 EnterLeaveEventPlugin
、 ChangeEventPlugin
、 SelectEventPlugin
、 BeforeInputEventPlugin
五种,判断事件属于哪一种 Plugin 是通过每种 Plugin 对应的 extractEvents
判断的。如果事件属于对应 Plugin,则该 Plugin 对应的 extractEvents
方法会生成并返回对应的合成事件,否则返回 undefined
。
以 click 事件为例,循环 Plugin 列表,首先执行 SimpleEventPlugin
对象上的 extractEvents
方法。
SimpleEventPlugin.extractEvents
1 | const SimpleEventPlugin: PluginModule<MouseEvent> & { |
可以看到,在事件为 click 的情况下,EventConstructor
被赋值为 SyntheticMouseEvent
。赋值 EventConstructor
之后,调用 EventConstructor.getPooled
。那么,首先应该来看看, SyntheticMouseEvent
是什么呢?
创建或从池中取出合成事件实例
SyntheticMouseEvent
1 | const SyntheticMouseEvent = SyntheticUIEvent.extend({ |
1 | const SyntheticUIEvent = SyntheticEvent.extend({ |
从字面可以看出,SyntheticMouseEvent
继承于 SyntheticUIEvent
,SyntheticUIEvent
继承于 SyntheticEvent
。那么,这个继承关系是如何实现的呢?
1 | SyntheticEvent.extend = function(Interface) { |
从代码上看,继承方式正是最经典的寄生组合继承,详情可见《JavaScript面向对象的程序设计-继承》。
这样,我们就知道了,SyntheticMouseEvent
实际上是一个继承于 SyntheticUIEvent
与 SyntheticEvent
的构造函数。
在通过条件判断,取得事件对应的构造函数之后,我们继续执行 extractEvents
接下来的代码,即 EventConstructor.getPooled
。
EventConstructor.getPooled
1 | function getPooledEvent(dispatchConfig, targetInst, nativeEvent, nativeInst) { |
可以看到,React 的事件机制做了一个性能上的优化,即“事件池”。如果事件池中有事件,则直接取出使用。如果没有,则新建一个事件。剧透一下,这个新建的事件在使用完毕后,就会被扔进事件池。
在本例中,此时,我们的事件池中还没有事件,因此执行 new EventConstructor(arguments...)
。
SyntheticEvent
执行 new EventConstructor(arguments...)
,该 EventConstructor
继承于 SyntheticEvent
,因而执行该方法。
1 | function SyntheticEvent( |
可以看到,这个方法基本就是将预设好的“接口对象”,即 Interface
对象,中的内容一一遍历,并将原生事件的对应属性/预设接口的执行结果赋值给 React 事件实例的过程。
执行完这里,我们就执行完了 new EventConstructor(argument...)
,也就是执行完了更上一层函数的 EventConstructor.getPooled(arguments...)
,得到了一个 React 合成事件实例。
1 | // extractEvents 方法的代码片段 |
下一步,我们应开始执行 accumulateTwoPhaseDispatches(event)
,即在创建的合成事件上保存对应事件监听函数的过程。
在合成事件实例上保存事件监听方法
accumulateTwoPhaseDispatches
积累两个阶段的 dispatch。
accumulateTwoPhaseDispatches(event)
-> forEachAccumulated(events, accumulateTwoPhaseDispatchesSingle)
(判断传入的 event 是否为数组,如果是,逐项执行 accumulateTwoPhaseDispatchesSingle(event)
,如果不是,则直接执行 accumulateTwoPhaseDispatchesSingle(event)
) -> accumulateTwoPhaseDispatchesSingle(event)
-> traverseTwoPhase(event._targetInst, accumulateDirectionalDispatches, event)
。
traverseTwoPhase
遍历两个阶段。
1 | /** |
getParent
代码如下。
1 | function getParent(inst) { |
return
关系详见对 Fiber 的介绍。可以看到,该方法会向上追溯最近的 HostComponent,即能够用于绑定事件的 fiberNode,不包括不能绑定事件的组件节点、文本节点等,然后返回。
这样,在 traverseTwoPhase
方法中,我们就得到了一条用于传递事件的节点链条。
接下来,我们按模拟捕获和冒泡的顺序,分别从根节点开始循环到触发事件的节点/从触发事件的节点循环到根节点,对每个节点执行 accumulateDirectionalDispatches
方法。
accumulateDirectionalDispatches
1 | /** |
这样,在循环执行了这个方法之后,就会按先捕获再冒泡的顺序,循环所有链条上的节点,将事件监听方法按顺序储存在事件实例上,并与绑定事件监听方法的节点一一对应。
到这里,前面的 accumulateTwoPhaseDispatches
也方法也完全执行完毕,返回了一个 event
,即 React 的合成事件实例。
1 | // extractEvents 方法的代码片段 |
至此,extractEvents
方法全部执行完毕,接着执行下一步,runEventsInBatch
方法。即真正意义上调用事件监听方法。
1 | export function runExtractedEventsInBatch( |
执行事件回调
runEventsInBatch
1 | function runEventsInBatch(events) { |
可以看到,该方法其实是可以同时传入多个事件,形成一个事件队列的。然后对事件队列中的事件分别执行 executeDispatchesAndReleaseTopLevel(event)
,executeDispatchesAndReleaseTopLevel(event)
中只有一行代码,即执行 executeDispatchesAndRelease(event)
。
executeDispatchesAndRelease
1 | var executeDispatchesAndRelease = function (event) { |
调用 executeDispatchesInOrder
。在调用过后,如果事件没有被标记为持久化(if (!event.isPersistent()) {}
),则释放事件。
executeDispatchesInOrder
1 | /** |
executeDispatch
1 | /** |
invokeGuardedCallbackAndCatchFirstError
执行 invokeGuardedCallback
并 catch Error, invokeGuardedCallback
调用 invokeGuardedCallbackImpl
。
invokeGuardedCallback
1 | let invokeGuardedCallbackImpl = function<A, B, C, D, E, F, Context>( |
func
即我们的 listener
。可以看到,至此,事件监听函数终于被成功执行了。
将使用过的事件实例释放到资源池中
1 | var executeDispatchesAndRelease = function (event) { |
event.constructor.release
,即为 releasePooledEvent
方法。
releasePooledEvent
1 | function releasePooledEvent(event) { |
event.distructor
1 | destructor: function () { |
在这个方法中,清空当前合成事件实例上的所有属性。然后回到上一步。
1 | function releasePooledEvent(event) { |
如果池子中的事件数量小于一个预设值,则池子还没放满,将清空后的事件放入池中。下次提取合成事件时,就可以直接从池中取,而不需要再次创建。
releaseTopLevelCallbackBookKeeping
最终,我们连我们存下的 bookKeeping
也需要释放。
1 | function releaseTopLevelCallbackBookKeeping(instance) { |
操作同样,都是先清空实例,然后看池子中是否还有位置,如果有位置,则放入池中。
至此,React 事件机制就已经介绍完成了。
总结
在 document 上绑定事件处理函数
如果 props 出现 onClick
,则在 document 上绑定针对 click 事件的处理方法,只绑定一次。其他事件同理。
这样,原生事件冒泡(某些事件是捕获)到 document 时,则触发 React 绑定的处理方法。
执行 document 上的事件处理函数
触发事件,则触发了 document 上绑定的处理方法。
该方法从原生事件中取得原生事件本身、触发事件的元素、触发事件的元素对应的 fiberNode、事件类型,然后分别进行两步重要操作:1. 提取(创建或从事件池中取出)合成事件;2. 执行用户绑定的事件处理函数。
提取事件
根据事件类型不同,执行不同的提取事件方法。以常见的点击事件为例:
- 先新建(或从事件池中取出)一个对象,然后将原生事件的必要属性按照 React 希望的形式赋值到该对象上;
- 从触发事件的 fiberNode 开始一直向上遍历到 HostRoot(即 #root 对应的 fiberNode),取得所有可以绑定事件的 fiberNode(即 HostComponent 类型)形成一个链条。
- 模拟捕获阶段和冒泡阶段,分别从 HostRoot 遍历到触发事件的 fiberNode,取出上面期望绑定在捕获阶段的事件处理函数(即 props onClickCapture),储存在合成事件上,然后模拟冒泡阶段,反着遍历,将绑定在冒泡阶段的事件处理函数(即 props onClick)记录在合成事件上,形成数组。其对应的 fiberNode 也同步记录在另一个数组中,与事件处理函数的数组一一对应。
执行事件处理函数(用户绑定的)
遍历事件中储存的事件处理函数列表,循环依次执行。执行完整个列表后,清空该列表以及其对应的 fiberNode 列表、
将事件实例释放到池中
- 初始化事件上的所有属性;
- 如果事件池中的事件数目小于预设值,则将事件实例推入池中。
动图
找到了一位作者为这个过程制作的React 事件机制动图,应该能便于大家理解。