「React 深入」畅聊React 事件系统(v17、v18版本)
大家好,我是小杜杜,我们知道React
自身提供了一套虚拟的事件系统,通过前两篇的学习,我们已经知道React
事件系统与原生系统有哪些不同,React v16
事件系统到底是如何运作的,接下来看看v17
和v18
中,事件系统做了哪些更改,接下来就来详细的看看~
关于
React事件系统
将会分三个方面去讲解,分别是:React事件系统与原生事件系统、深入React v16事件系统、对比Reactv16~v18事件系统 三个模块,有感兴趣的可以关注下新的专栏:React 深入进阶,一起进阶学习~
在正式开始介绍前,请先看看以下问题:
Reactv17
、v18
在v16
的基础上做了哪些该改变?- 在
v17
中事件系统又是如何绑定,如何收集,如何触发的? - 对比与原生事件的捕获、冒泡,不同的版本,执行的顺序是什么?
- 为什么要取消事件池?
- ...
本文基于React v17.0.1源码、React v18.2.0
同时建议先看看前两篇,这样的话看这篇就容易一点,同时也能让更好的了解
先来看看整个事件系统的流程图:
React v17、v18 事件机制
React v17
可以说是一个特殊的版本,因为这个版本并无新特性的存在,而是侧重于升级简化React本身,可以认为这个版本是垫脚石的版本
虽然在这个版本中没有新的hooks
加入,也没有fiber
架构的改变,但对于事件系统而言,则是一个重大的改变,接下我们一起来看看
而在v18
中与v17
中大体相同,我们就以v17
为主,接下来一起看看
事件绑定
首先,在v17
版本中,将顶层事件调整到container
上,这样做的目的主要是为了:兼容性和跨平台,可以兼容多个版本,非常有利于微前端
(微前端会对应多个系统,存在对应多个react
版本的问题)
同时在React v16
中,React
执行大多数事件都会调用documnet.addEventListener()
, 而在v17
中,在底层中调用rootNode.addEventListener()
,如:
createRoot
当我们调用 document.getElementById('root')
时,会走createRoot
方法(源码位置:packages/react-dom/src/client/ReactDOMRoot.js
中)
export function createRoot(
container: Container,
options?: RootOptions,
): RootType {
...
return new ReactDOMRoot(container, options);
}
// ReactDOMRoot
function ReactDOMRoot(container: Container, options: void | RootOptions) {
this._internalRoot = createRootImpl(container, ConcurrentRoot, options);
}
//createRootImpl
function createRootImpl(
container: Container,
tag: RootTag,
options: void | RootOptions,
) {
...
if (enableEagerRootListeners) {
const rootContainerElement =
container.nodeType === COMMENT_NODE ? container.parentNode : container;
listenToAllSupportedEvents(rootContainerElement);
} else {
..
}
...
return root;
}
可以看出执行的顺序为:createRoot
=> ReactDOMRoot
=> createRootImpl
=> listenToAllSupportedEvents
v18中的createRoot
可以看到最终走向还是listenToAllSupportedEvents
函数
这里的markContainerAsRoot
方法是指向对应的fiber
节点
listenToAllSupportedEvents
listenToAllSupportedEvents:实际上就是整个事件绑定的开始
这里要特别注意rootContainerElement和allNativeEvents
- rootContainerElement:就是根节点root
- allNativeEvents:是所有原生事件的集合(
set
类型),在这里会遍历所有的原生事件(除了一些特殊的),!nonDelegatedEvents.has(domEventName)
则是判断这些原生事件哪些具有冒泡,有冒泡的则会绑定
可以简单的看下allNativeEvents这个集合:
常用的事件都在其中,如click
、input
、change
、scroll
等共有80个
v18中的listenToAllSupportedEvents
可以看出与v17
中大差不差,但这里的allNativeEvents
并不是v17
的allNativeEvents
里面的原生事件有对应的更改,变为了81个
之后的走向与v17
中的流向大体相同
listenToNativeEvent
listenToNativeEvent: 处理冒泡和捕获的函数,我们还是以最熟悉的click
来做解说
参数:
- domEventName:对应的事件名,如
click
- isCapturePhaseListener:是否捕获,
true
为捕获,false
为冒泡
内容:
- getEventListenerSet(target):它的作用是存储对应的事件名,防止重复添加监听器
也就是 cancel
=> cancel__capture
或 cancel__bubble
- getListenerSetKey:获取对应的事件名,也就是
click__bubble
- 如果未绑定规则,则会调用addTrappedEventListener,最终添加到listenerSet
addTrappedEventListener
通过addTrappedEventListener的作用是在对应的监听器
- createEventListenerWrapperWithPriority:这个函数是整个事件的重中之重,它是用来判断事件执行的优先级,返回对应的监听器
- 之后就是在原生事件中添加不同的监听器
createEventListenerWrapperWithPriority
简单来看下 createEventListenerWrapperWithPriority 这个函数
可以看出,它是根据eventPriority来判断优先级,不同的优先级返回不同的监听函数
可以简单的了解下,最终的目的都是进行事件收集、事件调用,这块比较复杂,建议了解就好~
- dispatchDiscreteEvent:离散事件监听器,优先级为 0
- dispatchUserBlockingUpdate:用户阻塞事件监听器,优先级为1
- dispatchEvent:连续事件或其他事件监听器,优先级为2
所以执行事件的优先级为:dispatchEvent => dispatchUserBlockingUpdate => dispatchDiscreteEvent
addEventBubbleListener
接下来就是对应的挂载了:
dispatchEvent
在上面的过程中,在无论走哪种监听器,都会调用dispatchEvent,它的优先级最高的原因是,它是同步,而其余两个监听器是异步的。
可以说dispatchEvent是合成事件的核心内容,最终走向dispatchEventsForPlugins,同时也是这函数出发了事件收集的功能
dispatchEventsForPlugins
源码位置:packages/react-dom/src/events/DOMPluginEventSystem.js
参数:
- domEventName:事件名称
- eventSystemFlags:事件处理的阶段,0:冒泡阶段,4:捕获阶段
- nativeEvent:原生事件的事件源(event)
- targetInst:
DOM
元素对应的节点,即fiber
节点 - targetContainer:根节点
内容:
- dispatchQueue:就是事件队列,收集到的事件都会存储到这
- extractEvents:收集事件
- processDispatchQueue 执行事件
extractEvents(收集事件)
extractEvents:它的作用就是用来生成不同的事件,由于每个事件都有稍许差异,所以导致有不同的插件,但这些插件的目的都一样,都是为了生成对应的事件
我们以最普遍的 SimpleEventPlugin
的extractEvents
为例(源码位置在:react-dom/src/events/plugins/SimpleEventPlugin.js
)
大概讲以下步骤
- 1.通过
topLevelEventsToReactNames.get(domEventName)
来获取对应的合成事件名称,如:onMouseOver
- 2.
SyntheticEventCtor()
是合成函数的构造函数 -
- 然后通过
switch
来匹配对应的合成事件的构造函数
- 然后通过
-
inCapturePhase
判断是否捕获阶段,下面的是冒泡阶段
- 5、通过
accumulateSinglePhaseListeners()
函数来获取当前阶段的所有事件 - 6、最后通过
new SyntheticEventCtor()
生成对应的事件源,插入队列中
accumulateSinglePhaseListeners
accumulateSinglePhaseListeners这个函数会获取存储在Fiber
上的Props
的对应事件,然后通过createDispatchListener
返回的对象加入到监听集合上,如果是不会冒泡的函数则会停止(比如:scroll
),反之会向上递归
可以看出
scroll
函数不再进行冒泡,如果是scroll
,accumulateTargetOnly
就会为true
,执行过一次就不会再执行了
processDispatchQueue(执行事件)
最后再来看看执行事件:processDispatchQueue
执行的时候还是先会判断是否是捕获阶段,之后就会遍历对应的合成事件,然后取出对应的事件源
和监听的函数
,最后会调用processDispatchQueueItemsInOrder函数
这个函数就会通过inCapturePhase
来模拟对应的冒泡与捕获
- event.isPropagationStopped():用来判断是否阻止冒泡(
e.stopPropagation
),如果阻止冒泡,就会在这一步退出,从而模拟事件流的过程 - executeDispatch():执行事件的函数
最后可以看看顺便看看onClick
的合成对象:
扩展:事件池的取消
关于事件池的取消可以看看官方的:
简单的说,就是没啥用,也没有对应的性能提高,所以就没了,但去除事件池后,自然也不存在持久化的问题,所以在setTimeout可以获得对应的事件源
顺便一提,
e.persistent()
还是可以继续使用,只不过没有什么效果
总结
总的来说,在v17
中没有了事件池的概念,从顶层到根节点的转变,也让其更加适应多版本,正如官方所说,这个版本就是垫脚石,为以后做准备的版本
此外,onScroll
、onFocus
、onBlur
并不会冒泡
对比 v16事件机制
执行顺序:原生事件 vs 合成事件
按照上面的讲解,React
的 v17
和 v18
并没有进行太大的变化,但在测试的时候遇到这样一个bug
,虽然跟事件机制没有啥关系,但顺便提一下吧~
测试代码:
import React, {useEffect} from "react";
export default function App(props) {
useEffect(() => {
const div = document.getElementById("div")
const button = document.getElementById("button")
div.addEventListener("click", () => console.log("原生冒泡:div元素"))
button.addEventListener("click", () => console.log("原生冒泡:button元素"))
div.addEventListener("click", () => console.log("原生捕获:div元素"), true)
button.addEventListener("click", () => console.log("原生捕获:button元素"), true)
document.addEventListener("click", () => console.log("document元素冒泡"))
document.addEventListener("click", () => console.log("document元素捕获"), true)
}, [])
return (
<div
id="div"
onClick={() => console.log('React冒泡:div元素')}
onClickCapture={() => console.log('React捕获:div元素')}
>
<button
id="button"
onClick={() => console.log('React冒泡:button元素')}
onClickCapture={() => console.log('React捕获:button元素')}
>
执行顺序 v16/v17/v18
</button>
</div>
我们分别看看这段代码在 v16
、v17
、v18
的环境上运行,是什么效果
React v16
:
documnet
捕获 => 原生捕获 => 原生冒泡 => 合成事件捕获 => 合成事件冒泡 =>documnet
冒泡
React v17
:
documnet
捕获 => 合成事件捕获 => 原生捕获 => 原生冒泡 => 合成事件冒泡 =>documnet
冒泡
React v18
:
可以看到v18
的顺序与v17
一样
对比下版本的走向
扩展 v18严格模式下的useEffect
可能有的小伙伴会好奇,在v18
中会执行两遍原生的事件,这个实际上并不是事件机制重复执行,而是因为<React.StrictMode/>
这个标签,也就是严格模式
在v18
中的严格模式会让useEffect
执行两遍,导致监听了两次,所以看到打印的时候会有两遍
所谓的严格模式是用于突出显示应用程序中潜在问题的工具,它不会呈现任何可见的UI。
注意,严格模式并不会影响生产环境,生成环境下useEffect
还是会执行一遍,这应该算个bug
吧~
所以只要去掉严格模式,打印的结果就与v17
一样了
不同版本的对比
v16 | v17、v18 | |
---|---|---|
顶层 | documen | root |
事件池 | 存在,导致setTimeout 无法获取 | 不存在 |
是否是真的捕获事件 | 不是,实际上是冒泡 | 是 |
End
参考
相关文章
- 「React 深入」React事件系统与原生事件系统究竟有何区别?
- 「React深入」一文吃透React v16事件系统
- 花一个小时,迅速掌握Jest的全部知识点~
- 「React 深入」一文玩转React Hooks的单元测试
结语
经过了三周的时间,终于把React v16 ~ v18
的事件系统更完,看源码过程确实比较痛苦,好歹是坚持下来了
有关事件系统的文章就更完了,可能以现在的水平无法解释的更好,理解的更深,可能过段时间再看源码,会有不一样的感觉,如有不对的地方还请告知~
读源码并不是一蹴而就,要经过长时间的理解,第一遍看可能并不会理解为何要这样去做,这样做是否合理,随着时间的累加,回过头发现并没有那么难,实践永远是最好的老师,建议大家亲自弄弄,看看React
中究竟是如何执行的~
感兴趣的可以关注下这个专栏,这个专栏会以进阶为目的,详细讲解React
相关的原理、源码、实战,有感兴趣的可以关注下,一起学习,一起进步~
转载自:https://juejin.cn/post/7163079446683992100