「React深入」一文吃透React v16事件系统
大家好,我是小杜杜,我们知道React
自身提供了一套虚拟的事件系统,通过上篇的学习,我们已经知道React
事件系统与原生系统有哪些不同,接下来让我们继续探索React v16
究竟是如何进行事件绑定,如何触发事件的呢。
关于
React事件系统
将会分三个方面去讲解,分别是:React事件系统与原生事件系统、深入React v16事件系统、对比Reactv16~v18事件系统 三个模块,有感兴趣的可以关注下新的专栏:React 深入进阶,一起进阶学习~
在正式开始介绍前,请先看看以下问题:
React
是如何绑定事件的,又是如何触发事件的?- 为什么绑定
onChange
事件后,document
会多出很多监听器? onChange
是对应的原生事件的change
吗?- 什么是事件池?它又是如何进行工作的?
- 什么是事件插件机制,合成事件和原生事件都是一一对应的吗?
- 如何进行批量更新的?
- 为什么在不使用箭头函数的情况下,要通过
bind
绑定this
? React
中的捕获事件,走的真是捕获阶段吗?- .....
如果你对以上问题有疑问,那么相信看完本章后,你的疑问会全部解决。
一起来看看今天的知识图谱:
注: 本文基于 react v16.13.1 源码
前置知识
在正式开始前,我们先来讲讲事件池的概念,由于事件池的概念比较多,为防止后续看文章的体验,单独拿出来讲,你也可以先阅读,等看到事件池这部分的内容,再回过来看,感觉会不一样哦~
事件池
什么是事件池?
在上篇讲过,React
为了避免垃圾回收,因而引入了事件池的概念,从而防止事件会被频繁的创建和回收
从本质上来讲,事件池
是React
提供的一种优化方式,将所有的合成事件
都放到事件池
内统一管理,同时不同类型的合成事件对应不同的事件池
事件池是如何工作的?
在点击事件中,实际上会调用SimpleEventPlugin
的extractEvents
函数(源码位置packages\react-dom\src\events\SimpleEventPlugin.js
),来看看event
,如:
const event = EventConstructor.getPooled(
dispatchConfig,
targetInst,
nativeEvent,
nativeEventTarget,
);
而 EventConstructor.getPooled
在packages/legacy-events/SyntheticEvent.js
下的getPooledEvent
,一起来看看这个函数:
function getPooledEvent(dispatchConfig, targetInst, nativeEvent, nativeInst) {
const EventConstructor = this;
if (EventConstructor.eventPool.length) {
const instance = EventConstructor.eventPool.pop();
EventConstructor.call(
instance,
dispatchConfig,
targetInst,
nativeEvent,
nativeInst,
);
return instance;
}
return new EventConstructor(
dispatchConfig,
targetInst,
nativeEvent,
nativeInst,
);
}
也就是说当EventConstructor.eventPool
存在的时候会复用事件对象,否则会创建新的对象
解释下对应的参数:
- dispatchConfig:这个参数将事件对应的
react
元素实例、原生事件、原生事件对应的DOM
封装成了一个合成事件。比如说冒泡事件中的onClick
和捕获事件中的onClickCapture
- targetInst:组件的实例,它是通过
e.target
(事件源)得到对应的ReactDomComponent
- nativeEvent:对应原生事件对象
- nativeInst:原生的事件源
事件池是怎样填充的?
const EVENT_POOL_SIZE = 10;
function releasePooledEvent(event) {
const EventConstructor = this;
invariant(
event instanceof EventConstructor,
'Trying to release an event instance into a pool of a different type.',
);
event.destructor();
if (EventConstructor.eventPool.length < EVENT_POOL_SIZE) {
EventConstructor.eventPool.push(event);
}
}
在填充对象的时候先会执行event.destructor()
方法,这个方法会重置event
的部分属性。然后如果事件池没有满,则会填充进去
合成对象如何持久化?
我们先看看以下代码:
<input onChange={(e) => {
console.log(e.target)
setTimeout(() => {
console.log(e.target, 'setTimeout')
})
}} />
按照常理而言,两处的e.target
应该一样,然而实际效果为:
这是因为每次在派发事件中,React
都会从事件池中判断,是否能够复用,当派发完成时,就会将函数的属性置成null
,也就是会清空对应的属性,所以setTimeout
会打印出null
的原因
那么,我们如何在setTimeout
拿到e
呢?如何保证对象的持久化呢?
可以使用e.persistent(),效果:
这是因为执行e.persistent()
函数,React
不会执行EventConstructor.release
方法。
换言之,此时的onChange
并没走事件池,也不会进行销毁,因此会保留e
的值
React是如何绑定事件的?
我们知道React
中的所有都模拟的,甚至事件源也是虚拟的,那么React
是如何将模拟的事件进行绑定的呢?
首先我们需要知道事件插件这个概念,接下来一起看看
事件插件机制
在React
中,所有的事件都是通过插件来进行统一处理,但并非是同一个插件,因为每个事件的处理逻辑、事件源都不同,所以会有多个事件插件。
如:onClick
对应SimpleEventPlugin
、onChange
对应ChangeEventPlugin
插件的结构
PluginModule
PluginModule
是每个插件的结构,如:
export type EventTypes = {[key: string]: DispatchConfig, ...};
export type PluginModule<NativeEvent> = {
eventTypes: EventTypes,
extractEvents: (
topLevelType: TopLevelType,
targetInst: null | Fiber,
nativeTarget: NativeEvent,
nativeEventTarget: null | EventTarget,
eventSystemFlags: EventSystemFlags,
container?: Document | Element | Node,
) => ?ReactSyntheticEvent,
tapMoveThreshold?: number,
};
其中 eventTypes
对应的是声明插件的事件类型,extractEvents
是对事件进行处理的参数,最后会返回一个合成事件对象
eventTypes
接下来具体来看看 eventTypes
:
export type DispatchConfig = {
dependencies: Array<TopLevelType>,
phasedRegistrationNames?: {
bubbled: string,
captured: string,
|},
registrationName?: string,
eventPriority: EventPriority,
|};
- dependencies:依赖的原生事件,也就是与之相关联的原生事件,但这里要注意,大多数事件一般只对应一个,复杂的事件会对应多个(如:
onChange
) - phasedRegistrationNames:对应的事件名称,
React
会根据这个参数查找对应的事件类型。其中bubbled
对应冒泡阶段,captured
对应捕获阶段 - registrationName:
props
事件注册名称,并不是所有的事件都具有冒泡事件的(比如:onMouseEnter
),如果不支持冒泡的话,只会有registrationName
,而不会有phasedRegistrationNames
- eventPriority:用来处理事件的优先级,本文暂不介绍(之后可能从
fiber
中进行介绍)
以click
为例,实际就为:
`click`:{
dependencies: ['click'],
phasedRegistrationNames:{
bubbled: 'onClick',
captured:'onClickCapture'
},
}
插件的实例
为了更好的理解,我们可以具体看看这些插件的实例,这里主要介绍下比较典型的三个插件
SimpleEventPlugin
SimpleEventPlugin:这个插件比较通用,大多数方法都是通过此插件处理,如:click
、input
、focus
等,与原生事件一一对应,所以这类事件比较好处理
EnterLeaveEventPlugin
EnterLeaveEventPlugin:从上图可见,onMouseEnter
是依靠mouseout
, mouseover
事件,这样可以在document
上面进行委托监听,还可以有效的避免一些奇怪、不实用的行为
ChangeEventPlugin
ChangeEventPlugin: 在React
中,onChange
比较特殊,它是React
的一个自定义的事件,它依赖8种原生事件来模拟onChange
事件
事件绑定
回归正题,看看在React
中到底是如何进行绑定的
通过「React 深入」React事件系统与原生事件系统究竟有何区别?
我们知道事件最终保存在fiber
中的memoizedProps
和 pendingProps
之后会调用legacyListenToEvent
函数
legacyListenToEvent
legacyListenToEvent:用来注册事件监听器,要注意,事件必须是合成事件,如onClick
- registrationName:合成事件名
- dependencies:合成事件所依赖的事件组
可以看出,会根据合成事件(onClick
)去匹配对应的原生事件(click
)
之后就会走向legacyListenToTopLevelEvent函数,而这个函数的目的判断事件是否进行冒泡处理
为什么绑定onChange事件后会多出很多监听器?
用同样的方法,我们来看看onChange
绑定后是什么样的
可以发现,onChange
的依赖组有: ['blur', 'change', 'click', 'focus', 'input', 'keydown', 'keyup', 'selectionchange']
然后会依次对这个数组进行遍历,进行绑定,所以当我们绑定onChange
事件后会在document
上多出那么多事件的原因
legacyListenToTopLevelEvent
对于大多数事件,都会走冒泡阶段
,但事无绝对,并不是所有的事件都会走,一些特殊的事件,是按照捕获来处理的,比如:onScroll
trapCapturedEvent(TOP_SCROLL, mountAt)
就是处理事件捕获的
绑定dispatchEvent
接下来也就是最重要的一步,就是如何绑定到document
的,接下来一起看看
接下来会走trapEventForPluginEventSystem
函数,如:
然后会判断对应的类型,通过对应的事件对listerner
进行赋值,(如click
对应的为dispatchDiscreteEvent
)
然后判断是否为捕获,分别进行绑定
实际上,所有的事件都绑定在
dispatchEvent
上
addEventBubbleListener
- addEventBubbleListener:处理冒泡
- addEventCaptureListener:处理捕获
- element: 对应
document
- eventType:对应的事件,此处为
click
- listener:对应监听的函数
至此事件绑定的内容就完了,接下来我们一起看看事件究竟是如何触发的。
React是如何触发事件的?
触发dispatchEvent
当我们的事件注册完成后,会有一个统一管理的函数,也就是dispatchEvent
。
所以当我们触发onClick
后,首先走的也是dispatchEvent
,如:
前三个参数不必多说,来看看第四个参数nativeEvent:
这个参数实际上是真正的事件源对象:event
attemptToDispatchEvent
然后会走到attemptToDispatchEvent这个函数,它会尝试去调度事件,如:
- 首先会根据事件源找到真正的
DOM
元素,也就是nativeEventTarget
- 其次会根据这个元素找到与之对应的
fiber
(也就是button
的fiber
),给targetInst
- 最后走入
dispatchEventForLegacyPluginEventSystem
,而这个函数则是进入lagacy模式的事件处理函数系统
getClosestInstanceFromNode
getClosestInstanceFromNode:这个函数可以找到对应的fiber
,那么这个函数是如何找到的呢?
实际上,当我们的元素进行初始化的时候,每个元素都会对应一个随机的randomKey,也就是
const internalInstanceKey = '__reactInternalInstance$' + randomKey;
之后再通过 getClosestInstanceFromNode
找到这个key
export function getClosestInstanceFromNode(targetNode) {
let targetInst = targetNode[internalInstanceKey];
if (targetInst) {
return targetInst;
}
...
}
legacy 事件处理系统
接下来,我们一起来看看dispatchEventForLegacyPluginEventSystem
这个函数
- 首先,会根据
getTopLevelCallbackBookKeeping
函数找到事件池中对应的属性,赋予给事件,可以先看看bookKeeping
:
- 然后会通过batchedEventUpdates来处理批量更新
- 最终通过releaseTopLevelCallbackBookKeeping来释放事件池
我们提出了一个概念叫事件池,那么事件池又是是什么,可以看看前置的知识~
batchedEventUpdates 批量更新
batchedEventUpdates函数:
实际上是通过isBatchingEventUpdates来控制是否进行批量更新
而实际处理批量更新的函数是fn
也就是handleTopLevel
这个函数
调用的函数最终是在handleTopLevel(bookKeeping)
,如果我们在函数中触发了setState
,那么isBatchingEventUpdates
就为true
,所以就具备了批量更新的功能
这么说好像不太好理解,我们简单举个例子🌰:
export default class App extends Component {
state = {
count: 0
}
render() {
return (
<button
onClick={() => {
this.setState({count: this.state.count + 1 })
console.log(this.state.count) //0
setTimeout(()=>{
this.setState({count: this.state.count + 1 })
console.log(this.state.count) //2
})
}}
>
点击{this.state.count}
</button>
)
}
}
我们说setState
即是同步也是异步,大多数情况下为异步,少部分下为同步
而同步获取的其中一个就是依靠定时器,利用的原理是事件循环
第一个setState执行符合批量更新的条件,所以打印的值自然不是最新值,也就是异步
但在setTimeout下,eventLoop放在了下一次的事件循环中,此时的isBatchingEventUpdates已经为false了,所以此时会拿到最新变化的值
handleTopLevel
handleTopLevel:简单的说下这个函数,它主要是找到对应的插件,比如onClick
走的就是SimpleEventPlugin
,简单的画下走的流程:
handleTopLevel => runExtractedPluginEventsInBatch => extractPluginEvents => runEventsInBatch
我们主要看下 extractPluginEvents这个函数
当我们找到对应的插件SimpleEventPlugin
后,会调用他的extractEvents函数,将对应的事件都放在了extractEvents
下,这样的好处主要是处理兼容性,不需要考虑浏览器,而是由React
统一处理
extractEvents
以点击为例,所以看看SimpleEventPlugin
的extractEvents
简单的说就是经历了一系列的匹配,最终将调用EventConstructor.getPooled拿到对应的事件源(合成对象),之后将事件源传递给了accumulateTwoPhaseDispatches
然后进行遍历,也就是traverseTwoPhase,遍历的方法为:
以 _targetInst 为起始点开始遍历
- 捕获阶段:由顶层开始向下传播
- 冒泡阶段:有 _targetInst 开始,向上传播
再来看看看accumulateDirectionalDispatches:
这个函数的作用是查询当前节点,是否存在对应的事件处理器
栗子🌰
我们来做个简单的测试:
return (
<div
onClick={() => console.log('3')}
onClickCapture={() => console.log('4')}
>
<button
onClick={() => console.log('1')}
onClickCapture={() => console.log('2')}
>
点击
</button>
</div>
)
结果:
- 首先,会找到
button
对应的fiber
,捕获在冒泡之前,所以此时的结构为console.log('2')
=>console.log('1')
- 然后遇到了
div
对应的fiber
,同理,此时的结构为console.log('4')
=>console.log('2')
=>console.log('1')
=>console.log('3')
- 也可以这么理解,每次点击时都是由内向外,
button
=>div
,捕获事件永远在执行队列的最前面,冒泡永远是最后面
runEventsInBatch
最终会进入runEventsInBatch函数,这个函数也是最终进行批量执行的地方,同时,如果发现有阻止冒泡,则会跳出循环,重置事件源
扩展:为什么要使用this
归其原因是因为dispatchEvent中调用的invokeGuardedCallback,直接使用的func
,并没有指定调用的组件,所以此时不绑定this
的话,直接获取的为undefined
而箭头函数本身并不会创建自己的this
,而是会继承上层的this
,所以获取的自然是组件的本身了
End
参考
总结
事件绑定总结:
- 在
React
中,首先将元素转化成fiber
,在fiber
中的props
如果是合成事件(如:onClick
),就会按照独立的处理逻辑,单独处理 - 然后判断合成事件的事件类型,寻找对应的原生事件类型,需要注意的是,这里的原生类型是个数组,并不完全是一对一的关系,如
onClick
对应click
,而onMouseEnter
对应[mouseout
,mouseover
],而onChange
更是融合了8个
原生事件 - 之后会判断事件类型,大多数事件(
onClick
)都是走的冒泡逻辑,少部分事件(如:onScroll
)会走捕获事件 - 最后会调用
trapEventForPluginEventSystem
函数,绑定在document
上,实现统一处理函数(dispatchEvent
函数)
另外,这里可能存在一个误区,并不是捕获事件就会走捕获的阶段(如:
onClickCapture
),实际上,onClickCapture
与onClick
一样,都是走的冒泡阶段,而捕获阶段毕竟是少数,如:onScroll
、onBlur
、onFocus
事件触发总结:
- 所有的事件首先通过
dispatchEvent
函数处理,然后进行批量更新 - 然后根据事件源找到与之匹配的
DOM元素fiber
,进入插件中的extractEvents,然后遍历,得到最终的一个队列,这个队列就是React
用来模拟的事件过程 - 最终走向runEventsInBatch,进行批量执行事件队列,完成整个触发流程。如果发现有阻止冒泡的情况,则会跳出循环,重置事件源,再放回到事件池中,完成流程
相关文章
- 「React 深入」React事件系统与原生事件系统究竟有何区别?
- 「React 深入」畅聊React 事件系统(v17、v18版本)
- 花一个小时,迅速掌握Jest的全部知识点~
- 「React 深入」一文玩转React Hooks的单元测试
结语
总的来说,React
中的事件主要分为绑定和触发两个模块,在 v16
中还是有很多的概念,建议大家多多看看源码,亲自走一走,这样印象会深一点,同时也存在着一些误区,可能并不是你一开始理解的那样,如有不对的地方还请指出~
那么关于React
事件系统还剩最后一章,v17
和v18
又对事件系统做了哪些更改?相比于v16
变了哪些部分?流程上做了哪些更改?
感兴趣的可以关注下这个专栏,这个专栏会以进阶为目的,详细讲解React
相关的原理、源码、实战,有感兴趣的可以关注下,一起学习,一起进步~
转载自:https://juejin.cn/post/7160857324691652616