on
事件机制三顾
前端攻城狮在“攻城”之时往往有一利器那便是事件
。
此利器非常人所能习得之物。欲精其身,必先精其内,明其相辅相成之道。
咳~这么说下去我怕是马上就走到文化沙漠了哈哈哈。今天呢,就来研究研究前端乃至React的事件相关机制。 这一期有太多的理论知识需要做铺垫,且有的内容需要涉及到fiber之类的高深知识,我自己也是一知半解,所以如若有误,一定要告诉我,还要说明所有涉及到的源码都是粗略地读,没有仔细看里面的具体实现。
源码阅读版本16.13.x
一顾:DOM事件流
事件机制的历史要追溯到1988年了。
W3C协会早在1988年就开始了DOM标准的制定,W3C DOM标准可以分为 DOM1、DOM2、DOM3 三个版本。
从 DOM2 开始,DOM 的事件传播分三个阶段进行:事件捕获阶段、处于目标阶段和事件冒泡阶段。
下面给出这三个阶段的大概定义
-
事件捕获阶段:事件对象通过目标节点的祖先 Window 传播到目标的父节点。
-
处于目标阶段:事件对象到达事件目标节点。如果
阻止事件冒泡
,那么该事件对象将在此阶段完成后停止传播
。 -
事件冒泡阶段:事件对象以相反的顺序从目标节点的父项开始传播,从目标节点的父项开始到 Window 结束。
不得不说的EventTarget.addEventListener()
在写addEventListener()的时候我还有些犹豫,前缀应该写什么呢?DOMElement,Document,Window貌似都行。看了MDN的定义之后,才知道应该是EventTarget
。
可以来品一下MDN的定义:
The EventTarget method addEventListener() sets up a function that will be called whenever the specified event is delivered to the target.
Common targets are Element, Document, and Window
,but the target may be any object that supports events (such as XMLHttpRequest)
.
这里值得注意的是:除了一些常见的例如DOM Element
, Document
, Window
这类EventTarget可以绑定事件之外,还有一些”奇怪的东西”也能绑定,例如XMLHttpRequest
。
EventTarget.addEventListener()的参数
众所周知EventTarget.addEventListener()的前两个参数是做什么的:
-
type
A case-sensitive string representing the event type to listen for.
一个
区分大小写
的string,标注监听的类型
-
listener
This must be an object implementing the EventListener interface, or a JavaScript function.
必须是一个实现了EventListener接口的对象或者是一个JS的函数。(这里实现一个EventListener接口的对象还挺麻烦的🤦♂️,暂时忽略可专抽一期研究)。我们主要在日常开发中使用的都是一个function。
- options
- capture: Boolean,表示 listener 会在该类型的事件捕获阶段传播到该 EventTarget 时触发。
- once: Boolean,表示 listener 在添加之后最多只调用一次。如果是 true, listener 会在其被调用之后自动移除。
- passive: Boolean,设置为true时,表示 listener 永远不会调
-
useCapture
是一个配置是否在
捕获阶段
就执行的配置项。
其实我之前还记得第三个参数是useCapture
但是我打开现在的mdn发现第三个参数已经变成了options????
先来自己验证一下整个事件流程
首先我们需要构造一个3层的嵌套结构。
function App() {
return (
<div className="bodyContainer">
<div className="container1">
<span className="tex1">Container 1</span>
<div className="container2">
<span className="tex1">Container 2</span>
<div className="container3">
<span className="tex1">Container 3</span>
</div>
</div>
</div>
</div>
);
}
分别给每个层级的div加一个事件监听
。
import React, { useCallback, useEffect } from 'react';
import './App.css';
function App() {
const onContainerBodyClick = useCallback(() => {
console.log('click in container')
}, []);
const onContainer1Click = useCallback(() => {
console.log('click in container 1');
}, []);
const onContainer2Click = useCallback(() => {
console.log('click in container 2');
}, []);
const onContainer3Click = useCallback(() => {
console.log('click in container 3');
}, []);
useEffect(() => {
const $ContainerBodyEle = document.getElementById('containerBody');
const $Container1Ele = document.getElementById('container1');
const $Container2Ele = document.getElementById('container2');
const $Container3Ele = document.getElementById('container3');
$ContainerBodyEle.addEventListener('click', onContainerBodyClick);
$Container1Ele.addEventListener('click', onContainer1Click);
$Container2Ele.addEventListener('click', onContainer2Click);
$Container3Ele.addEventListener('click', onContainer3Click);
return () => {
$ContainerBodyEle.removeEventListener('click', onContainerBodyClick);
$Container1Ele.removeEventListener('click', onContainer1Click);
$Container2Ele.removeEventListener('click', onContainer2Click);
$Container3Ele.removeEventListener('click', onContainer3Click);
}
}, [onContainerBodyClick, onContainer1Click, onContainer2Click, onContainer3Click]);
return (
<div id="containerBody" className="bodyContainer">
<div id="container1" className="container1">
<span className="tex1">Container 1</span>
<div id="container2" className="container2">
<span className="tex1">Container 2</span>
<div id="container3" className="container3">
<span className="tex1">Container 3</span>
</div>
</div>
</div>
</div>
);
}
export default App;
点击最外层
点击Container1
点击Container2
点击Container3
因为是验证所以想把过程贴的详细一些,就把每次的截图都贴出来了。
可以看到如果按照之前我们所说的冒泡机制
,我们的输出结果是完全符合的。
这时候我想看看options里的capture属性和useCapture有什么区别?
我们在Container2上分别设置下options: { capture: true }
和useCapture: true
。
$Container2Ele.addEventListener('click', onContainer2Click, { capture: true });
$Container2Ele.addEventListener('click', onContainer2Click, true);
发现打印的结果都如下
查看DOM的事件监听发现最终都体现为这个样子
所以我的结论是,options中的capture属性
和useCapture
配置的是同一个功能。将Container2
的事件在捕获阶段就执行,所以最先打印的是click in container 2
。
二顾:React事件机制
在介绍完最基本的DOM的事件流向机制之后,我们来学习一下React的事件机制。
友情提示: 会有很多源码阅读…没有办法,搞懂原理就离不开去瞟几眼源码的命运…
首先我们先把之前的例子改成React中的事件写法
import React, { useCallback, useEffect } from 'react';
import './App.css';
function App() {
const onContainerBodyClick =() => {
console.log('react container')
}
const onContainer1Click = () => {
console.log('react container 1');
}
const onContainer2Click = () => {
console.log('react container 2');
}
const onContainer3Click =() => {
console.log('react container 3');
};
return (
<div id="containerBody" className="bodyContainer" onClick={onContainerBodyClick}>
<div id="container1" className="container1" onClick={onContainer1Click}>
<span className="tex1">Container 1</span>
<div id="container2" className="container2" onClick={onContainer2Click}>
<span className="tex1">Container 2</span>
<div id="container3" className="container3" onClick={onContainer3Click}>
<span className="tex1">Container 3</span>
</div>
</div>
</div>
</div>
);
}
export default App;
然后点击Container 3
打印结果如下
效果和DOM的事件打印出来一摸一样
。但是真的是一摸一样吗???
这个时候把React事件和DOM事件同时绑定
到对应的元素上。
这个时候区别瞬间就出来了!
React的事件
全部都是晚于DOM事件
的执行。
为什么React的事件全部都会晚于DOM事件的执行呢??
我们都知道React会把事件代理到document
上(< React v17.x),如图
React提供了一种“顶层注册,事件收集,统一触发”的事件机制。
SyntheticEvent(合成事件)
SyntheticEvent
是一个类
,这个类实例出来的对象是一个合成事件
,顾名思义SyntheticEvent
是有合成之意
的。
引入一个问题
既然React
提供了合成事件,那么合成事件
和原生事件
是如何最终对应起来并发挥作用的呢??
EventPlugin(React 事件插件)
事件插件可以认为是React将不同的合成事件
处理函数封装
成了一个模块
。
每个模块只处理自己对应的合成事件,这样不同类型的事件种类就可以在代码上解耦,例如针对onChange
事件有一个单独的LegacyChangeEventPlugin
插件来处理,针对onMouseEnter
, onMouseLeave
使用 LegacyEnterLeaveEventPlugin
插件来处理。
React会在一开始就会把事件插件
全部都加载进来
injectEventPluginsByName({
SimpleEventPlugin: LegacySimpleEventPlugin,
EnterLeaveEventPlugin: LegacyEnterLeaveEventPlugin,
ChangeEventPlugin: LegacyChangeEventPlugin,
SelectEventPlugin: LegacySelectEventPlugin,
BeforeInputEventPlugin: LegacyBeforeInputEventPlugin
});
事件绑定
其实仔细关注一下我们是如何注册一个React事件的
<div id="container1" className="container1" onClick={onContainer1Click}>
onClick
其实是一个prop
,而我们的事件函数onContainer1Click
是通过传参的方式传到了组件(其实是fiber节点,但是我对fiber的了解很少,所以这里没办法展开讲)。
事件注册
其实就是将我们在JSX中所编写的事件相关的操作绑定
到document上的一种操作。
而事件绑定
主要是在初始化DOM的事件(当然DOM更新的时候也会有,但我们这里不讨论)。在初始化的时候会调用一个方法叫做setInitialDOMProperties()
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
,这里判断某个propKey
是否在registrationNameDependencies
中,registrationNameDependencies
是一个对象,存储了所有React事件对应的原生DOM事件的集合。
补充两个比较重要的对象
第一个是registrationNameModule
。
它包含了 React 事件到它对应的 plugin 的映射, 大致长下面这样,它包含了 React 所支持的所有事件类型,这个对象最大的作用是判断一个组件的 prop 是否是事件类型,这在处理原生组件的 props 时候将会用到,如果一个 prop 在这个对象中才会被当做事件处理。
{
onBlur: SimpleEventPlugin,
onClick: SimpleEventPlugin,
onClickCapture: SimpleEventPlugin,
onChange: ChangeEventPlugin,
onChangeCapture: ChangeEventPlugin,
onMouseEnter: EnterLeaveEventPlugin,
onMouseLeave: EnterLeaveEventPlugin,
...
}
第二个是registrationNameDependencies
,这个对象就是回答我们最开始提的那个问题的答案,他就是将SyntheticEvent
和原生DOM事件
一一对应起来的对象。
{
onBlur: ['blur'],
onClick: ['click'],
onClickCapture: ['click'],
onChange: ['blur', 'change', 'click', 'focus', 'input', 'keydown', 'keyup', 'selectionchange'],
onMouseEnter: ['mouseout', 'mouseover'],
onMouseLeave: ['mouseout', 'mouseover'],
...
}
如果是事件类型的prop,那么将会调用ensureListeningTo()
去绑定事件。
function ensureListeningTo(rootContainerElement, registrationName, domElement) {
const isDocumentOrFragment =
rootContainerElement.nodeType === DOCUMENT_NODE ||
rootContainerElement.nodeType === DOCUMENT_FRAGMENT_NODE
const doc = isDocumentOrFragment
? rootContainerElement
: rootContainerElement.ownerDocument
listenTo(registrationName, doc) // <------- 看看这
}
可以简单的理解为ensureListeningTo
就是去完成事件绑定
这步操作的。
export function listenTo(
registrationName: string,
mountAt: Document | Element,
) {
const isListening = getListeningForDocument(mountAt)
const dependencies = registrationNameDependencies[registrationName]
for (let i = 0; i < dependencies.length; i++) {
const dependency = dependencies[i]
if (!(isListening.hasOwnProperty(dependency) && isListening[dependency])) {
switch (dependency) {
case TOP_SCROLL:
trapCapturedEvent(TOP_SCROLL, mountAt) // <------- 看看这
break
case TOP_FOCUS:
case TOP_BLUR:
trapCapturedEvent(TOP_FOCUS, mountAt)
trapCapturedEvent(TOP_BLUR, mountAt)
// We set the flag for a single dependency later in this function,
// but this ensures we mark both as attached rather than just one.
isListening[TOP_BLUR] = true
isListening[TOP_FOCUS] = true
break
case TOP_CANCEL:
case TOP_CLOSE:
if (isEventSupported(getRawEventName(dependency))) {
trapCapturedEvent(dependency, mountAt) // <------- 看看这
}
break
case TOP_INVALID:
case TOP_SUBMIT:
case TOP_RESET:
// We listen to them on the target DOM elements.
// Some of them bubble so we don't want them to fire twice.
break
default:
// By default, listen on the top level to all non-media events.
// Media events don't bubble so adding the listener wouldn't do anything.
const isMediaEvent = mediaEventTypes.indexOf(dependency) !== -1
if (!isMediaEvent) {
trapBubbledEvent(dependency, mountAt) // <------- 还有这
}
break
}
isListening[dependency] = true
}
}
}
这里就是潇
大佬分享的最主要的那部分了,我们不需要看懂整个listenTo()。
- 首先我们知道这个东西是完成绑定的最后一步,也就是
设置listener
的最后一步 - 然后代码中
switch
的是事件的种类
。 - 仔细看其实就做了一件事就是,为
捕获阶段的Event
or冒泡阶段的Event
设置一个陷阱
,让它们”掉进来”。这个名字也太形象了。。。。就是监听
。
export function trapBubbledEvent(
topLevelType: DOMTopLevelEventType,
element: Document | Element,
) {
if (!element) {
return null
}
const dispatch = isInteractiveTopLevelEventType(topLevelType)
? dispatchInteractiveEvent
: dispatchEvent
addEventBubbleListener( // <------- 看看这
element,
getRawEventName(topLevelType),
// Check if interactive and wrap in interactiveUpdates
dispatch.bind(null, topLevelType),
)
}
export function trapCapturedEvent(
topLevelType: DOMTopLevelEventType,
element: Document | Element,
) {
if (!element) {
return null
}
const dispatch = isInteractiveTopLevelEventType(topLevelType)
? dispatchInteractiveEvent
: dispatchEvent
addEventCaptureListener( // <------- 看看这
element,
getRawEventName(topLevelType),
// Check if interactive and wrap in interactiveUpdates
dispatch.bind(null, topLevelType),
)
}
trapCapturedEvent & trapBubbledEvent
就是我之前说的,如同这个函数的名字,给捕获(或冒泡)阶段的事件设置陷阱。
也就是设置监听事件。
源码中的addEventCaptureListener & addEventBubbleListener
都只是element.addEventListener
包裹了一层。其本意就是去设置监听器了。
到这个阶段,绑定
就彻底完成了。
完成绑定之后的监听
第一步绑定
完成之后,把绑定到对应位置的监听函数
叫做listener
。但这个listener
并不是我们在组件里所写的那一个函数,而是一个通过createEventListenerWrapperWithPriority()
包裹生成的函数。
在看这个这么长名字的函数其实已经明白这个函数是做什么的了。创建一个有优先级eventListener包裹器
。
其实不同的事件
都有各自不同的优先级
。
-
DiscreteEvent
离散事件. 例如blur、focus、 click、 submit、 touchStart. 这些事件都是离散触发的 -
UserBlockingEvent
用户阻塞事件. 例如touchMove、mouseMove、scroll、drag、dragOver等等。这些事件会’阻塞’用户的交互。 -
ContinuousEvent
可连续事件。例如load、error、loadStart、abort、animationEnd. 这个优先级最高,也就是说它们应该是立即同步执行的,这就是Continuous的意义,即可连续的执行,不被打断.
值得补充
到现在位置我们遗漏了一个知识点,就是我们改怎么区分事件执行阶段
呢?
在事件注册
(事件绑定)的时候我们可以通过区分onClick
or onClickCapture
来区分是在该事件是以捕获阶段
的顺序执行还是冒泡阶段
的顺序执行。
但是到了真正执行事件
的时候呢?我们已经把对应的事件函数通过createEventListenerWrapperWithPriority()
包裹了,并不是我们本身的那个事件处理函数了。
这时候createEventListenerWrapperWithPriority()
包裹形成的函数本身接受一个参数eventSystemFlags
用于区分系统各个阶段。
比如eventSystemFlags
其中一个标记叫IS_CAPTURE_PHASE
表明了当前的事件是捕获阶段触发。当事件名含有Capture
后缀时,eventSystemFlags
会被赋值为IS_CAPTURE_PHASE
。
可以看这张图
最后的事件触发
这里的触发流程极其复杂(for me),所以我直接引用React 事件系统工作原理 - 网易大前端文章中的解释
最后坚持一会,源码马上就要看完啦。
我们知道由于所有类型种类的事件都是绑定为React的 dispatchEvent 函数,所以就能在全局处理一些通用行为。
这里的dispatchEventForLegacyPluginEventSystem()
我在别的源码版本中就是dispatchEvent()
…具体不知道其中的渊源。
export function dispatchEventForLegacyPluginEventSystem(
topLevelType: DOMTopLevelEventType,
eventSystemFlags: EventSystemFlags,
nativeEvent: AnyNativeEvent,
targetInst: null | Fiber,
): void {
const bookKeeping = getTopLevelCallbackBookKeeping( // <------- 看看这
topLevelType,
nativeEvent,
targetInst,
eventSystemFlags
);
try {
// Event queue being processed in the same cycle allows
// `preventDefault`.
batchedEventUpdates(handleTopLevel, bookKeeping);
} finally {
releaseTopLevelCallbackBookKeeping(bookKeeping);
}
}
- 任意一个事件触发,执行
dispatchEvent
函数。 dispatchEvent
执行batchedEventUpdates(handleTopLevel)
,batchedEventUpdates
会打开批量渲染开关并调用handleTopLevel。
handleTopLevel
会依次执行 plugins 里所有的事件插件
。- 如果一个插件检测到自己需要处理的事件类型时,则处理该事件。
其实这个阶段最主要的目的就是模拟捕获和冒泡
的过程。
至于源码,因为涉及到React Fiber相关的知识,我在这方面还有欠缺,暂时没办法展开讲。
终于能回到我们的Demo了
看源码可能头都已经大了🤦♂️。这个时候在我们初步了解过一些深层的原理之后,我们就可以理解一下之前的现象。
如图中所示React事件都会代理到document上
简单的概括总结一下
- react首先会通过
registrationNameModule & registrationNameDependencies
去判断元素中的参数是否有事件类型
的。 - 然后会通过
trapBubbledEvent
ortrapCaptureEvent
去绑定监听事件
。 - 监听事件并不是我们给定的事件函数,而是通过
createEventListenerWrapperWithPriority
包裹生成的带有优先级的监听事件。 - 然后事件的触发是通过执行
dispatchEvent
函数,将事件批量执行,找到对应的plugin
类行,处理对应的事件操作。
而这一切的一切,是需要DOM事件冒泡到document才能执行的。如果我们在其中一个元素的原生DOM事件上添加组织冒泡行为:
const onContainer2Click = useCallback((e) => {
e.stopPropagation();
console.log('click in container 2');
}, []);
- 可以看到事件先是
捕获
到Container 3
然后开始冒泡 - 冒泡到
Container2
被阻止不再继续冒泡。 - 这时候
Container1
就不会执行 - 没有冒泡到document所以
React
的合成事件一个也不会被调用。
三顾:回顾总结
React 17更换了事件绑定的位置。
React 17取消了事件复用
官方的解释是事件对象的复用在现代浏览器上性能已经提高的不明显了,反而还很容易让人用错,所以干脆就放弃这个优化。
现在返回来看React事件系统图
- 事件从DOM兴起,由React进行一层包装形成相应的具有
优先级
的ReactEventListener
。 application
就是我们编写的React应用。我们通过ReactEventEmitter
所暴露出来的相关事件接口来为React提供能够包裹的事件监听函数。PluginRegistry
才是整个React事件机制的核心,它用于协调,分发
不同种类的事件。- 而React会在界面初始化就把
若干种类的插槽
都加载到PluginRegistry
中供其使用。