「React进阶」一文吃透react事件原理

开发 前端
今天我们来一起探讨一下React事件原理,这篇文章,我尽量用通俗简洁的方式,把React事件系统讲的明明白白。

[[396223]]

本文转载自微信公众号「前端Sharing」,作者前端Sharing。转载本文请联系前端Sharing公众号。

一 前言

今天我们来一起探讨一下React事件原理,这篇文章,我尽量用通俗简洁的方式,把React事件系统讲的明明白白。

我们讲的react版本是16.13.1 , v17之后react对于事件系统会有相关的改版,文章后半部分会提及。

老规矩,在正式讲解react之前,我们先想想这几个问题(如果我是面试官,你会怎么回答?):

1 我们写的事件是绑定在dom上么,如果不是绑定在哪里?

2 为什么我们的事件不能绑定给组件?

3 为什么我们的事件手动绑定this(不是箭头函数的情况)

4 为什么不能用 return false来阻止事件的默认行为?

5 react怎么通过dom元素,找到与之对应的 fiber对象的?

6 onClick是在冒泡阶段绑定的?那么onClickCapture就是在事件捕获阶段绑定的吗?

必要的知识概念

在弄清楚react事件之前,有几个概念我们必须弄清楚,因为只有弄明白这几个概念,在事件触发阶段,我们才能更好的理解react处理事件本质。

我们写在JSX事件终将变成什么?

