Un artículo que lo lleva a una comprensión integral del sistema de eventos de React

1. ¿Qué es un evento sintético?

En React, si queremos vincular un evento a una divetiqueta , le agregaremos un onClickatributo y aceptará una función de devolución de llamada. Cuando activamos un evento de clic, solo se ejecuta esta función de devolución de llamada. De hecho, esto onClickes lo que llamar a un evento sintético Su evento de sonido original correspondiente es click, similar a él, hay onChangeeventos , sus eventos originales correspondientes son blur, change, input, keydown, y así sucesivamente keyup.

Ahora que sabemos que los eventos sintéticos de React en realidad corresponden a uno o más eventos nativos, ¿el método de enlace de los eventos sintéticos es el mismo que el de los eventos nativos? Primero observe cómo se vincula el evento nativo:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <div onclick="handleClick()">click</div>
</body>
</html>

<script>
    function handleClick() {
        console.log('我被点击了~')
    }
</script>
复制代码

divVinculamos un clickevento y vemos cómo se refleja en el DOM.

image.pngAquí podemos ver que nuestro evento sí está vinculado al DOM real, veamos cómo se refleja en React:

import React from 'react';
function App() {
  const handleClick = () => {
    console.log("我被点击了~")
  }
  return (
    <div className="App">
     <span onClick={handleClick}>click</span>
    </div>
  );
}
export default App;
复制代码

Aquí spanvinculamos un onClickevento a image.pngFinalmente , encontraremos que el controlador es en realidad una función vacía, entonces, ¿a dónde fue nuestro evento? Los estudiantes cuidadosos encontrarán que nuestros eventos ya están vinculados a doucumenton , y cuando eliminamos el onClickevento, doucumentel clickevento también desaparece, por lo que podemos llegar a la conclusión de que los eventos sintéticos de React eventualmente estarán vinculados a doucument.

2. ¿Por qué React realiza eventos sintéticos?

Al ver esto, tengo una pregunta. Dado que el efecto de los eventos sintéticos y los eventos nativos es similar, es decir, la función de devolución de llamada se puede llamar cuando el usuario activa el evento correspondiente, entonces, ¿por qué React gasta tanta energía para escribir un nuevo evento? ¿sistema? Creo que hay varias razones:

  1. Suaviza las diferencias entre diferentes navegadores

React 作为一个优秀的前端框架,首先当然需要去兼容不同的浏览器,但是不同的浏览器的事件系统可能会存在一定的差异,比如 event 对象在 IE 和 chrome 是不同的,所以 React 为了能够达到兼容的目的就需要去抹平这一差异,这样对于开发者来说我们只需要使用 React 给我们提供的事件而不需要去担心不同浏览器的兼容问题。

  1. 避免频繁的对 DOM 进行事件的绑定和解绑

在原生事件当中我们有事件委托的概念,即通过给父元素绑定事件去避免给每一个子元素都绑定相同的事件,在 React 中也很好的践行了这一理念,只不过他不是在父元素上去绑定事件而是在 doucument 上去绑定,这样我们在 diff 算法中对新增/删除的节点就不需要频繁的去调用 addEventListenerremoveEventListener,造成性能的浪费,我们只需要在document上做监听,然后根据 event 中的 target 来判断事件触发的结点。

  1. 避免频繁的创建和销毁事件对象

React 采用了事件池的思想,每次执行事件函数时可以对事件池中的事件对象进行复用,在事件处理函数执行完毕之后会清空事件对象上相应的属性,也就达到避免频繁的创建和销毁事件对象的目的。

  1. 批量更新

React 批量更新在16和17中采用的是不同的机制,在 Ract16 中采用的是 transction 事件机制,而在 React17 中采用的是 lane 模型,下面以展示的是 React16 的批量更新机制:

batchedEventUpdates(fn,a){ 
    isBatchingEventUpdates = true; 
    try{ 
        fn(a)
    }finally{ 
        isBatchingEventUpdates = false 
    } 
}
复制代码

React 通过变量 isBatchingEventUpdates 来决定当前是否处于批量更新的流程中,而我们的时间函数最终也会在 batchedEventUpdates 中被执行,当我们在事件函数中执行 setState 时就会走批量更新的流程,从而避免多次渲染的问题。这里稍微拓展下,假如我的事件函数长这样,会出现什么问题呢?

class App extends React.Component {
    constructor(){
        super();
        this.state = {
            count:0
        }
    }
    render() {
        return (
            <div onClick={
                () => {
                    this.setState({count:1}) // 0
                    console.log(this.state.count);
                    this.setState({count:2}) // 0
                    console.log(this.state.count);
                    setTimeout(() => {
                        this.setState({count:3}) // 3
                        console.log(this.state.count);
                        this.setState({count:4}) // 4
                        console.log(this.state.count);
                    })
                }
            }>
                click
            </div>
        )
    }
}
复制代码

