探究React事件系统

205 阅读9分钟

什么是React事件系统

React官网 中介绍了合成事件对象以及为什么提供合成事件对象,主要原因是因为 React 想实现一个全浏览器的框架, 为了实现这种目标就需要提供全浏览器一致性的事件系统,以此抹平不同浏览器的差异

合成事件(SyntheticEvent)的意思就是使用原生事件合成一个 React 事件, 例如使用原生click事件合成了onClick事件,使用原生mouseout事件合成了onMouseLeave事件,原生事件和合成事件类型大部分都是一一对应,只有涉及到兼容性问题时我们才需要使用不对应的事件合成。

例如我们经常在代码中写的这种代码:

<button onClick={handleClick}>
  btna
</button>

这个onClick只是一个合成事件而不是原生事件,上面的代码看起来很简洁,实际上 React 事件系统工作机制比起上面要复杂的多,其工作原理大体上分为两个阶段:

  1. 事件绑定
  2. 事件触发

下面就一起来看下这两个阶段究竟是如何工作的, 这里主要从源码层分析,并以 16.13 源码中内容为基准。

1. React事件初始化阶段

React 既然提供了合成事件,就需要知道合成事件与原生事件是如何对应起来的,这个对应关系存放在 React 事件插件中EventPlugin, 事件插件可以认为是 React 将不同的合成事件处理函数封装成了一个模块,每个模块只处理自己对应的合成事件,这样不同类型的事件种类就可以在代码上解耦,例如针对onChange事件有一个单独的ChangeEventPlugin插件来处理,针对onMouseEnteronMouseLeave使用EnterLeaveEventPlugin插件来处理。

为了知道合成事件与原生事件的对应关系,React 在一开始就将事件插件全部加载进来,代码如下:

injectEventPluginsByName({
    SimpleEventPlugin: SimpleEventPlugin,
    EnterLeaveEventPlugin: EnterLeaveEventPlugin,
    ChangeEventPlugin: ChangeEventPlugin,
    SelectEventPlugin: SelectEventPlugin,
    BeforeInputEventPlugin: BeforeInputEventPlugin
});

注册完上述插件后,会初始化好一些全局对象,有几个对象比较重要,可以单独说一下。

registrationNameModule

它包含了 React 事件到它对应的 plugin 的映射, 大致长下面这样,它包含了 React 所支持的所有事件类型,这个对象最大的作用是判断一个组件的 prop 是否是事件类型,这在处理原生组件的 props 时候将会用到,如果一个 prop 在这个对象中才会被当做事件处理。

{
    onBlur: SimpleEventPlugin,
    onClick: SimpleEventPlugin,
    onClickCapture: SimpleEventPlugin,
    onChange: ChangeEventPlugin,
    onChangeCapture: ChangeEventPlugin,
    onMouseEnter: EnterLeaveEventPlugin,
    onMouseLeave: EnterLeaveEventPlugin,
    ...
}

registrationNameDependencies

这个对象长下面在这个样子

{
    onBlur: ['blur'],
    onClick: ['click'],
    onClickCapture: ['click'],
    onChange: ['blur', 'change', 'click', 'focus', 'input', 'keydown', 'keyup', 'selectionchange'],
    onMouseEnter: ['mouseout', 'mouseover'],
    onMouseLeave: ['mouseout', 'mouseover'],
    ...
}

这个对象即是一开始我们说到的合成事件到原生事件的映射,对于onClick , 只依赖原生click事件。但是对于 onMouseLeave它却是依赖了两个mouseout, mouseover, 这说明这个事件是 React 使用 mouseout 和 mouseover 模拟合成的。正是因为这种行为,使得 React 能够合成一些哪怕浏览器不支持的事件供我们代码里使用。

plugins

plugins = [SimpleEventPlugin, EnterLeaveEventPlugin, ...];

看完上面这些信息后我们再反过头来看下一个普通的EventPlugin长什么样子。一个 plugin 就是一个对象, 这个对象包含了下面两个属性,以SimpleEventPlugin为例:

const SimpleEventPlugin = {
    eventTypes:{
        'click':{...},
        'blur':{...},
        ...
    }
    extractEvents:function(...){}
}

eventTypes是一个对象,保存了原生事件名和对应的配置项的映射关系

extractEvents是一个函数,当原生事件触发时执行这个函数,该方法接受事件名称、原生DOM事件对象、事件触发的DOM元素以及React组件实例, 返回一个合成事件对象,后面会详解执行过程。

在初始化合成事件阶段主要形成了上述的几个重要对象,构建初始化React合成事件和原生事件的对应关系,合成事件和对应的事件处理插件关系。

2. React事件绑定

我们已经知道react 的所有事件并没有绑定到具体的dom节点上而是绑定在了document 上,然后由统一的事件处理程序来处理,同时也是基于浏览器的事件机制(冒泡),所有节点的事件都会在 document 上触发。

