写在前面
上个月有幸研究了 react
事件机制这个知识点,并且在公司内部把自己的理解进行了分享。现在趁还算热乎赶紧的整理下来,留住这个长脸的时刻。
大纲
主要分为 4 大块儿,主要是结合源码对 react 事件机制的原理
进行分析,希望可以让你对 react 事件机制有更清晰的认识和理解。
当然肯定会存在一些表述不清或者理解不够标准的地方,还请各位大神、大佬斧正。
01 - 对事件机制的初步理解和验证
02 - 对于合成的理解
03 - 事件注册机制
04 - 事件执行机制
01 02 是理论的废话,也算是我的个人总结,没兴趣的可以直接跳到 03-事件执行机制。
ps: 本文基于 react15.6.1 进行分析,虽然不是最新版本但是也不会影响我们对 react 事件机制的整体把握和理解。
对事件机制的初步理解和验证
对react 事件机制
的表象理解,验证,意义和思考。
表象理解
先回顾下 对 react 事件机制基本理解,react 自身实现了一套自己的事件机制,包括事件注册、事件的合成、事件冒泡、事件派发等,虽然和原生的是两码事,但也是基于浏览器的事件机制下完成的。
我们都知道 react 的所有事件并没有绑定到具体的 dom 节点上而是绑定在了 document 上,然后由统一的事件处理程序来处理,同时也是基于浏览器的事件机制(冒泡),所有节点的事件都会在 document 上触发。
既然已经有了对 react事件
的一个基本的认知,那这个认知是否正确呢?我们可以通过简单的方法进行验证。
验证
验证内容:
所有事件均注册到了元素的最顶层-document 上 节点的事件由统一的入口处理 为了方便,直接通过 cli 创建一个项目。
javascript 代码解读复制代码componentDidMount(){
document.getElementById('btn-reactandnative').addEventListener('click', (e) => {
console.log('原生+react 事件: 原生事件执行');
});
}
handleNativeAndReact = (e) => {
console.log('原生+react 事件: 当前执行react事件');
}
handleClick=(e)=>{
console.log('button click');
}
render(){
return <div className="pageIndex"><p>react event!!!</p
<button id="btn-confirm" onClick={this.handleClick}>react 事件</button>
<button id="btn-reactandnative" onClick={this.handleNativeAndReact}>原生 + react 事件</button>
</div>
}
代码中给两个 button
绑定了合成事件,单独给btn#btn-reactandnative
绑定了一个原生的事件。
然后看下chrome
控制台,查看元素上注册的事件。
经过简单的验证,可以看到所有的事件根据不同的事件类型都绑定在了 document
上,触发函数统一是 dispatchEvent
。
试想一下
如果一个节点上同时绑定了合成和原生事件,那么禁止冒泡后执行关系是怎样的呢?
其实读到这里答案已经有了。我们现在基于目前的知识去分析下这个关系。
因为合成事件的触发是基于浏览器的事件机制来实现的,通过冒泡机制冒泡到最顶层元素,然后再由dispatchEvent
统一去处理。
得出的结论:
原生事件阻止冒泡肯定会阻止合成事件的触发。
合成事件的阻止冒泡不会影响原生事件。
为什么呢?先回忆下浏览器事件机制
浏览器事件的执行需要经过三个阶段,捕获阶段-目标元素阶段-冒泡阶段。
节点上的原生事件的执行是在目标阶段,然而合成事件的执行是在冒泡阶段,所以原生事件会先合成事件执行,然后再往父节点冒泡。
既然原生都阻止冒泡了,那合成还执行个啥嘞。
好,轮到合成的被阻止冒泡了,那原生会执行吗? 当然会了。
因为原生的事件先于合成的执行,所以合成事件内阻止的只是合成的事件冒泡。(代码我就不贴了)
所以得出结论:
原生事件(阻止冒泡)会阻止合成事件的执行
合成事件(阻止冒泡)不会阻止原生事件的执行
两者最好不要混合使用,避免出现一些奇怪的问题
意义
react 自己做这么多的意义是什么?
- 减少内存消耗,提升性能,不需要注册那么多的事件了,一种事件类型只在 document 上注册一次
- 统一规范,解决 ie 事件兼容问题,简化事件逻辑
- 对开发者友好
思考
既然 react 帮我们做了这么多事儿,那他的背后的机制是什么样的呢?
事件怎么注册的,事件怎么触发的,冒泡机制怎样实现的呢?
请继续往后看......
对于合成的理解
刚听说合成这个词时候,感觉是特别高大上,很有深度,不是很好理解。
当我大概的了解过 react 事件机制后,略微了解一些皮毛,我觉得合成不单单是事件的合成和处理,从广义上来说还包括:
- 对原生事件的封装
- 对某些原生事件的升级和改造
- 不同浏览器事件兼容的处理
对原生事件的封装
上面代码是给一个元素添加click
事件的回调方法,方法中的参数e
,其实不是原生事件对象而是 react 包装过的对象,同时原生事件对象被放在了属性 e.nativeEvent
内。
通过调试,在执行栈里看下这个参数e
包含哪些属性
再看下官方说明文档
SyntheticEvent 是 react 合成事件的基类,定义了合成事件的基础公共属性和方法。
react 会根据当前的事件类型来使用不同的合成事件对象,比如鼠标单机事件 - SyntheticMouseEvent,焦点事件-SyntheticFocusEvent 等,但是都是继承自 SyntheticEvent。
对原生事件的升级和改造
对于有些 dom 元素事件,我们进行事件绑定之后,react 并不是只处理你声明的事件类型,还会额外的增加一些其他的事件,帮助我们提升交互的体验。
这里就举一个例子来说明下:
当我们给 input 声明个 onChange 事件,看下 react 帮我们做了什么?
可以看到 react 不只是注册了一个 onchange 事件,还注册了很多其他事件。
而这个时候我们向文本框输入内容的时候,是可以实时的得到内容的。
然而原生只注册一个 onchange 的话,需要在失去焦点的时候才能触发这个事件,所以这个原生事件的缺陷 react 也帮我们弥补了。
ps: 上面红色箭头中有一个 invalid 事件,这个并没有注册到 document 上,而是在具体的元素上。我的理解是这个是 html5 新增的一个事件,当输入的数据不符合验证规则的时候自动触发,然而验证规则和配置都要写在当前 input 元素上,如果注册到 document 上这个事件就无效了。
浏览器事件的兼容处理
react 在给 document 注册事件的时候也是对兼容性做了处理。
上面这个代码就是给 document 注册事件,内部其实也是做了对ie浏览器
的兼容做了处理。
以上就是我对于 react 合成这个名词的理解,其实 react 内部还处理了很多,我只是简单的举了几个栗子,后面开始聊事件注册和事件派发的机制。
事件注册机制
这是 react
事件机制的第三节 - 事件注册,在这里你将了解react
事件的注册过程,以及在这个过程中主要经过了哪些关键步骤,同时结合源码进行验证和增强理解。
在这里并不会说非常细节的内容,而是把大概的流程和原理性的内容进行介绍,做到对整体流程有个认知和理解。
大致流程
react 事件注册过程其实主要做了 2 件事:事件注册、事件存储。
a. 事件注册 - 组件挂载阶段,根据组件内的声明的事件类型-onclick,onchange 等,给 document 上添加事件 -addEventListener,并指定统一的事件处理程序 dispatchEvent。
b. 事件存储 - 就是把 react 组件内的所有事件统一的存放到一个对象里,缓存起来,为了在触发事件的时候可以查找到对应的方法去执行。
关键步骤
上面大致说了事件注册需要完成的两个目标,那完成目标的过程需要经过哪些关键处理呢?
首先 react 拿到将要挂载的组件的虚拟 dom(其实就是 react element 对象),然后处理react dom
的 props ,判断属性内是否有声明为事件的属性,比如onClick,onChange
,这个时候得到事件类型 click,change
和对应的事件处理程序 fn
,然后执行后面3步
a. 完成事件注册
b. 将react dom
,事件类型,处理函数 fn
放入数组存储
c. 组件挂载完成后,处理 b 步骤生成的数组,经过遍历把事件处理函数存储到listenerBank(一个对象)
中
源码解析
从 jsx 说起
看个最熟悉的代码,也是我们日常的写法
javascript 代码解读复制代码 //此处代码省略
handleFatherClick=()=>{
}
handleChildClick=()=>{
}
render(){
return <div className="box">
<div className="father" onClick={this.handleFatherClick}>
<div className="child" onClick={this.handleChildClick}>child </div>
</div>
</div>
}
经过 babel
编译后,可以看到最终调用的方法是react.createElement
,z 而且声明的事件类型和回调就是个props
。
react.createElement
执行的结果会返回一个所谓的虚拟 dom (react element object)
处理组件 props,拿到事件类型和回调 fn
ReactDOMComponent
在进行组件加载(mountComponent)、更新(updateComponent)的时候,需要对 props 进行处理(_updateDOMProperties):
可以看下 registrationNameModules 的内容,就不细说了,他就是一个内置的常量。
事件注册和事件的存储
事件注册
接着上面的代码执行到了这个方法
javascript;
代码解读;
复制代码enqueuePutListener(this, propKey, nextProp, transaction);
在这个方法里会进行事件的注册以及事件的存储,包括冒泡和捕获的处理
根据当前的组件实例获取到最高父级-也就是 document,然后执行方法 listenTo
- 也是最关键的一个方法,进行事件绑定处理。
源码文件:ReactBrowerEventEmitter.js
最后执行EventListener.listen(冒泡)
或者EventListener.capture(捕获)
,单看下冒泡的注册,其实就是addEventListener
的第三个参数是 false
。
也可以看到注册事件的时候也对 ie 浏览器做了兼容。
上面没有看到 dispatchEvent
的定义,下面可以看到传入 dispatchEvent
方法的代码。
到这里事件注册就完事儿了。
事件存储
开始事件的存储,在 react 里所有事件的触发都是通过 dispatchEvent
方法统一进行派发的,而不是在注册的时候直接注册声明的回调,来看下如何存储的 。
react
把所有的事件和事件类型以及 react 组件进行关联,把这个关系保存在了一个 map
里,也就是一个对象里(键值对),然后在事件触发的时候去根据当前的组件id
和事件类
型查找到对应的事件fn
。
结合源码:
javascript 代码解读复制代码function enqueuePutListener(inst, registrationName, listener, transaction) {
var containerInfo = inst._hostContainerInfo;
var isDocumentFragment = containerInfo._node && containerInfo._node.nodeType === DOC_FRAGMENT_TYPE;
var doc = isDocumentFragment ? containerInfo._node : containerInfo._ownerDocument;
listenTo(registrationName, doc);//这个方法上面已说完
//这里涉及到了事务,事物会在以后的章节再介绍,主要看事件注册
//下面的代码是将putListener放入数组,当组件挂载完后会依次执行数组的回调。也就是putListener会依次执行
transaction.getReactMountReady().enqueue(putListener, {
inst: inst,//组件实例
registrationName: registrationName,//事件类型 click
listener: listener //事件回调 fn
});
}
function putListener() {
var listenerToPut = this;
//放入数组,回调队列
EventPluginHub.putListener(listenerToPut.inst, listenerToPut.registrationName, listenerToPut.listener);
}
大致的流程就是执行完listenTo(事件注册)
,然后执行 putListener
方法进行事件存储,所有的事件都会存储到一个对象中 - listenerBank
,具体由EventPluginHub
进行管理。
javascript 代码解读复制代码 //拿到组件唯一标识 id
var getDictionaryKey = function getDictionaryKey(inst) {
return '.' + inst._rootNodeID;
}
putListener: function putListener(inst, registrationName, listener) {
//得到组件 id
var key = getDictionaryKey(inst);
//得到listenerBank对象中指定事件类型的对象
var bankForRegistrationName = listenerBank[registrationName] || (listenerBank[registrationName] = {});
//存储回调 fn
bankForRegistrationName[key] = listener;
//....
}
listenerBank
其实就是一个二级 map
,这样的结构更方便事件的查找。
这里的组件 id 就是组件的唯一标识,然后和 fn 进行关联,在触发阶段就可以找到相关的事件回调。
看到这个结构是不是很熟悉呢?就是我们平常使用的 object.
到这里大致的流程已经说完,是不是感觉有点明白又不大明白。
没关系,再来个详细的图,重新理解下。
事件执行机制
在事件注册阶段,最终所有的事件和事件类型都会保存到listenerBank
中。
那么在事件触发的过程中上面这个对象有什么 �� 处呢?
其实就是用来查找事件回调
大致流程
事件触发过程总结为主要下面几个步骤:
1.进入统一的事件分发函数(dispatchEvent)
2.结合原生事件找到当前节点对应的 ReactDOMComponent 对象
3.开始事件的合成
3.1 根据当前事件类型生成指定的合成对象
3.2 封装原生事件和冒泡机制
3.3 查找当前元素以及他所有父级
3.4 在listenerBank
查找事件回调并合成到 event
(合成事件结束)
4.批量处理合成事件内的回调事件(事件触发完成 end)
举个栗子
在说具体的流程前,先看一个栗子,后面的分析也是基于这个栗子
javascript 代码解读复制代码handleFatherClick=(e)=>{
console.log('father click');
}
handleChildClick=(e)=>{
console.log('child click');
}
render(){
return <div className="box">
<div className="father" onClick={this.handleFatherClick}> father
<div className="child" onClick={this.handleChildClick}>child </div>
</div>
</div>
}
看到这个熟悉的代码,我们就已经知道了执行结果。
当我点击 child div 的时候,会同时触发 father 的事件。
源码解析
dispatchEvent 进行事件分发
进入统一的事件分发函数 (dispatchEvent)。
当我点击 child div 的时候,这个时候浏览器会捕获到这个事件,然后经过冒泡,事件被冒泡到 document 上,交给统一事件处理函数 dispatchEvent 进行处理。(上一文中我们已经说过 document 上已经注册了一个统一的事件处理函数 dispatchEvent)。
查找 ReactDOMComponent
结合原生事件找到当前节点对应的ReactDOMComponent
对象,在原生事件对象内已经保留了对应的ReactDOMComponent
实例的引用,应该是在挂载阶段就已经保存了。
看下 ReactDOMComponent 实例的内容
事件合成 ing
事件的合成,冒泡的处理以及事件回调的查找都是在合成阶段完成的。
合成对象的生成
根据当前事件类型找到对应的合成类,然后进行合成对象的生成
javascript 代码解读复制代码//进行事件合成,根据事件类型获得指定的合成类
var SimpleEventPlugin = {
eventTypes: eventTypes,
extractEvents: function extractEvents(topLevelType, targetInst, nativeEvent, nativeEventTarget) {
var dispatchConfig = topLevelEventsToDispatchConfig[topLevelType];
//代码已省略....
var EventConstructor;
switch (topLevelType) {
//代码已省略....
case 'topClick'://【这里有一个不解的地方】 topLevelType = topClick,执行到这里了,但是这里没有做任何操作
if (nativeEvent.button === 2) {
return null;
}
//代码已省略....
case 'topContextMenu'://而是会执行到这里,获取到鼠标合成类
EventConstructor = SyntheticMouseEvent;
break;
case 'topAnimationEnd':
case 'topAnimationIteration':
case 'topAnimationStart':
EventConstructor = SyntheticAnimationEvent;//动画类合成事件
break;
case 'topWheel':
EventConstructor = SyntheticWheelEvent;//鼠标滚轮类合成事件
break;
case 'topCopy':
case 'topCut':
case 'topPaste':
EventConstructor = SyntheticClipboardEvent;
break;
}
var event = EventConstructor.getPooled(dispatchConfig, targetInst, nativeEvent, nativeEventTarget);
EventPropagators.accumulateTwoPhaseDispatches(event);
return event;//最终会返回合成的事件对象
}
封装原生事件和冒泡机制
在这一步会把原生事件对象挂到合成对象的自身,同时增加事件的默认行为处理和冒泡机制
javascript 代码解读复制代码/**
*
* @param {obj} dispatchConfig 一个配置对象 包含当前的事件依赖 ["topClick"],冒泡和捕获事件对应的名称 bubbled: "onClick",captured: "onClickCapture"
* @param {obj} targetInst 组件实例ReactDomComponent
* @param {obj} nativeEvent 原生事件对象
* @param {obj} nativeEventTarget 事件源 e.target = div.child
*/
function SyntheticEvent(dispatchConfig, targetInst, nativeEvent, nativeEventTarget) {
this.dispatchConfig = dispatchConfig;
this._targetInst = targetInst;
this.nativeEvent = nativeEvent;//将原生对象保存到 this.nativeEvent
//此处代码略.....
var defaultPrevented = nativeEvent.defaultPrevented != null ? nativeEvent.defaultPrevented : nativeEvent.returnValue === false;
//处理事件的默认行为
if (defaultPrevented) {
this.isDefaultPrevented = emptyFunction.thatReturnsTrue;
} else {
this.isDefaultPrevented = emptyFunction.thatReturnsFalse;
}
//处理事件冒泡 ,thatReturnsFalse 默认返回 false,就是不阻止冒泡
this.isPropagationStopped = emptyFunction.thatReturnsFalse;
return this;
}
下面是增加的默认行为和冒泡机制的处理方法,其实就是改变了当前合成对象的属性值, 调用了方法后属性值为 true,就会阻止默认行为或者冒泡。
javascript 代码解读复制代码//在合成类原型上增加preventDefault和stopPropagation方法
_assign(SyntheticEvent.prototype, {
preventDefault: function preventDefault() {
// ....略
this.isDefaultPrevented = emptyFunction.thatReturnsTrue;
},
stopPropagation: function stopPropagation() {
//....略
this.isPropagationStopped = emptyFunction.thatReturnsTrue;
}
);
看下 emptyFunction 代码就明白了
查找所有父级实例
根据当前节点实例查找他的所有父级实例存入 path
javascript 代码解读复制代码/**
*
* @param {obj} inst 当前节点实例
* @param {function} fn 处理方法
* @param {obj} arg 合成事件对象
*/
function traverseTwoPhase(inst, fn, arg) {
var path = [];//存放所有实例 ReactDOMComponent
while (inst) {
path.push(inst);
inst = inst._hostParent;//层级关系
}
var i;
for (i = path.length; i-- > 0;) {
fn(path[i], 'captured', arg);//处理捕获 ,反向处理数组
}
for (i = 0; i < path.length; i++) {
fn(path[i], 'bubbled', arg);//处理冒泡,从0开始处理,我们直接看冒泡
}
}
看下 path 长啥样
事件合成结束
在 listenerBank 查找事件回调并合成到 event。
紧接着上面代码
javascript;
代码解读;
复制代码fn(path[i], "bubbled", arg);
上面的代码会调用下面这个方法,在listenerBank
中查找到事件回调,并存入合成事件对象。
javascript 代码解读复制代码
/**EventPropagators.js
* 查找事件回调后,把实例和回调保存到合成对象内
* @param {obj} inst 组件实例
* @param {string} phase 事件类型
* @param {obj} event 合成事件对象
*/
function accumulateDirectionalDispatches(inst, phase, event) {
var listener = listenerAtPhase(inst, event, phase);
if (listener) {//如果找到了事件回调,则保存起来 (保存在了合成事件对象内)
event._dispatchListeners = accumulateInto(event._dispatchListeners, listener);//把事件回调进行合并返回一个新数组
event._dispatchInstances = accumulateInto(event._dispatchInstances, inst);//把组件实例进行合并返回一个新数组
}
}
/**
* EventPropagators.js
* 中间调用方法 拿到实例的回调方法
* @param {obj} inst 实例
* @param {obj} event 合成事件对象
* @param {string} propagationPhase 名称,捕获capture还是冒泡bubbled
*/
function listenerAtPhase(inst, event, propagationPhase) {
var registrationName = event.dispatchConfig.phasedRegistrationNames[propagationPhase];
return getListener(inst, registrationName);
}
/**EventPluginHub.js
* 拿到实例的回调方法
* @param {obj} inst 组件实例
* @param {string} registrationName Name of listener (e.g. `onClick`).
* @return {?function} 返回回调方法
*/
getListener: function getListener(inst, registrationName) {
var bankForRegistrationName = listenerBank[registrationName];
if (shouldPreventMouseEvent(registrationName, inst._currentElement.type, inst._currentElement.props)) {
return null;
}
var key = getDictionaryKey(inst);
return bankForRegistrationName && bankForRegistrationName[key];
}
为什么能够查找到的呢?
因为 inst (组件实例)里有_rootNodeID,所以也就有了对应关系。
到这里事件合成对象生成完成,所有的事件回调已保存到了合成对象中。
批量处理事件合成对象
批量处理合成事件对象内的回调方法(事件触发完成 end)。
生成完 合成事件对象后,调用栈回到了我们起初执行的方法内。
javascript 代码解读复制代码
//在这里执行事件的回调
runEventQueueInBatch(events);
到下面这一步中间省略了一些代码,只贴出主要的代码,下面方法会循环处理 合成事件内的回调方法,同时判断是否禁止事件冒泡。
贴上最后的执行回调方法的代码
javascript 代码解读复制代码/**
*
* @param {obj} event 合成事件对象
* @param {boolean} simulated false
* @param {fn} listener 事件回调
* @param {obj} inst 组件实例
*/
function executeDispatch(event, simulated, listener, inst) {
var type = event.type || 'unknown-event';
event.currentTarget = EventPluginUtils.getNodeFromInstance(inst);
if (simulated) {//调试环境的值为 false,按说生产环境是 true
//方法的内容请往下看
ReactErrorUtils.invokeGuardedCallbackWithCatch(type, listener, event);
} else {
//方法的内容请往下看
ReactErrorUtils.invokeGuardedCallback(type, listener, event);
}
event.currentTarget = null;
}
/** ReactErrorUtils.js
* @param {String} name of the guard to use for logging or debugging
* @param {Function} func The function to invoke
* @param {*} a First argument
* @param {*} b Second argument
*/
var caughtError = null;
function invokeGuardedCallback(name, func, a) {
try {
func(a);//直接执行回调方法
} catch (x) {
if (caughtError === null) {
caughtError = x;
}
}
}
var ReactErrorUtils = {
invokeGuardedCallback: invokeGuardedCallback,
invokeGuardedCallbackWithCatch: invokeGuardedCallback,
rethrowCaughtError: function rethrowCaughtError() {
if (caughtError) {
var error = caughtError;
caughtError = null;
throw error;
}
}
};
if (process.env.NODE_ENV !== 'production') {//非生产环境会通过自定义事件去触发回调
if (typeof window !== 'undefined' && typeof window.dispatchEvent === 'function' && typeof document !== 'undefined' && typeof document.createEvent === 'function') {
var fakeNode = document.createElement('react');
ReactErrorUtils.invokeGuardedCallback = function (name, func, a) {
var boundFunc = func.bind(null, a);
var evtType = 'react-' + name;
fakeNode.addEventListener(evtType, boundFunc, false);
var evt = document.createEvent('Event');
evt.initEvent(evtType, false, false);
fakeNode.dispatchEvent(evt);
fakeNode.removeEventListener(evtType, boundFunc, false);
};
}
}
最后 react 通过生成了一个临时节点 fakeNode,然后为这个临时元素绑定事件处理程序,然后创建自定义事件 Event,通过 fakeNode.dispatchEvent 方法来触发事件,并且触发完毕之后立即移除监听事件。
到这里事件回调已经执行完成,但是也有些疑问,为什么在非生产环境需要通过自定义事件来执行回调方法。可以看下上面的代码在非生产环境对ReactErrorUtils.invokeGuardedCallback
方法进行了重写。
总结
主要是从整体流程上介绍了下 react 事件的原理,其中并没有深入到源码的各个细节,包括事务处理、合成的细节等,另外梳理过程中自己也有一些疑惑的地方,感觉说原理还能比较容易理解一些,但是一结合源码来写就会觉得乱,因为react
代码过于庞大,而且盘根错节,很难抽离,对源码有兴趣的小伙儿可以深入研究下,当然还是希望本文能够带给你一些启发,若文章有表述不清或有问题的地方欢迎留言、 交流、斧正。
最后
文章行文潦草,有碍观瞻,各位大佬受苦了。^_^...
参考资料
react.docschina.org/docs/events…
作者:zz_jesse 链接:https://juejin.cn/post/6844903939092348936 来源:稀土掘金 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。