当我们在 setTimeout 中执行 setState 则不会进入批量更新的流程,因为 transcation 机制是同步的,而 setTimeout 是异步执行的当执行回调函数时已经不在 transcation 的管辖范围之内了,这个问题在 React17 中得到了解决,因为在 React17 中使用了 lane 模型,他里面规定假如两次更新的 lane 值相同那么就会将这两个更新合并成一次,那么什么情况下 lane 会相同呢?当这些更新都在同一个事件函数中被触发时他们的 lane 是相同的,所以上面输出应该是 0 0 2 2,这只是一种情况,后面会出一篇关于 lane 模型的文章。

“当这些更新都在同一个事件函数中被触发时他们的 lane 是相同的”这句话说得不是很准确,因为不同的事件函数中的更新的 lane 也可能相同,比如我再添加一个 setTimeout,里面的更新也会和前一个 setTineout 的更新合并,这里要深入的话就得和时间切片联系起来,这里就不展开了。

三、事件绑定

以下源码来自 react-16.13.1

1、前置知识

在讲事件绑定之前我们首先要知道我们传给 span 的事件函数最终去哪了,先看我们的 App 组件:

function App() {
  const handleClick = () => {
    console.log("我被点击了~")
  }
  return (
    <div className="App">
     <span onClick={handleClick}>click</span>
    </div>
  );
}
复制代码

这里是我们的 JSX 代码,这些代码经过 babel 编译之后会变成什么,来看看结果:

image.png

经过babel转换后会变成React.createElement形式,这是 React16 的转换方式,在 React17 中引入了 JSX 运行时,也就是说我们写组件时不需要手动引入 React 了,React.createElement 的运行结果就是对应的 JSX 对象,如下图所示: image.png 我们的事件函数就保存在这个对象的 props 中,我们知道在 React16 中引入了 fiber 的概念,每一个节点对应一个 fiber,而这些 fiber 节点就是根据 React.createElement 生成的 JSX 对象创建而来的(diff 算法中的也是将 fiber 树和 JSX 对象进行对比),所以 props 上的数据最终会被保存进 fiber 节点,如图所示: image.png 现在我们知道事件函数最终会被保存进 pendingProps,那么我么可以想一想在赋值的时候我们能不能去检查一下 props 中的事件,如果是合成事件我们就可以直接给 document 去注册事件,没错 React 就是这么干的,这件事就发生在 React 的 completeWork 流程中,然后调用 legacyListenToEvent 方法去注册事件。

completeWork 是什么?

completeWork 是一个函数,他会在 render 阶段被执行,它的作用我们简单了解下就行:

1、为 fiber 节点生成对应的 DOM 节点,即 stateNode。

2、将他的子孙节点的 DOM 插入刚生成的 DOM 中。

3、处理 props 中的属性。

我们可以简单的看一下他们的调用栈

image.png

2、legacyListenToEvent 注册事件监听器

// registrationName -> onClick 事件 
// mountAt -> document
function legacyListenToEvent(registrationName, mountAt) {
  // 已经注册的事件 map 表
  var listenerMap = getListenerMapForElement(mountAt);
  // 根据 onClick 获取 onClick 依赖的事件数组 [ 'click' ],对于 onChange事件来说他的
  // 依赖数组为[`blur`、`change`、`input`、`keydown`、`keyup`]。
  var dependencies = registrationNameDependencies[registrationName];
  // 将每一个事件都注册到 document 上,如果已经注册了的就不用再注册了
  for (var i = 0; i < dependencies.length; i++) {
    var dependency = dependencies[i];
    legacyListenToTopLevelEvent(dependency, mountAt, listenerMap);
  }
}
复制代码

legacyListenToEvent函数中,先找到 React 合成事件对应的原生事件集合,比如 onClick -> ['click'] , onChange -> ['blur' , 'change' , 'input' , 'keydown' , 'keyup'],然后遍历依赖项的数组,绑定事件。

3、legacyListenToTopLevelEvent

function legacyListenToTopLevelEvent(topLevelType, mountAt, listenerMap) {
  if (!listenerMap.has(topLevelType)) {
    switch (topLevelType) {
      case TOP_SCROLL:
        // 绑定捕获事件
        trapCapturedEvent(TOP_SCROLL, mountAt);
        break;
      //.....省略
      default:
        var isMediaEvent = mediaEventTypes.indexOf(topLevelType) !== -1;
        if (!isMediaEvent) {
          // 绑定冒泡事件
          trapBubbledEvent(topLevelType, mountAt);
        }
        break;
    }
    listenerMap.set(topLevelType, null);
  }
}
复制代码

legacyListenToTopLevelEvent 函数中会根据 topLevelType 区分是捕获事件还是冒泡事件,像 click 这样的就属于冒泡事件,scroll、focus、blur等都属于捕获事件。