了解了前面所说的内容,下面开始进入事件绑定阶段:

  1. React 执行 diff 操作,标记出哪些 DOM 类型 的节点需要添加或者更新。 image1.png
  2. 当检测到需要创建一个节点或者更新一个节点时, 使用 registrationNameModule 查看一个 prop 是不是一个事件类型,如果是则执行下一步。 image2.png
  3. 通过 registrationNameDependencies 检查这个 React 事件依赖了哪些原生事件类型。 image3.png
  4. 检查这些一个或多个原生事件类型有没有注册过,如果有则忽略。 image4.png
  5. 如果这个原生事件类型没有注册过,则注册这个原生事件到 document 上,回调为React提供的dispatchEvent函数。 image5.png

在事件绑定阶段,根据事件名分了大三类,DiscreteEvent,UserBlockingEvent,ContinuousEvent,对应选出不同的事件派发器。这些事件最终执行的dispatchEvent方法。

  1. DiscreteEvent 离散事件. 例如blur、focus、 click、 submit、 touchStart. 这些事件都是离散触发的。
  2. UserBlockingEvent用户阻塞事件. 例如touchMove、mouseMove、scroll、drag、dragOver等等。这些事件会'阻塞'用户的交互。
  3. ContinuousEvent 连续事件。例如load、error、loadStart、abort、animationEnd. 这个优先级最高,也就是说它们应该是立即同步执行的,这就是Continuous的意义,是持续地执行,不能被打断。

上面的阶段说明:

  1. 我们将所有事件类型都注册到document上。
  2. 所有原生事件的 listener 最终执行的是dispatchEvent函数。
  3. 同一个类型的事件 React 只会绑定一次原生事件,例如无论我们写了多少个onClick, 最终反应在 DOM 事件上只会有一个listener
  4. React 并没有将我们业务逻辑里的listener绑在原生事件上,也没有去维护一个类似eventlistenermap的东西存放我们的listener(React 在初始化真实 dom 的时候,用一个随机的 key 指针指向了当前 dom 对应的 fiber 对象,fiber 对象用 stateNode 指向了当前的 dom 元素。也就是 dom 和 fiber 对象它们是相互关联起来的)。

由 3,4 条规则可以得出,我们业务逻辑的listener和实际 DOM 事件压根就没关系,React 只是会确保这个原生事件能够被它自己捕捉到,后续由 React 来派发我们的事件回调,当我们页面发生较大的切换时候,React 可以什么都不做,从而免去了去操作removeEventListener或者同步eventlistenermap的操作,所以其执行效率将会大大提高,相当于全局给我们做了一次事件委托,即便是渲染大列表,也不用开发者关心事件绑定问题。

3.React事件触发

我们知道由于所有类型种类的事件最后都是执行为React的 dispatchEvent 函数,所以就能在全局处理一些通用行为,整个触发事件流程如下:

  1. 任意一个事件触发,执行dispatchEvent函数,最后会执行handleTopLevel函数。
  2. handleTopLevel会依次执行plugins里所有的事件插件。
  3. 如果一个插件检测到自己需要处理的事件类型时,则处理该事件。

主函数:

function handleTopLevel(bookKeeping) {
 // ...
  for (var i = 0; i < bookKeeping.ancestors.length; i++) {
    // ...
    runExtractedPluginEventsInBatch(topLevelType, targetInst, nativeEvent, eventTarget, eventSystemFlags);
  }
}

事件批处理函数:

function runExtractedPluginEventsInBatch(topLevelType, targetInst, nativeEvent, nativeEventTarget, eventSystemFlags) {
  var events = extractPluginEvents(topLevelType, targetInst, nativeEvent, nativeEventTarget, eventSystemFlags);
  runEventsInBatch(events);
}

找到对应的事件插件,形成对应的合成event,形成事件执行队列:

function extractPluginEvents(topLevelType, targetInst, nativeEvent, nativeEventTarget, eventSystemFlags) {
  var events = null;
  for (var i = 0; i < plugins.length; i++) {
    var possiblePlugin = plugins[i];
    if (possiblePlugin) {
      var extractedEvents = possiblePlugin.extractEvents(topLevelType, targetInst, nativeEvent, nativeEventTarget, eventSystemFlags);
      if (extractedEvents) {
        events = accumulateInto(events, extractedEvents);
      }
    }
  }
  return events;
}
function runEventsInBatch(events) {
  // ...
}

对于大部分事件而言其处理逻辑如下,也即 SimpleEventPlugin 插件做的工作如下:

  1. 通过原生事件类型决定使用哪个合成事件类型(原生 event 的封装对象,例如 SyntheticMouseEvent) 。
  2. 如果对象池里有这个类型的实例,则取出这个实例,覆盖其属性,作为本次派发的事件对象(事件对象复用),若没有则新建一个实例。 image6.png
  3. 从点击的原生事件中找到对应 DOM 节点,从 DOM 节点中找到一个最近的React组件实例, 从而找到了一条由这个实例父节点不断向上组成的链, 这个链就是我们要触发合成事件的链,(只包含原生类型组件, div, a 这种原生组件),也用来模拟合成事件的事件冒泡和事件捕获。 image7.png

