「React深入」一文吃透React v16事件系统

3,801 阅读13分钟

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

大家好,我是小杜杜,我们知道React自身提供了一套虚拟的事件系统,通过上篇的学习,我们已经知道React事件系统与原生系统有哪些不同,接下来让我们继续探索React v16究竟是如何进行事件绑定,如何触发事件的呢。

关于React事件系统将会分三个方面去讲解,分别是:React事件系统与原生事件系统深入React v16事件系统对比Reactv16~v18事件系统 三个模块,有感兴趣的可以关注下新的专栏:React 深入进阶,一起进阶学习~

在正式开始介绍前,请先看看以下问题:

  • React是如何绑定事件的,又是如何触发事件的?
  • 为什么绑定onChange事件后,document会多出很多监听器?
  • onChange是对应的原生事件的change吗?
  • 什么是事件池?它又是如何进行工作的?
  • 什么是事件插件机制,合成事件和原生事件都是一一对应的吗?
  • 如何进行批量更新的?
  • 为什么在不使用箭头函数的情况下,要通过bind绑定this
  • React中的捕获事件,走的真是捕获阶段吗?
  • .....

如果你对以上问题有疑问,那么相信看完本章后,你的疑问会全部解决。

一起来看看今天的知识图谱: 深入React事件系统.png

注: 本文基于 react v16.13.1 源码

前置知识

在正式开始前,我们先来讲讲事件池的概念,由于事件池的概念比较多,为防止后续看文章的体验,单独拿出来讲,你也可以先阅读,等看到事件池这部分的内容,再回过来看,感觉会不一样哦~

事件池

什么是事件池?

在上篇讲过,React为了避免垃圾回收,因而引入了事件池的概念,从而防止事件会被频繁的创建和回收

从本质上来讲,事件池React提供的一种优化方式,将所有的合成事件都放到事件池内统一管理,同时不同类型的合成事件对应不同的事件池

事件池是如何工作的?

在点击事件中,实际上会调用SimpleEventPluginextractEvents函数(源码位置packages\react-dom\src\events\SimpleEventPlugin.js),来看看event,如:

    const event = EventConstructor.getPooled(
      dispatchConfig,
      targetInst,
      nativeEvent,
      nativeEventTarget,
    );

EventConstructor.getPooledpackages/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应该一样,然而实际效果为:

img.gif

这是因为每次在派发事件中,React都会从事件池中判断,是否能够复用,当派发完成时,就会将函数的属性置成null,也就是会清空对应的属性,所以setTimeout会打印出null的原因

那么,我们如何在setTimeout拿到e呢?如何保证对象的持久化呢?

可以使用e.persistent(),效果:

image.png

这是因为执行e.persistent()函数,React不会执行EventConstructor.release方法。

换言之,此时的onChange并没走事件池,也不会进行销毁,因此会保留e的值

React是如何绑定事件的?

我们知道React中的所有都模拟的,甚至事件源也是虚拟的,那么React是如何将模拟的事件进行绑定的呢?

首先我们需要知道事件插件这个概念,接下来一起看看

事件插件机制

React中,所有的事件都是通过插件来进行统一处理,但并非是同一个插件,因为每个事件的处理逻辑、事件源都不同,所以会有多个事件插件。

如:onClick对应SimpleEventPluginonChange对应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对应捕获阶段
  • registrationNameprops事件注册名称,并不是所有的事件都具有冒泡事件的(比如:onMouseEnter),如果不支持冒泡的话,只会有registrationName,而不会有phasedRegistrationNames
  • eventPriority:用来处理事件的优先级,本文暂不介绍(之后可能从fiber中进行介绍)

click为例,实际就为:

`click`:{
    dependencies: ['click'],
    phasedRegistrationNames:{ 
        bubbled: 'onClick', 
        captured:'onClickCapture'
    },
 }

插件的实例

为了更好的理解,我们可以具体看看这些插件的实例,这里主要介绍下比较典型的三个插件

SimpleEventPlugin

image.png

SimpleEventPlugin:这个插件比较通用,大多数方法都是通过此插件处理,如:clickinputfocus等,与原生事件一一对应,所以这类事件比较好处理

EnterLeaveEventPlugin

image.png

EnterLeaveEventPlugin:从上图可见,onMouseEnter是依靠mouseoutmouseover事件,这样可以在document上面进行委托监听,还可以有效的避免一些奇怪、不实用的行为

ChangeEventPlugin

image.png

ChangeEventPlugin: 在React中,onChange比较特殊,它是React的一个自定义的事件,它依赖8种原生事件来模拟onChange事件

事件绑定

回归正题,看看在React中到底是如何进行绑定的

通过「React 深入」React事件系统与原生事件系统究竟有何区别? 我们知道事件最终保存在fiber中的memoizedProps 和 pendingProps

image.png

之后会调用legacyListenToEvent函数

legacyListenToEvent

legacyListenToEvent:用来注册事件监听器,要注意,事件必须是合成事件,如onClick

image.png

  • registrationName:合成事件名
  • dependencies:合成事件所依赖的事件组