4、trapBubbledEvent / trapCapturedEvent

function trapBubbledEvent(topLevelType, element) {
  trapEventForPluginEventSystem(element, topLevelType, false);
}
function trapCapturedEvent(topLevelType, element) {
  trapEventForPluginEventSystem(element, topLevelType, true);
}
function trapEventForPluginEventSystem(container, topLevelType, capture) {
  var listener;
  // 根据事件的优先级调用不同的 dispatch 函数,但是最终都会去执行 dispatchEvent。
  switch (getEventPriorityForPluginSystem(topLevelType)) {
    case DiscreteEvent:
      listener = dispatchDiscreteEvent.bind(null, topLevelType, PLUGIN_EVENT_SYSTEM, container);
      break;

    case UserBlockingEvent:
      listener = dispatchUserBlockingUpdate.bind(null, topLevelType, PLUGIN_EVENT_SYSTEM, container);
      break;

    case ContinuousEvent:
    default:
      listener = dispatchEvent.bind(null, topLevelType, PLUGIN_EVENT_SYSTEM, container);
      break;
  }

  var rawEventName = getRawEventName(topLevelType);
  // 如果是捕获事件
  if (capture) {
    addEventCaptureListener(container, rawEventName, listener);
  } else {
    addEventBubbleListener(container, rawEventName, listener);
  }
}
复制代码

从这里我们知道 trapBubbledEventtrapCapturedEvent 最终都会调用 trapEventForPluginEventSystem 函数,在 trapEventForPluginEventSystem 中做了两件事,一是根据事件的优先级去生成不同的监听器,二是将监听器作为捕获或者冒泡事件绑定在 document 上,这样我们就完成了我们的事件绑定流程。

四、事件触发

1、前置知识

namesToPlugins

const namesToPlugins = { 
    SimpleEventPlugin, 
    EnterLeaveEventPlugin, 
    ChangeEventPlugin, 
    SelectEventPlugin, 
    BeforeInputEventPlugin,
}
复制代码

这些插件的作用就是为他对应的事件生成一个合成的事件对象,比如 click 事件就对应 SimpleEventPlugin

plugins

plugins 就是 namesToPlugins 的数组表示方式,方便后面调用。

const plugins = [LegacySimpleEventPlugin, LegacyEnterLeaveEventPlugin, ...];
复制代码

registrationNameModules

registrationNameModules 记录了React合成的事件对应的事件插件的关系。

{ 
    onBlur: SimpleEventPlugin, 
    onClick: SimpleEventPlugin, 
    onClickCapture: SimpleEventPlugin, 
    onChange: ChangeEventPlugin, 
    onChangeCapture: ChangeEventPlugin, 
    onMouseEnter: EnterLeaveEventPlugin, 
    ...
}
复制代码

上面已经介绍完事件的绑定流程,那当我们去点击触发 onClick 事件会发生什么呢?还是老规矩我们先看一下调用栈,了解整个执行流程:

image.png

2、dispatchEvent

当我们触发 onClick 事件时,click 事件将会最终冒泡至document,并触发我们监听在document 上的监听器 dispatchEventdispatchEvent 的前三个参数我们已经通过 bind 传进去了,此时我们只需要将第四个参数 nativeEvent 串进去就行,这个参数就是事件源的事件对象。

image.png 当前的 target 就是 span 元素。

3、attemptToDispatchEvent

function attemptToDispatchEvent(topLevelType, eventSystemFlags, container, nativeEvent) {
  // 从原生事件对象中获取 target
  var nativeEventTarget = getEventTarget(nativeEvent);
  // 获取 target 对应的 fiber 节点
  var targetInst = getClosestInstanceFromNode(nativeEventTarget);
  // .... 一些兼容处理
  dispatchEventForLegacyPluginEventSystem(topLevelType, eventSystemFlags, nativeEvent, targetInst);
}
复制代码

这个函数主要做了三件事:

  1. 从原生事件对象中获取对应的 target。
  2. 根据 target 获取该元素对应的 fiber 节点。
  3. 进入批量更新流程,也就是 batchedEventUpdates

4、runExtractedEventsInBatch

进入 batchedEventUpdates 流程后首先会调用他的回调函数, 即handleTopLevel 函数,在 handleTopLevel 函数中会调用 runExtractedPluginEventsInBatch,这是React事件处理最重要的函数,下面看看他做什么?

function runExtractedPluginEventsInBatch(topLevelType, targetInst, nativeEvent, nativeEventTarget, eventSystemFlags) {
  // 模拟捕获冒泡收集需要执行的回调函数
  var events = extractPluginEvents(topLevelType, targetInst, nativeEvent, nativeEventTarget, eventSystemFlags);
  // 遍历回调函数模拟捕获冒泡
  runEventsInBatch(events);
}
复制代码