同时,因为合成事件的委托机制,对于事件冒泡,合成事件不能阻止原生事件,原生事件可以阻止合成事件,因为合成事件都委托在document上,原生事件是一定优先于合成事件触发

以下是SimpleEventPlugin插件源码:

var SimpleEventPlugin = {
    eventTypes: simpleEventPluginEventTypes,
    extractEvents: function (topLevelType, targetInst, nativeEvent, nativeEventTarget, eventSystemFlags) {
      var dispatchConfig = topLevelEventsToDispatchConfig.get(topLevelType);
      var EventConstructor;

      switch (topLevelType) {
        case TOP_KEY_PRESS:
          // Firefox creates a keypress event for function keys too. This removes
          // the unwanted keypress events. Enter is however both printable and
          // non-printable. One would expect Tab to be as well (but it isn't).
          if (getEventCharCode(nativeEvent) === 0) {
            return null;
          }

        case TOP_KEY_DOWN:
        case TOP_KEY_UP:
          EventConstructor = SyntheticKeyboardEvent;
          break;

        case TOP_BLUR:
        case TOP_FOCUS:
          EventConstructor = SyntheticFocusEvent;
          break;

        // ...

        default:
          EventConstructor = SyntheticEvent;
          break;
      }

      var event = EventConstructor.getPooled(dispatchConfig, targetInst, nativeEvent, nativeEventTarget);
      accumulateTwoPhaseDispatches(event);
      return event;
    }
  };

可以看到该插件主要做的首先是根据事件的类型确定SyntheticEvent构造器,然后构造SyntheticEvent的event对象,作为结果返回。

什么是EventPool事件池

为了避免频繁创建和释放事件对象导致性能损耗(对象创建和垃圾回收),React使用一个事件池来负责管理事件对象,使用完的事件对象会放回池中,以备后续的复用。

调用合成事件SyntheticEvent类的getPooled是创建事件对象的唯一方式,getPooled执行的是getPooledEvent函数。源码如下:

function addEventPoolingTo(EventConstructor) {
    EventConstructor.eventPool = [];
    EventConstructor.getPooled = getPooledEvent;
    EventConstructor.release = releasePooledEvent;
}
function getPooledEvent(dispatchConfig, targetInst, nativeEvent, nativeInst) {
    var EventConstructor = this;

    if (EventConstructor.eventPool.length) {
      var instance = EventConstructor.eventPool.pop();
      EventConstructor.call(instance, dispatchConfig, targetInst, nativeEvent, nativeInst);
      return instance;
    }

    return new EventConstructor(dispatchConfig, targetInst, nativeEvent, nativeInst);
}

可复用的事件实例被新的构造参数覆盖复用

EventPool事件池何时被填充呢?填充过程是在执行完事件之后,析构函数destructor会重置event部分属性为null,相关源码如下:

批量执行事件过程(也就是前面所提到handleTopLevel下的批量执行函数)如下:

var executeDispatchesAndRelease = function (event) {
    if (event) {
      executeDispatchesInOrder(event);

      if (!event.isPersistent()) {
        event.constructor.release(event);
      }
    }
};

释放事件池对象,初始化事件对象属性为null:

function releasePooledEvent(event) {
    var EventConstructor = this;

    event.destructor();

    if (EventConstructor.eventPool.length < EVENT_POOL_SIZE) {
      EventConstructor.eventPool.push(event);
    }
}

这也意味着,在事件处理器同步执行完后,SyntheticEvent对象就会马上被回收,所有属性都会无效。所以会有个现象就是无法在事件监听函数外使用事件对象,因为已经被清空了,看下面代码:

function onClick(e) {
    console.log('a:', e.target)
	setTimeout(() => {
		console.log('b:', e.target);
	}, 100);
}

由以上所述可知a输出正常内容,b输出为null。

如果你需要在事件处理函数运行之后获取事件对象的属性,你需要调用 e.persist().

从以上几个阶段说明了下面的现象:

  1. React 的合成事件只能在事件周期内使用,因为这个对象很可能被其他阶段复用, 如果想持久化需要手动调用event.persist() 告诉 React 这个对象需要持久化。( React17 中被废弃)
  2. React 的冒泡和捕获并不是真正 DOM 级别的冒泡和捕获
  3. 事件只针对原生组件生效,自定义组件不会触发 onClick。

4.整体认识

我们现在可以轻松理解 React 事件系统的架构图了 image8.png

5.React 17 中事件系统有哪些新特性

React 17 目前已经发布了, 官方称之为没有新特性的更新,修复了之前存在的诸多缺陷,其中变化最大的就数对事件系统的改造了。

1. 调整将顶层事件绑在container上,ReactDOM.render(app, container)

image9.png

将顶层事件绑定在 container 上而不是 document 上能够解决遇到的多版本共存问题。

2.取消事件复用

官方的解释是事件对象的复用在现代浏览器上性能已经提高的不明显了,反而还很容易让人用错,所以干脆就放弃这个优化。