我们先写一段含有点击事件的react JSX语法,看一下它最终会变成什么样子?

  1. class Index extends React.Component{ 
  2.     handerClick= (value) => console.log(value)  
  3.     render(){ 
  4.         return <div> 
  5.             <button onClick={ this.handerClick } > 按钮点击 </button> 
  6.         </div> 
  7.     } 

经过babel转换成React.createElement形式,如下:

babel.jpg

最终转成fiber对象形式如下:

fiber.jpg

fiber对象上的memoizedProps 和 pendingProps保存了我们的事件。

什么是合成事件?

通过上一步我们看到了,我们声明事件保存的位置。但是事件有没有被真正的注册呢?我们接下来看一下:

我们看一下当前这个元素<button>上有没有绑定这个事件监听器呢?

button_event.jpg

button上绑定的事件

我们可以看到 ,button上绑定了两个事件,一个是document上的事件监听器,另外一个是button,但是事件处理函数handle,并不是我们的handerClick事件,而是noop。

noop是什么呢?我们接着来看。

原来noop就指向一个空函数。

noop.jpg

然后我们看document绑定的事件

document.jpg

可以看到click事件被绑定在document上了。

接下来我们再搞搞事情??????,在demo项目中加上一个input输入框,并绑定一个onChange事件。睁大眼睛看看接下来会发生什么?

  1. class Index extends React.Component{ 
  2.     componentDidMount(){ 
  3.         console.log(this) 
  4.     } 
  5.     handerClick= (value) => console.log(value)  
  6.     handerChange=(value) => console.log(value) 
  7.     render(){ 
  8.         return <div style={{ marginTop:'50px' }} > 
  9.             <button onClick={ this.handerClick } > 按钮点击 </button> 
  10.             <input  placeholder="请输入内容" onChange={ this.handerChange }  /> 
  11.         </div> 
  12.     } 

我们先看一下input dom元素上绑定的事件

然后我们看一下document上绑定的事件

8E1D3BDB-ACFB-4E49-A5FF-CF990C47A60E.jpg

我们发现,我们给<input>绑定的onChange,并没有直接绑定在input上,而是统一绑定在了document上,然后我们onChange被处理成很多事件监听器,比如blur , change , input , keydown , keyup 等。

综上我们可以得出结论:

①我们在 jsx 中绑定的事件(demo中的handerClick,handerChange),根本就没有注册到真实的dom上。是绑定在document上统一管理的。

②真实的dom上的click事件被单独处理,已经被react底层替换成空函数。

③我们在react绑定的事件,比如onChange,在document上,可能有多个事件与之对应。

④ react并不是一开始,把所有的事件都绑定在document上,而是采取了一种按需绑定,比如发现了onClick事件,再去绑定document click事件。

那么什么是react事件合成呢?

在react中,我们绑定的事件onClick等,并不是原生事件,而是由原生事件合成的React事件,比如 click事件合成为onClick事件。比如blur , change , input , keydown , keyup等 , 合成为onChange。

那么react采取这种事件合成的模式呢?

一方面,将事件绑定在document统一管理,防止很多事件直接绑定在原生的dom元素上。造成一些不可控的情况

另一方面, React 想实现一个全浏览器的框架, 为了实现这种目标就需要提供全浏览器一致性的事件系统,以此抹平不同浏览器的差异。

接下来的文章中,会介绍react是怎么做事件合成的。

dom元素对应的fiber Tag对象

我们知道了react怎么储存了我们的事件函数和事件合成因果。接下来我想让大家记住一种类型的 fiber 对象,因为后面会用到,这对后续的理解很有帮助。

我们先来看一个代码片段:

  1. <div>  
  2.   <div> hello , my name is alien </div> 
  3. </div> 

 

 

 

看<div> hello , my name is alien </div> 对应的 fiber类型。tag = 5

然后我们去react源码中找到这种类的fiber类型。
  1. /react-reconciler/src/ReactWorkTagsq.js 
  1. export const HostComponent = 5; // 元素节点 

好的 ,我们暂且把 HostComponent 和 HostText记录??下来。接下来回归正题,我们先来看看react事件合成机制。

二 事件初始化-事件合成,插件机制

接下来,我们来看一看react这么处理事件合成的。首先我们从上面我们知道,react并不是一次性把所有事件都绑定进去,而是如果发现项目中有onClick,才绑定click事件,发现有onChange事件,才绑定blur , change , input , keydown , keyup等。所以为了把原理搞的清清楚楚,笔者把事件原理分成三部分来搞定:

1 react对事件是如何合成的。

2 react事件是怎么绑定的。

3 react事件触发流程。

事件合成-事件插件

1 必要概念

我们先来看来几个常量关系,这对于我们吃透react事件原理很有帮助。在解析来的讲解中,我也会讲到这几个对象如何来的,具体有什么作用。

①namesToPlugins

第一个概念:namesToPlugins 装事件名 -> 事件模块插件的映射,namesToPlugins最终的样子如下:

  1. const namesToPlugins = { 
  2.     SimpleEventPlugin, 
  3.     EnterLeaveEventPlugin, 
  4.     ChangeEventPlugin, 
  5.     SelectEventPlugin, 
  6.     BeforeInputEventPlugin, 

SimpleEventPlugin等是处理各个事件函数的插件,比如一次点击事件,就会找到SimpleEventPlugin对应的处理函数。我们先记录下它,至于具体有什么作用,接下来会讲到。

②plugins

plugins,这个对象就是上面注册的所有插件列表,初始化为空。

  1. const  plugins = [LegacySimpleEventPlugin, LegacyEnterLeaveEventPlugin, ...]; 

③registrationNameModules

registrationNameModules记录了React合成的事件-对应的事件插件的关系,在React中,处理props中事件的时候,会根据不同的事件名称,找到对应的事件插件,然后统一绑定在document上。对于没有出现过的事件,就不会绑定,我们接下来会讲到。registrationNameModules大致的样子如下所示。

  1.     onBlur: SimpleEventPlugin, 
  2.     onClick: SimpleEventPlugin, 
  3.     onClickCapture: SimpleEventPlugin, 
  4.     onChange: ChangeEventPlugin, 
  5.     onChangeCapture: ChangeEventPlugin, 
  6.     onMouseEnter: EnterLeaveEventPlugin, 
  7.     onMouseLeave: EnterLeaveEventPlugin, 
  8.     ... 

④事件插件

那么我们首先就要搞清楚,SimpleEventPlugin,EnterLeaveEventPlugin每个插件都是什么?我们拿SimpleEventPlugin为例,看一下它究竟是什么样子?

  1. const SimpleEventPlugin = { 
  2.     eventTypes:{  
  3.         'click':{ /* 处理点击事件  */ 
  4.             phasedRegistrationNames:{ 
  5.                 bubbled: 'onClick',       // 对应的事件冒泡 - onClick  
  6.                 captured:'onClickCapture' //对应事件捕获阶段 - onClickCapture 
  7.             }, 
  8.             dependencies: ['click'], //事件依赖 
  9.             ... 
  10.         }, 
  11.         'blur':{ /* 处理失去焦点事件 */ }, 
  12.         ... 
  13.     } 
  14.     extractEvents:function(topLevelType,targetInst,){ /* eventTypes 里面的事件对应的统一事件处理函数,接下来会重点讲到 */ } 

首先事件插件是一个对象,有两个属性,第一个extractEvents作为事件统一处理函数,第二个eventTypes是一个对象,对象保存了原生事件名和对应的配置项dispatchConfig的映射关系。由于v16React的事件是统一绑定在document上的,React用独特的事件名称比如onClick和onClickCapture,来说明我们给绑定的函数到底是在冒泡事件阶段,还是捕获事件阶段执行。

⑤ registrationNameDependencies

  1. onBlur: ['blur'], 
  2. onClick: ['click'], 
  3. onClickCapture: ['click'], 
  4. onChange: ['blur''change''click''focus''input''keydown''keyup''selectionchange'], 
  5. onMouseEnter: ['mouseout''mouseover'], 
  6. onMouseLeave: ['mouseout''mouseover'], 
  7. ... 

2 事件初始化

对于事件合成,v16.13.1版本react采用了初始化注册方式。

  1. react-dom/src/client/ReactDOMClientInjection.js 
  1. /* 第一步:注册事件:  */ 
  2. injectEventPluginsByName({ 
  3.     SimpleEventPlugin: SimpleEventPlugin, 
  4.     EnterLeaveEventPlugin: EnterLeaveEventPlugin, 
  5.     ChangeEventPlugin: ChangeEventPlugin, 
  6.     SelectEventPlugin: SelectEventPlugin, 
  7.     BeforeInputEventPlugin: BeforeInputEventPlugin, 
  8. }); 

injectEventPluginsByName 这个函数具体有什么用呢,它在react底层是默认执行的。我们来简化这个函数,看它到底是干什么的。

  1. legacy-event/EventPluginRegistry.js 
  1. /* 注册事件插件 */ 
  2. export function injectEventPluginsByName(injectedNamesToPlugins){ 
  3.      for (const pluginName in injectedNamesToPlugins) { 
  4.          namesToPlugins[pluginName] = injectedNamesToPlugins[pluginName] 
  5.      } 
  6.      recomputePluginOrdering() 

injectEventPluginsByName做的事情很简单,形成上述的namesToPlugins,然后执行recomputePluginOrdering,我们接下来看一下recomputePluginOrdering做了写什么?

  1. const eventPluginOrder = [ 'SimpleEventPlugin' , 'EnterLeaveEventPlugin','ChangeEventPlugin','SelectEventPlugin' , 'BeforeInputEventPlugin' ] 
  2.  
  3. function recomputePluginOrdering(){ 
  4.     for (const pluginName in namesToPlugins) { 
  5.         /* 找到对应的事件处理插件,比如 SimpleEventPlugin  */ 
  6.         const pluginModule = namesToPlugins[pluginName]; 
  7.         const pluginIndex = eventPluginOrder.indexOf(pluginName); 
  8.         /* 填充 plugins 数组  */ 
  9.         plugins[pluginIndex] = pluginModule; 
  10.     } 
  11.  
  12.     const publishedEvents = pluginModule.eventTypes; 
  13.     for (const eventName in publishedEvents) { 
  14.        // publishedEvents[eventName] -> eventConfig , pluginModule -> 事件插件 , eventName -> 事件名称 
  15.         publishEventForPlugin(publishedEvents[eventName],pluginModule,eventName,) 
  16.     }  
  17. 】 

recomputePluginOrdering,作用很明确了,形成上面说的那个plugins,数组。然后就是重点的函数publishEventForPlugin。

  1. /* 
  2.   dispatchConfig -> 原生事件对应配置项 { phasedRegistrationNames :{  冒泡 捕获  } ,   } 
  3.   pluginModule -> 事件插件 比如SimpleEventPlugin   
  4.   eventName -> 原生事件名称。 
  5. */ 
  6. function publishEventForPlugin (dispatchConfig,pluginModule,eventName){ 
  7.     eventNameDispatchConfigs[eventName] = dispatchConfig; 
  8.     /* 事件 */ 
  9.     const phasedRegistrationNames = dispatchConfig.phasedRegistrationNames; 
  10.     if (phasedRegistrationNames) { 
  11.     for (const phaseName in phasedRegistrationNames) { 
  12.         if (phasedRegistrationNames.hasOwnProperty(phaseName)) { 
  13.             // phasedRegistrationName React事件名 比如 onClick / onClickCapture 
  14.             const phasedRegistrationName = phasedRegistrationNames[phaseName]; 
  15.             // 填充形成 registrationNameModules React 合成事件 -> React 处理事件插件映射关系 
  16.             registrationNameModules[phasedRegistrationName] = pluginModule; 
  17.             // 填充形成 registrationNameDependencies React 合成事件 -> 原生事件 映射关系 
  18.             registrationNameDependencies[phasedRegistrationName] = pluginModule.eventTypes[eventName].dependencies; 
  19.         } 
  20.     } 
  21.     return true
  22.     } 

publishEventForPlugin 作用形成上述的 registrationNameModules 和 registrationNameDependencies 对象中的映射关系。

3 事件合成总结

到这里整个初始化阶段已经完事了,我来总结一下初始化事件合成都做了些什么。这个阶段主要形成了上述的几个重要对象,构建初始化React合成事件和原生事件的对应关系,合成事件和对应的事件处理插件关系。接下来就是事件绑定阶段。

三 事件绑定-从一次点击事件开始

事件绑定流程

如果我们在一个组件中这么写一个点击事件,React会一步步如何处理。

1 diffProperties 处理React合成事件

  1. <div> 
  2.   <button onClick={ this.handerClick }  className="button" >点击</button> 
  3. </div> 

 

 

第一步,首先通过上面的讲解,我们绑定给hostComponent种类的fiber(如上的button元素),会 button 对应的fiber上,以memoizedProps 和 pendingProps形成保存。

  1. button 对应 fiber 
  2. memoizedProps = { 
  3.    onClick:function handerClick(){}, 
  4.    className:'button' 

结构图如下所示:

58E6A4AF-1902-42BC-9D11-B47234037E01.jpg

第二步,React在调合子节点后,进入diff阶段,如果判断是HostComponent(dom元素)类型的fiber,会用diff props函数diffProperties单独处理。

  1. react-dom/src/client/ReactDOMComponent.js 
  1. function diffProperties(){ 
  2.     /* 判断当前的 propKey 是不是 React合成事件 */ 
  3.     if(registrationNameModules.hasOwnProperty(propKey)){ 
  4.          /* 这里多个函数简化了,如果是合成事件, 传入成事件名称 onClick ,向document注册事件  */ 
  5.          legacyListenToEvent(registrationName, document); 
  6.     } 

diffProperties函数在 diff props 如果发现是合成事件(onClick) 就会调用legacyListenToEvent函数。注册事件监听器。

2 legacyListenToEvent 注册事件监听器

  1. react-dom/src/events/DOMLegacyEventPluginSystem.js 
  1. //  registrationName -> onClick 事件 
  2. //  mountAt -> document or container 
  3. function legacyListenToEvent(registrationName,mountAt){ 
  4.    const dependencies = registrationNameDependencies[registrationName]; // 根据 onClick 获取  onClick 依赖的事件数组 [ 'click' ]。 
  5.     for (let i = 0; i < dependencies.length; i++) { 
  6.     const dependency = dependencies[i]; 
  7.     //这个经过多个函数简化,如果是 click 基础事件,会走 legacyTrapBubbledEvent ,而且都是按照冒泡处理 
  8.      legacyTrapBubbledEvent(dependency, mountAt); 
  9.   } 

legacyTrapBubbledEvent 就是执行将绑定真正的dom事件的函数 legacyTrapBubbledEvent(冒泡处理)。

  1. function legacyTrapBubbledEvent(topLevelType,element){ 
  2.    addTrappedEventListener(element,topLevelType,PLUGIN_EVENT_SYSTEM,false

第三步:在legacyListenToEvent函数中,先找到 React 合成事件对应的原生事件集合,比如 onClick -> ['click'] , onChange -> [blur , change , input , keydown , keyup],然后遍历依赖项的数组,绑定事件,这就解释了,为什么我们在刚开始的demo中,只给元素绑定了一个onChange事件,结果在document上出现很多事件监听器的原因,就是在这个函数上处理的。

我们上面已经透露了React是采用事件绑定,React 对于 click 等基础事件,会默认按照事件冒泡阶段的事件处理,不过这也不绝对的,比如一些事件的处理,有些特殊的事件是按照事件捕获处理的。

  1. case TOP_SCROLL: {                                // scroll 事件 
  2.     legacyTrapCapturedEvent(TOP_SCROLL, mountAt); // legacyTrapCapturedEvent 事件捕获处理。 
  3.     break; 
  4. case TOP_FOCUS: // focus 事件 
  5. case TOP_BLUR:  // blur 事件 
  6. legacyTrapCapturedEvent(TOP_FOCUS, mountAt); 
  7. legacyTrapCapturedEvent(TOP_BLUR, mountAt); 
  8. break; 

3 绑定 dispatchEvent,进行事件监听

如上述的scroll事件,focus 事件 ,blur事件等,是默认按照事件捕获逻辑处理。接下来就是最重要关键的一步。React是如何绑定事件到document?事件处理函数函数又是什么?问题都指向了上述的addTrappedEventListener,让我们来揭开它的面纱。

  1. /* 
  2.   targetContainer -> document 
  3.   topLevelType ->  click 
  4.   capture = false 
  5. */ 
  6. function addTrappedEventListener(targetContainer,topLevelType,eventSystemFlags,capture){ 
  7.    const listener = dispatchEvent.bind(null,topLevelType,eventSystemFlags,targetContainer)  
  8.    if(capture){ 
  9.        // 事件捕获阶段处理函数。 
  10.    }else
  11.        /* TODO: 重要, 这里进行真正的事件绑定。*/ 
  12.       targetContainer.addEventListener(topLevelType,listener,false) // document.addEventListener('click',listener,false
  13.    } 

第四步:这个函数内容虽然不多,但是却非常重要,首先绑定我们的事件统一处理函数 dispatchEvent,绑定几个默认参数,事件类型 topLevelType demo中的click ,还有绑定的容器doucment。然后真正的事件绑定,添加事件监听器addEventListener。 事件绑定阶段完毕。

4 事件绑定过程总结

我们来做一下事件绑定阶段的总结。

① 在React,diff DOM元素类型的fiber的props的时候, 如果发现是React合成事件,比如onClick,会按照事件系统逻辑单独处理。

② 根据React合成事件类型,找到对应的原生事件的类型,然后调用判断原生事件类型,大部分事件都按照冒泡逻辑处理,少数事件会按照捕获逻辑处理(比如scroll事件)。

③ 调用 addTrappedEventListener 进行真正的事件绑定,绑定在document上,dispatchEvent 为统一的事件处理函数。

④ 有一点值得注意: 只有上述那几个特殊事件比如 scorll,focus,blur等是在事件捕获阶段发生的,其他的都是在事件冒泡阶段发生的,无论是onClick还是onClickCapture都是发生在冒泡阶段,至于 React 本身怎么处理捕获逻辑的。我们接下来会讲到。

四 事件触发-一次点击事件,在react底层系统会发生什么?

  1. <div> 
  2.   <button onClick={ this.handerClick }  className="button" >点击</button> 
  3. </div> 

 

 

还是上面这段代码片段,当点击一下按钮,在 React 底层会发生什么呢?接下来,让我共同探索事件触发的奥秘。

事件触发处理函数 dispatchEvent

我们在事件绑定阶段讲过,React事件注册时候,统一的监听器dispatchEvent,也就是当我们点击按钮之后,首先执行的是dispatchEvent函数,因为dispatchEvent前三个参数已经被bind了进去,所以真正的事件源对象event,被默认绑定成第四个参数。

  1. react-dom/src/events/ReactDOMEventListener.js 
  1. function dispatchEvent(topLevelType,eventSystemFlags,targetContainer,nativeEvent){ 
  2.     /* 尝试调度事件 */ 
  3.     const blockedOn = attemptToDispatchEvent( topLevelType,eventSystemFlags, targetContainer, nativeEvent); 

在这个阶段主要做了这几件事:

① 首先根据真实的事件源对象,找到 e.target 真实的 dom 元素。

② 然后根据dom元素,找到与它对应的 fiber 对象targetInst,在我们 demo 中,找到 button 按钮对应的 fiber。

③ 然后正式进去legacy模式的事件处理系统,也就是我们目前用的React模式都是legacy模式下的,在这个模式下,批量更新原理,即将拉开帷幕。

这里有一点问题,React怎么样通过原生的dom元素,找到对应的fiber的呢? ,也就是说 getClosestInstanceFromNode 原理是什么?

答案是首先 getClosestInstanceFromNode 可以找到当前传入的 dom 对应的最近的元素类型的 fiber 对象。React 在初始化真实 dom 的时候,用一个随机的 key internalInstanceKey 指针指向了当前dom对应的fiber对象,fiber对象用stateNode指向了当前的dom元素。

  1. // 声明随机key 
  2. var internalInstanceKey = '__reactInternalInstance$' + randomKey; 
  3.  
  4. // 使用随机key  
  5. function getClosestInstanceFromNode(targetNode){ 
  6.   // targetNode -dom  targetInst -> 与之对应的fiber对象 
  7.   var targetInst = targetNode[internalInstanceKey]; 

在谷歌调试器上看

fiber_dom.jpg

两者关系图

dom_fiber.jpg

legacy 事件处理系统与批量更新

  1. react-dom/src/events/DOMLegacyEventPluginSystem.js 
  1. /* topLevelType - click事件 | eventSystemFlags = 1 | nativeEvent = 事件源对象  | targetInst = 元素对应的fiber对象  */ 
  2. function dispatchEventForLegacyPluginEventSystem(topLevelType,eventSystemFlags,nativeEvent,targetInst){ 
  3.     /* 从React 事件池中取出一个,将 topLevelType ,targetInst 等属性赋予给事件  */ 
  4.     const bookKeeping = getTopLevelCallbackBookKeeping(topLevelType,nativeEvent,targetInst,eventSystemFlags); 
  5.     try { /* 执行批量更新 handleTopLevel 为事件处理的主要函数 */ 
  6.     batchedEventUpdates(handleTopLevel, bookKeeping); 
  7.   } finally { 
  8.     /* 释放事件池 */   
  9.     releaseTopLevelCallbackBookKeeping(bookKeeping); 
  10.   } 

对于v16事件池,我们接下来会讲到,首先 batchedEventUpdates为批量更新的主要函数。我们先来看看batchedEventUpdates

  1. react-dom/src/events/ReactDOMUpdateBatching.js 
  1. export function batchedEventUpdates(fn,a){ 
  2.     isBatchingEventUpdates = true
  3.     try{ 
  4.        fn(a) // handleTopLevel(bookKeeping) 
  5.     }finally{ 
  6.         isBatchingEventUpdates = false 
  7.     } 

批量更新简化成如上的样子,从上面我们可以看到,React通过开关isBatchingEventUpdates来控制是否启用批量更新。fn(a),事件上调用的是 handleTopLevel(bookKeeping) ,由于js是单线程的,我们真正在组件中写的事件处理函数,比如demo 的 handerClick实际执行是在handleTopLevel(bookKeeping)中执行的。所以如果我们在handerClick里面触发setState,那么就能读取到isBatchingEventUpdates = true这就是React的合成事件为什么具有批量更新的功能了。比如我们这么写

  1. state={number:0} 
  2. handerClick = () =>{ 
  3.     this.setState({number: this.state.number + 1   }) 
  4.     console.log(this.state.number) //0 
  5.     this.setState({number: this.state.number + 1   }) 
  6.     console.log(this.state.number) //0 
  7.     setTimeout(()=>{ 
  8.         this.setState({number: this.state.number + 1   }) 
  9.         console.log(this.state.number) //2 
  10.         this.setState({number: this.state.number + 1   }) 
  11.         console.log(this.state.number)// 3 
  12.     }) 

如上述所示,第一个setState和第二个setState在批量更新条件之内执行,所以打印不会是最新的值,但是如果是发生在setTimeout中,由于eventLoop 放在了下一次事件循环中执行,此时 batchedEventUpdates 中已经执行完isBatchingEventUpdates = false,所以批量更新被打破,我们就可以直接访问到最新变化的值了。

接下来我们有两点没有梳理:

  • 一是React事件池概念
  • 二是最后的线索是执行handleTopLevel(bookKeeping),那么handleTopLevel到底做了写什么。

执行事件插件函数

上面说到整个事件系统,最后指向函数 handleTopLevel(bookKeeping) 那么 handleTopLevel 到底做了什么事情?

  1. // 流程简化后 
  2. // topLevelType - click   
  3. // targetInst - button Fiber 
  4. // nativeEvent 
  5. function handleTopLevel(bookKeeping){ 
  6.     const { topLevelType,targetInst,nativeEvent,eventTarget, eventSystemFlags} = bookKeeping 
  7.     for(let i=0; i < plugins.length;i++ ){ 
  8.         const possiblePlugin = plugins[i]; 
  9.         /* 找到对应的事件插件,形成对应的合成event,形成事件执行队列  */ 
  10.         const  extractedEvents = possiblePlugin.extractEvents(topLevelType,targetInst,nativeEvent,eventTarget,eventSystemFlags)   
  11.     } 
  12.     if (extractedEvents) { 
  13.         events = accumulateInto(events, extractedEvents); 
  14.     } 
  15.     /* 执行事件处理函数 */ 
  16.     runEventsInBatch(events); 

我把整个流程简化,只保留了核心的流程,handleTopLevel最后的处理逻辑就是执行我们说的事件处理插件(SimpleEventPlugin)中的处理函数extractEvents,比如我们demo中的点击事件 onClick 最终走的就是 SimpleEventPlugin 中的 extractEvents 函数,那么React为什么这么做呢? 我们知道我们React是采取事件合成,事件统一绑定,并且我们写在组件中的事件处理函数( handerClick ),也不是真正的执行函数dispatchAciton,那么我们在handerClick的事件对象 event,也是React单独合成处理的,里面单独封装了比如 stopPropagation和preventDefault等方法,这样的好处是,我们不需要跨浏览器单独处理兼容问题,交给React底层统一处理。

extractEvents 形成事件对象event 和 事件处理函数队列

重点来了!重点来了!重点来了!,extractEvents 可以作为整个事件系统核心函数,我们先回到最初的demo,如果我们这么写,那么四个回调函数,那么点击按钮,四个事件是如何处理的呢。首先如果点击按钮,最终走的就是extractEvents函数,一探究竟这个函数。

  1. legacy-events/SyntheticEvent.js 
  1. const  SimpleEventPlugin = { 
  2.     extractEvents:function(topLevelType,targetInst,nativeEvent,nativeEventTarget){ 
  3.         const dispatchConfig = topLevelEventsToDispatchConfig.get(topLevelType); 
  4.         if (!dispatchConfig) { 
  5.             return null
  6.         } 
  7.         switch(topLevelType){ 
  8.             default
  9.             EventConstructor = SyntheticEvent; 
  10.             break; 
  11.         } 
  12.         /* 产生事件源对象 */ 
  13.         const event = EventConstructor.getPooled(dispatchConfig,targetInst,nativeEvent,nativeEventTarget) 
  14.         const phasedRegistrationNames = event.dispatchConfig.phasedRegistrationNames; 
  15.         const dispatchListeners = []; 
  16.         const {bubbled, captured} = phasedRegistrationNames; /* onClick / onClickCapture */ 
  17.         const dispatchInstances = []; 
  18.         /* 从事件源开始逐渐向上,查找dom元素类型HostComponent对应的fiber ,收集上面的React合成事件,onClick / onClickCapture  */ 
  19.          while (instance !== null) { 
  20.               const {stateNode, tag} = instance; 
  21.               if (tag === HostComponent && stateNode !== null) { /* DOM 元素 */ 
  22.                    const currentTarget = stateNode; 
  23.                    if (captured !== null) { /* 事件捕获 */ 
  24.                         /* 在事件捕获阶段,真正的事件处理函数 */ 
  25.                         const captureListener = getListener(instance, captured); 
  26.                         if (captureListener != null) { 
  27.                         /* 对应发生在事件捕获阶段的处理函数,逻辑是将执行函数unshift添加到队列的最前面 */ 
  28.                             dispatchListeners.unshift(captureListener); 
  29.                             dispatchInstances.unshift(instance); 
  30.                             dispatchCurrentTargets.unshift(currentTarget); 
  31.                         } 
  32.                     } 
  33.                     if (bubbled !== null) { /* 事件冒泡 */ 
  34.                         /* 事件冒泡阶段,真正的事件处理函数,逻辑是将执行函数push到执行队列的最后面 */ 
  35.                         const bubbleListener = getListener(instance, bubbled); 
  36.                         if (bubbleListener != null) { 
  37.                             dispatchListeners.push(bubbleListener); 
  38.                             dispatchInstances.push(instance); 
  39.                             dispatchCurrentTargets.push(currentTarget); 
  40.                         } 
  41.                     } 
  42.               } 
  43.               instance = instance.return
  44.          } 
  45.           if (dispatchListeners.length > 0) { 
  46.               /* 将函数执行队列,挂到事件对象event上 */ 
  47.             event._dispatchListeners = dispatchListeners; 
  48.             event._dispatchInstances = dispatchInstances; 
  49.             event._dispatchCurrentTargets = dispatchCurrentTargets; 
  50.          } 
  51.         return event 
  52.     } 

事件插件系统的核心extractEvents主要做的事是:

① 首先形成React事件独有的合成事件源对象,这个对象,保存了整个事件的信息。将作为参数传递给真正的事件处理函数(handerClick)。

② 然后声明事件执行队列 ,按照冒泡和捕获逻辑,从事件源开始逐渐向上,查找dom元素类型HostComponent对应的fiber ,收集上面的 React 合成事件,例如 onClick / onClickCapture ,对于冒泡阶段的事件(onClick),将 push 到执行队列后面 , 对于捕获阶段的事件(onClickCapture),将 unShift到执行队列的前面。

③ 最后将事件执行队列,保存到React事件源对象上。等待执行。

举个例子比如如下

  1. handerClick = () => console.log(1) 
  2. handerClick1 = () => console.log(2) 
  3. handerClick2 = () => console.log(3)  
  4. handerClick3= () => console.log(4) 
  5. render(){ 
  6.     return <div onClick={ this.handerClick2 } onClickCapture={this.handerClick3}  >  
  7.         <button onClick={ this.handerClick }  onClickCapture={ this.handerClick1  }  className="button" >点击</button> 
  8.     </div> 

看到这里我们应该知道上述函数打印顺序为什么了吧,首先遍历 button 对应的fiber,首先遇到了 onClickCapture ,将 handerClick1 放到了数组最前面,然后又把onClick对应handerClick的放到数组的最后面,形成的结构是[ handerClick1 , handerClick ] , 然后向上遍历,遇到了div对应fiber,将onClickCapture对应的handerClick3放在了数组前面,将onClick对应的 handerClick2 放在了数组后面,形成的结构 [ handerClick3,handerClick1 , handerClick,handerClick2 ] ,所以执行的顺序 // 4 2 1 3,就是这么简单,完美!

FDEBA681-2E03-420B-A838-5907439837A9.jpg

事件触发

有的同学可能好奇React的事件源对象是什么样的,以上面代码中SyntheticEvent为例子我们一起来看看:

  1. legacy-events/SyntheticEvent.js/ 
  1. function SyntheticEvent( dispatchConfig,targetInst,nativeEvent,nativeEventTarget){ 
  2.   this.dispatchConfig = dispatchConfig; 
  3.   this._targetInst = targetInst; 
  4.   this.nativeEvent = nativeEvent; 
  5.   this._dispatchListeners = null
  6.   this._dispatchInstances = null
  7.   this._dispatchCurrentTargets = null
  8.   this.isPropagationStopped = () => false; /* 初始化,返回为false  */ 
  9.  
  10. SyntheticEvent.prototype={ 
  11.     stopPropagation(){ this.isPropagationStopped = () => true;  }, /* React单独处理,阻止事件冒泡函数 */ 
  12.     preventDefault(){ },  /* React单独处理,阻止事件捕获函数  */ 
  13.     ... 

在 handerClick 中打印 e :

B9180401-93FF-4EF0-A2FB-C2FA43B29550.jpg

既然事件执行队列和事件源对象都形成了,接下来就是最后一步事件触发了。上面大家有没有注意到一个函数runEventsInBatch,所有事件绑定函数,就是在这里触发的。让我们一起看看。

  1. legacy-events/EventBatching.js 
  1. function runEventsInBatch(){ 
  2.     const dispatchListeners = event._dispatchListeners; 
  3.     const dispatchInstances = event._dispatchInstances; 
  4.     if (Array.isArray(dispatchListeners)) { 
  5.     for (let i = 0; i < dispatchListeners.length; i++) { 
  6.       if (event.isPropagationStopped()) { /* 判断是否已经阻止事件冒泡 */ 
  7.         break; 
  8.       } 
  9.        
  10.       dispatchListeners[i](event) 
  11.     } 
  12.   } 
  13.   /* 执行完函数,置空两字段 */ 
  14.   event._dispatchListeners = null
  15.   event._dispatchInstances = null

dispatchListeners[i](event)就是执行我们的事件处理函数比如handerClick,从这里我们知道,我们在事件处理函数中,返回 false ,并不会阻止浏览器默认行为。

  1. handerClick(){ //并不能阻止浏览器默认行为。 
  2.     return false 

应该改成这样:

  1. handerClick(e){ 
  2.     e.preventDefault() 

另一方面React对于阻止冒泡,就是通过isPropagationStopped,判断是否已经阻止事件冒泡。如果我们在事件函数执行队列中,某一会函数中,调用e.stopPropagation(),就会赋值给isPropagationStopped=()=>true,当再执行 e.isPropagationStopped()就会返回 true ,接下来事件处理函数,就不会执行了。

其他概念-事件池

  1. handerClick = (e) => { 
  2.    console.log(e.target) // button  
  3.    setTimeout(()=>{ 
  4.        console.log(e.target) // null 
  5.    },0) 

对于一次点击事件的处理函数,在正常的函数执行上下文中打印e.target就指向了dom元素,但是在setTimeout中打印却是null,如果这不是React事件系统,两次打印的应该是一样的,但是为什么两次打印不一样呢?因为在React采取了一个事件池的概念,每次我们用的事件源对象,在事件函数执行之后,可以通过releaseTopLevelCallbackBookKeeping等方法将事件源对象释放到事件池中,这样的好处每次我们不必再创建事件源对象,可以从事件池中取出一个事件源对象进行复用,在事件处理函数执行完毕后,会释放事件源到事件池中,清空属性,这就是setTimeout中打印为什么是null的原因了。

事件触发总结

我把事件触发阶段做的事总结一下:

①首先通过统一的事件处理函数 dispatchEvent,进行批量更新batchUpdate。

②然后执行事件对应的处理插件中的extractEvents,合成事件源对象,每次React会从事件源开始,从上遍历类型为 hostComponent即 dom类型的fiber,判断props中是否有当前事件比如onClick,最终形成一个事件执行队列,React就是用这个队列,来模拟事件捕获->事件源->事件冒泡这一过程。

③最后通过runEventsInBatch执行事件队列,如果发现阻止冒泡,那么break跳出循环,最后重置事件源,放回到事件池中,完成整个流程。

evnent_click.jpg

五 关于react v17版本的事件系统

React v17 整体改动不是很大,但是事件系统的改动却不小,首先上述的很多执行函数,在v17版本不复存在了。我来简单描述一下v17事件系统的改版。

1 事件统一绑定container上,ReactDOM.render(app, container);而不是document上,这样好处是有利于微前端的,微前端一个前端系统中可能有多个应用,如果继续采取全部绑定在document上,那么可能多应用下会出现问题。

react_17_delegation.png

2 对齐原生浏览器事件

React 17中终于支持了原生捕获事件的支持, 对齐了浏览器原生标准。同时 onScroll 事件不再进行事件冒泡。onFocus 和 onBlur 使用原生 focusin, focusout 合成。

3 取消事件池React 17取消事件池复用,也就解决了上述在setTimeout打印,找不到e.target的问题。

六 总结

本文从事件合成,事件绑定,事件触发三个方面详细介绍了React事件系统原理,希望大家能通过这篇文章更加深入了解v16 React 事件系统,如果有疑问和不足之处,也希望大家能在评论区指出。

 

责任编辑:武晓燕 来源: 前端Sharing
相关推荐

2021-10-15 14:28:30

React 组件渲染

2021-08-30 19:04:29

jsIO

2024-09-18 13:57:15

2021-06-07 08:41:59

React异步组件

2024-08-09 08:41:14

2023-08-27 21:29:43

JVMFullGC调优

2022-08-15 17:34:22

react-routv6

2023-09-08 08:20:46

ThreadLoca多线程工具

2024-08-26 08:58:50

2020-02-21 14:35:57

JavaScript继承前端

2021-09-10 16:10:21

panda透视表语言

2021-07-08 10:08:03

DvaJS前端Dva

2021-12-16 14:45:09

https架构服务端

2024-07-12 14:46:20

2024-01-09 08:24:47

JMM核心线程

2021-01-13 05:21:59

参数

2024-10-11 09:27:52

2021-01-26 05:19:56

语言Go Context

2021-07-21 05:24:32

EventBus3.0Android单例模式

2020-11-24 07:48:32

React
点赞
收藏

51CTO技术栈公众号