这里主要介绍下 extractPluginEvents 函数的作用,在这个函数中主要他会调用我们的 plugin 生成被触发的事件的事件对象,然后按照冒泡的方式去收集对应的回调函数,比如说 SimpleEventPlugin 这个插件,他就会沿着 fiber 链向上遍历,遇到 onClickonClickCapture 等事件就会将他们的回调函数收集起来存放到事件对象中,即extractedEvents,最终将这些事件对象合并到 events 中。

function extractPluginEvents(topLevelType, targetInst, nativeEvent, nativeEventTarget, eventSystemFlags) {
  var events = null;
  // 遍历所有的 plugin
  for (var i = 0; i < plugins.length; i++) {
    var possiblePlugin = plugins[i];

    if (possiblePlugin) {
      // 调用 plugin 上的 extractEvents 方法生成对应的事件对象,并按照冒泡的形式也就是从当前 fiber 节点向上遍历去收集所属这个插件的同时会被触发的事件的回调函数
      var extractedEvents = possiblePlugin.extractEvents(topLevelType, targetInst, nativeEvent, nativeEventTarget, eventSystemFlags);

      if (extractedEvents) {
        // 将生成的事件对象合并到 events 中
        events = accumulateInto(events, extractedEvents);
      }
    }
  }
  return events;
}
复制代码

下面举一个例子:

import React from 'react';
function App() {
  const handleClick1 = () => {
    debugger;
    console.log("我被点击了1")
  }
  const handleClick2 = () => {
    debugger;
    console.log("我被点击了2")
  }
  const handleClick3 = () => {
    debugger;
    console.log("我被点击了3")
  }
  const handleClick4 = () => {
    debugger;
    console.log("我被点击了4")
  }
  const handleClick5 = () => {
    debugger;
    console.log("我被点击了5")
  }
  const handleClick6 = () => {
    debugger;
    console.log("我被点击了6")
  }
  return (
    <div className="App" onClick={handleClick6} onClickCapture={handleClick1}>
     <div onClick={handleClick5} onClickCapture={handleClick2}>
        <span onClick={handleClick4} onClickCapture={handleClick3}>click</span>
     </div>
    </div>
  );
}

export default App;

复制代码

当我们点击 span 标签后生成的 events 如下:

image.png 最主要的就是这两个变量,他们存储了当前 fiber(span) 节点及父节点、父父级节点、...的所有的回调函数,最终就可以模拟捕获和冒泡过程。

5、executeDispatchesInOrder

function executeDispatchesInOrder(event) {
  var dispatchListeners = event._dispatchListeners;
  var dispatchInstances = event._dispatchInstances;
  if (Array.isArray(dispatchListeners)) {
    // 从前往后遍历,先执行捕获的回调函数,在执行冒泡的回调函数
    for (var i = 0; i < dispatchListeners.length; i++) {
      // 如果阻止冒泡则直接退出
      if (event.isPropagationStopped()) {
        break;
      } 
      // 执行回调函数
      executeDispatch(event, dispatchListeners[i], dispatchInstances[i]);
    }
  } else if (dispatchListeners) {
    executeDispatch(event, dispatchListeners, dispatchInstances);
  }
  event._dispatchListeners = null;
  event._dispatchInstances = null;
}

复制代码

这个函数会遍历我们的队列,先执行捕获的回调函数再执行冒泡的回调函数,同时 React 对于阻止冒泡就是通过 isPropagationStopped来判断的,如果在某一个函数中调用e.stopPropagation(),就会赋值给isPropagationStopped=()=>true,当再执行 e.isPropagationStopped()就会返回 true ,接下来事件处理函数,就不会执行了。

五、React17 事件系统

React17 针对 React16 种存在的问题进行了修复,主要的改动点如下:

1、将事件绑定在 container 上

React17 将事件统一绑定 container 上,这个 container 指的就是 render 函数的第二个参数。

image.png

这样好处是有利于微前端的,微前端一个前端系统中可能有多个应用,如果继续采取全部绑定在document上,那么可能多应用下会出现问题,比如比较流行的 qiankun 框架,它里面使用了shadow-dom 对子应用进行隔离,他会对子应用的事件源进行重定向,下面是官网的解释。

image.png

2、取消事件池 

React 17 取消事件池复用,因为 react16 中的事件池在一些情况下会出现一些问题,比如下面这个例子:

  const handleClick = (e) => {
    console.log(e);
    setTimeout(() => {
      console.log(e);
    });
  }
复制代码

打印出的结果如下:

image.png

很明显这两个输出是不一样的,因为 setTimeout 执行时事件池中的事件对象已经被清理了。

Supongo que te gusta

Origin juejin.im/post/7079764958359060511
Recomendado
Clasificación