可以看出,会根据合成事件(onClick)去匹配对应的原生事件(click

之后就会走向legacyListenToTopLevelEvent函数,而这个函数的目的判断事件是否进行冒泡处理

为什么绑定onChange事件后会多出很多监听器?

用同样的方法,我们来看看onChange绑定后是什么样的

img1.gif

可以发现,onChange的依赖组有: ['blur', 'change', 'click', 'focus', 'input', 'keydown', 'keyup', 'selectionchange']

然后会依次对这个数组进行遍历,进行绑定,所以当我们绑定onChange事件后会在document上多出那么多事件的原因

image.png

legacyListenToTopLevelEvent

对于大多数事件,都会走冒泡阶段,但事无绝对,并不是所有的事件都会走,一些特殊的事件,是按照捕获来处理的,比如:onScroll

image.png

trapCapturedEvent(TOP_SCROLL, mountAt)就是处理事件捕获的

绑定dispatchEvent

接下来也就是最重要的一步,就是如何绑定到document的,接下来一起看看

接下来会走trapEventForPluginEventSystem函数,如: image.png

然后会判断对应的类型,通过对应的事件对listerner进行赋值,(如click对应的为dispatchDiscreteEvent

image.png

然后判断是否为捕获,分别进行绑定

实际上,所有的事件都绑定在dispatchEvent

addEventBubbleListener

  • addEventBubbleListener:处理冒泡
  • addEventCaptureListener:处理捕获

image.png

  • element: 对应 document
  • eventType:对应的事件,此处为click
  • listener:对应监听的函数

至此事件绑定的内容就完了,接下来我们一起看看事件究竟是如何触发的。

React是如何触发事件的?

触发dispatchEvent

当我们的事件注册完成后,会有一个统一管理的函数,也就是dispatchEvent

所以当我们触发onClick后,首先走的也是dispatchEvent,如:

image.png

前三个参数不必多说,来看看第四个参数nativeEvent:

image.png

这个参数实际上是真正的事件源对象:event

attemptToDispatchEvent

然后会走到attemptToDispatchEvent这个函数,它会尝试去调度事件,如:

image.png

  • 首先会根据事件源找到真正的DOM元素,也就是nativeEventTarget
  • 其次会根据这个元素找到与之对应的fiber(也就是buttonfiber),给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这个函数

image.png

  • 首先,会根据getTopLevelCallbackBookKeeping函数找到事件池中对应的属性,赋予给事件,可以先看看bookKeeping:

image.png

  • 然后会通过batchedEventUpdates来处理批量更新
  • 最终通过releaseTopLevelCallbackBookKeeping来释放事件池

我们提出了一个概念叫事件池,那么事件池又是是什么,可以看看前置的知识~

batchedEventUpdates 批量更新

batchedEventUpdates函数:

image.png

实际上是通过isBatchingEventUpdates来控制是否进行批量更新

而实际处理批量更新的函数是fn也就是handleTopLevel这个函数

image.png

调用的函数最终是在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这个函数

image.png

当我们找到对应的插件SimpleEventPlugin后,会调用他的extractEvents函数,将对应的事件都放在了extractEvents 下,这样的好处主要是处理兼容性,不需要考虑浏览器,而是由React统一处理

extractEvents

以点击为例,所以看看SimpleEventPluginextractEvents

image.png

简单的说就是经历了一系列的匹配,最终将调用EventConstructor.getPooled拿到对应的事件源(合成对象),之后将事件源传递给了accumulateTwoPhaseDispatches

image.png

然后进行遍历,也就是traverseTwoPhase,遍历的方法为:

image.png

_targetInst 为起始点开始遍历

  • 捕获阶段:由顶层开始向下传播
  • 冒泡阶段:有 _targetInst 开始,向上传播

再来看看看accumulateDirectionalDispatches

image.png

这个函数的作用是查询当前节点,是否存在对应的事件处理器

栗子🌰

我们来做个简单的测试:

    return (
      <div
        onClick={() => console.log('3')}
        onClickCapture={() => console.log('4')}
      >
        <button
          onClick={() => console.log('1')}
          onClickCapture={() => console.log('2')}
        >
          点击
        </button>
      </div>
    )

结果:

image.png

  • 首先,会找到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

image.png

最终会进入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),实际上,onClickCaptureonClick一样,都是走的冒泡阶段,而捕获阶段毕竟是少数,如:onScrollonBluronFocus

事件触发总结:

  • 所有的事件首先通过dispatchEvent函数处理,然后进行批量更新
  • 然后根据事件源找到与之匹配的DOM元素fiber,进入插件中的extractEvents,然后遍历,得到最终的一个队列,这个队列就是React用来模拟的事件过程
  • 最终走向runEventsInBatch,进行批量执行事件队列,完成整个触发流程。如果发现有阻止冒泡的情况,则会跳出循环,重置事件源,再放回到事件池中,完成流程

相关文章

结语

总的来说,React中的事件主要分为绑定和触发两个模块,在 v16中还是有很多的概念,建议大家多多看看源码,亲自走一走,这样印象会深一点,同时也存在着一些误区,可能并不是你一开始理解的那样,如有不对的地方还请指出~

那么关于React事件系统还剩最后一章,v17v18又对事件系统做了哪些更改?相比于v16变了哪些部分?流程上做了哪些更改?

感兴趣的可以关注下这个专栏,这个专栏会以进阶为目的,详细讲解React相关的原理、源码、实战,有感兴趣的可以关注下,一起学习,一起进步~