如何理解 React 16 事件

5,125 阅读12分钟

如何理解 React.16 的事件系统

「本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!

学过 React 同学的小伙伴都知道,React 有自己的事件系统,和 DOM 原生的事件系统是完全不一样的,但是到底哪里不一样,和 DOM 原生的事件有什么区别和联系,这是个很难回答的问题,因为他涉及的代码量不少,相关的代码逻辑也不内聚,整体的理解难度较难。曾经被他整个晕头转向,后来通过抓住 React 事件特征的主要逻辑流程,算是把整个 React 的事件系统理清楚了,今天我们来探讨一下。

我们先回顾一下原生的 DOM 事件流是什么样,然后根据原生事件的事件委托来找到 React 事件的灵感来源,紧接着我们去探讨 React 事件系统是怎样工作的,并对他进行拆解,了解事件回调的收集和执行。这样循序渐进,我们就能对 React 的整个事件有了解了,最后我们再来思考一下 React 事件系统他设计的原因和动机是什么,到底是什么因素促使 React 团队要这样设计。整篇文章的行文结构和设计也是按照上面的思路来的,希望能够通过本文抛砖引玉,给大家带来新的思考和启发。

本文所有的代码和研究基础都是在 16.13.0 基础上的,下一篇文章会研究 React 17 上的事件系统,欢迎大家关注我

原生 DOM 事件流

我们在浏览器中,通过事件监听来实现 JS 和 HTML 之间的交互。一个页面往往会被绑定许许多多的事件,页面接收的事件的顺序,就是事件流。W3C 标准约定了一个事件的传播过程要经过三个阶段:

  • 事件捕获阶段
  • 目标阶段
  • 事件冒泡阶段

我们通过一个图来了解一下

事件传播的阶段

事件被触发的时候,一开始会经历一个捕获的过程,事件从最外层的元素开始 “穿过”,逐层 “穿过” 到最内层元素,这个过程会持续到事件到达他的目标元素(真正触发事件的元素)为止;这个时候时间流就切换到了 “目标阶段”(事件被目标元素所接收);然后事件就会被 “回弹”,进入到冒泡阶段,沿着来的路 “逆流而上”,一层一层再走回去。

这个有点像我们玩的蹦床,跳到床上,然后被弹起来,整个过程呈现一个 “V” 字。

事件流就像是这个蹦床,跳下去,弹起来,呈现一个 V 字

事件委托

在原生 DOM 中,事件委托是一种重要的性能优化手段,通过事件冒泡的方式,将子节点的事件通过 e.target 来捕获。看下面的代码:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Document</title>
</head>
<body>
  <ul id="list">
    <li>1</li>
    <li>2</li>
    <li>3</li>
    <li>4</li>
    <li>5</li>
    <li>6</li>
    <li>7</li>
    <li>8</li>
    <li>9</li>
    <li>0</li>
  </ul>
</body>
</html>

在这段代码中,如果希望能够点击每个 li 元素,都可以输出文本的内容,需要怎么做?

最直接的是让每个 li 元素都绑定一个监听事件,按照这个方式写出来是这样的:

<script>

  // 获取 li 列表

  var liList = document.getElementsByTagName('li')

  // 逐个安装监听函数

  for (var i = 0; i < liList.length; i++) {

    liList[i].addEventListener('click', function (e) {。。

      console.log(e.target.innerHTML)

    })

  }

</script>

但是这样不够优雅,而且性能也并不好,如果有成百上千个节点,会产生成百上千个节点监听事件,那怎么去解决这个问题呢?我们可以采用事件冒泡的方式!

对于这 10 个 li 元素来说,不管点击动作发生在哪个 li 上,点击事件最终都会被冒泡到他们共同的父亲 --- ul 元素上,所以可以让 ul 来帮忙感知这个点击事件。

既然 ul 可以感知事件,那么他就能帮忙处理事件,因为我们有 e.targetul 元素可以通过事件对象中的 target 属性,拿到实际触发事件的元素,针对这个元素分发事件处理的逻辑,做到真正的 “委托”。

按照上面的思路,我们就可以去掉 for 循环了,下面是用事件代理来实现同样效果的代码:

var ul = document.getElementById('poem')

ul.addEventListener('click', function(e){

  console.log(e.target.innerHTML)

})

代码里的 e.target 属性,指的是触发事件的具体目标,记录事件的源头。所以在这里,不管是监听函数在哪一层执行,只要拿到 e.target,就是拿到真正触发的元素,拿到元素之后,我们完全可以模拟出他的行为,实现无差别的监听效果。

利用事件的冒泡特性,把多个子元素的同一个类型的监听逻辑,合并到父元素上通过一个监听函数来管理的行为,就是事件委托,通过事件委托,可以减少内存开销、简化注册步骤,大大提高开发效率。

这个事件委托的方式,也正是 React 合成事件的灵感源泉。接下来我们就来看下 React 事件系统是怎么工作的。

React 事件系统是怎么工作的

React 事件系统采纳了事件委托的思想。在 React 中,除了少数特殊的不可冒泡的事件(如媒体类型的事件)无法被事件系统处理以外,大部分的事件都不会被绑定在具体的元素上,而是被统一绑定在页面的 document 上,当事件在具体的 DOM 节点被触发后,最终都会冒泡到 document 上,document 上所绑定的统一事件处理程序会将事件分发到具体的组件实例上。

所以,在分发事件前,React 首先会对事件进行包装,把原生 DOM 事件包装成合成事件。

什么是 React 合成事件

合成事件是 React 自定义的事件对象,是根据 W3C 规范 来定义的合成事件,在底层上抹平了不同浏览器的差异,向上层开发者暴露了统一、稳定、与 DOM 原生事件相同的事件接口。这样开发者就不必再关注繁琐的兼容性问题,只需要专注业务逻辑的开发。

虽然合成事件不是原生的 DOM 事件,但是保留了对于原生 DOM 事件的引用。如果需要访问原生 DOM 事件对象,通过合成事件对象的 e.nativeEvent 属性可以获取到原生的 DOM 事件引用,像下图一样:

通过合成事件拿到原生 DOM 的引用

e.nativeEvent 输出了 MouseEvent原生事件的引用

打印出来的原生 DOM 的引用

我们大致的讲了一下 React 事件系统的基本能原理,对合成事件的基本概念有一定的了解,接下来结合 React 源码和调用栈,对事件系统的工作流进行深入了解,加油,难啃的骨头来了!

React 事件系统工作的流程

事件系统,始终不会跳脱出 “事件绑定” 和 “事件触发” 两个主要动作,我们来看下事件绑定是怎么实现的。

事件绑定

事件的绑定在组件的挂载过程中(completeWork)完成的,在挂载过程中,会有三个主要动作

  • 创建 DOM 节点
  • 将 DOM 节点插入到 DOM 树中
  • 设置 DOM 节点属性

其中在 为 DOM 设置节点属性的环节,会遍历 FiberNode 的 props,当遍历到事件相关的 props 时,就会触发事件的注册链路。完整源码可以参考这个地址

考虑到源码可能会随着时间会稍有变化,我把代码摘出来了, 并且删掉了很多影响理解的地方,代码如下:

function completeWork(current, workInProgress, renderLanes) {
  // 取出 Fiber 节点的属性值,存储在 newProps 里
  const newProps = workInProgress.pendingProps;
  // 根据 workInProgress 节点的 tag 属性的不同,决定要进入哪段逻辑,
  // 我们主要看 HostComponent,其他的都删掉了
  switch (workInProgress.tag) {
    // 这里有很多 case,但是为了方便理解,我们只保留了 HostComponent 的 case
    case HostComponent: {
      // Dom 节点的逻辑
      popHostContext(workInProgress);
      const rootContainerInstance = getRootHostContainer();
      const type = workInProgress.type;
      // 判断 current 节点是否存在,在挂载阶段,current 节点是不存在的,
      // 这个逻辑只有在触发更新的时候会进入,等有时间我们再讲一下页面渲染和更新的逻辑
      if (current !== null && workInProgress.stateNode != null) {
        updateHostComponent(
          current,
          workInProgress,
          type,
          newProps,
          rootContainerInstance
        );
        if (current.ref !== workInProgress.ref) {
          markRef(workInProgress);
        }
      } else {
        // 为 Dom 节点的创建做准备
        const currentHostContext = getHostContext();
        // 与服务端渲染有关的值 先不关注
        const wasHydrated = popHydrationState(workInProgress);
        // 判断是否是服务端渲染 先不关注
        if (wasHydrated) {
          if (
            prepareToHydrateHostInstance(
              workInProgress,
              rootContainerInstance,
              currentHostContext
            )
          ) {
            markUpdate(workInProgress);
          }
        } else {
          // 创建 DOM 节点
          const instance = createInstance(
            type,
            newProps,
            rootContainerInstance,
            currentHostContext,
            workInProgress
          );
          // 尝试把上一步创建好的 Dom 节点挂载到 DOM 树上,
          //这里需要注意下,有可能 DOM 树还不存在,但是没关系,看下面的一行代码
          appendAllChildren(instance, workInProgress, false, false);
          // stateNode 存储当前 Fiber 节点对应的 DOM 节点,
          // 这里每个节点保存了自己的 DOM 实例,
          // 所以即使是 DOM 树还不存在,也没关系,
          //等 DOM 存在时候可以遍历的从 FiberNode 中取出 DOM 节点
          workInProgress.stateNode = instance;
          // 用来为 DOM 节点设置属性,这写法有点硬核,
          //其实把返回的返回值赋值给一个变量,会好理解一些,不过源码里很多这种写法,难受
          if (
            finalizeInitialChildren(
              instance,
              type,
              newProps,
              rootContainerInstance,
              currentHostContext
            )
          ) {
            markUpdate(workInProgress);
          }
        }
        if (workInProgress.ref !== null) {
          markRef(workInProgress);
        }
      }
      bubbleProperties(workInProgress);
      return null;
    }
  }
}

目前从源码来看,大致的流程图如下所示:

completeWork 内大致流程图

结合源码和流程图来看,接下来的重点在 finalizeInitialChildren 里,但是这样就太难受了,又要跳到 finalizeInitialChildren 的源码里,这完全是套娃,我们目的主要是要梳理整个流程,而不是在陷入源码里无法自拔,最终让自己看代码看到怀疑人生。所以今天也在这分享一个方法,看整个调用流程。

  1. create-react-app 创建一个 react 项目,用下面的代码替换掉
import { useState } from "react";

function App() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <div
        onClick={(e) => {
          console.log("原生的 DOM 事件是:", e.nativeEvent);
          setCount(count + 1);
        }}
      >
        <p>{count}</p>
      </div>
    </div>
  );
}

export default App;
  1. 打开 Chrome 的 Performance 面板,点击 “刷新记录” 按钮(圈出来的那个)

Performance 面板

  1. 等待 3 - 4 秒钟,然后终止记录,就能得到下面的调用栈大图。

调用栈大图

  1. 然后我们找到 completeWork, 就可以看调用栈了。

completeWork 调用栈

最后从整个调用栈的图来看,我们就可以重新梳理我们的流程图,这样是不是远比看源码简单多了。

image

从图中我们也可以看到,事件的注册过程是由 ensureListeningTo 函数开启的,在 ensureListeningTo 中,会尝试获取当前 DOM 结构中的根节点(document 对象),然后通过调用 legacyListenToEvent,将统一的事件监听函数注册到 document 上面。将统一的事件监听函数注册到 document 上面。

legacyListenToEvent 中,实际上是通过调用 legacyListenToTopLevelEvent 来处理事件和 document 之间的关系的,从 TopLevelEvent 就可以看出监听的是顶层的事件,这个顶层可以理解成事件委托的最顶层,也就是 document 节点。

legacyListenToTopLevelEvent 有个地方要注意,图中我圈出来了,里面有个 listenerMap 的变量,采用的 map 作为存储的数据结构,里面存储了整个 document 的事件。

legacyListenToTopLevelEvent

在这里,legacyListenToTopLevelEvent 是整个事件的起点,首先会判断 listenerMap 里面有没有这个事件,不过注意下,这里的 topLevelType 指的是函数上下文代表事件类型,比如,如果是点击事件,那么 topLevelType 的值是 click。如下图所示:

Demo 中打断点出来的 topLevelType

还有个细节值得我们注意,为什么这里要用 map 来存储 document 事件呢,而且还用 topLevelType 作为 map 的 key,仔细想想,这里其实是 React 的一个优化,就是我们在 React 项目中多次调用了对同一个事件的监听,也只会在 document 上触发一次注册。并且 React 最终注册到 document 上的并不是某一个 DOM 节点上具体的回调逻辑,而只是一个分发函数

我们在 addEventBubbleListener 上打个断点来试下就可以见分晓!

addEventBubbleListener 做的事情

从截图中我们可以看到,element 就是 document,eventTypeclick,而 listener 就是 dispatchDiscreteEvent 函数,哈哈哈,就是这么直接,接下里再来看下 dispatchDiscreteEvent 里的代码:

function dispatchDiscreteEvent(topLevelType, eventSystemFlags, container, nativeEvent) {
  flushDiscreteUpdatesIfNeeded(nativeEvent.timeStamp);
  discreteUpdates(dispatchEvent, topLevelType, eventSystemFlags, container, nativeEvent);
}

通过 dispatchDiscreteEvent 的源码我们可以看到,最后绑定到 document 上的这个统一的事件分发函数,其实就是 dispatchEvent

最终问题来了,dispatchEvent 是如何实现事件分发的呢?

事件的触发

事件触发的根本是对 dispatchEvent 的调用,但是 dispatchEvent 的触发链路比较长,况且不少同学应该被上面的流程估计已经整的头晕脑胀了,所以我直接把整个核心工作流绘制出来了。

dispatchEvent 工作流程

前三个步骤已经在上面已经分析了,对我们来说陌生的是 4、5、6 这三步,这三步也是我们要重点攻克的。

我们通过一个 Demo 来理解这个过程

import React, { useState } from "react";

function App() {
  const [value, setValue] = useState(0);
  return (
    <div
      onClickCapture={() => console.log("捕获经过 div")}
      onClick={() => console.log("冒泡经过 div")}
    >
      <p>{value}</p>
      <button
        onClick={() => {
          setValue(value + 1);
        }}
      >
        加一
      </button>
    </div>
  );
}
export default App;

这个 Demo 对应的界面是这样的

Demo 对应的界面

Demo 是一行数字文本和一个按钮,每点击一下按钮,数字文本会 +1。在 JSX 结构中,监听点击事件的除了按钮,还有 div 标签,这个 div 标签同时监听了点击事件的冒泡和捕获。

整个 Demo 对应的 Fiber 树结构是这样的

Fiber 树

我们就根据这个 Fiber 树结构来理解事件回调的收集过程

function traverseTwoPhase(inst, fn, arg) {
  // 定义一个 Path 数组,用来存储捕获和冒泡的节点
  var path = [];

  while (inst) {
    // 把当前的节点收集进 path 数组
    path.push(inst);
    // 往上收集 tag === HostComponent 的父节点
    inst = getParent(inst);
  }

  var i;
  // 从后往前,收集 path 数组中参与捕获过程的节点与对应的回调
  for (i = path.length; i-- > 0;) {
    fn(path[i], 'captured', arg);
  }
  // 从前往后,收集 path 数组中参与冒泡过程的节点与对应回调
  for (i = 0; i < path.length; i++) {
    fn(path[i], 'bubbled', arg);
  }
}

从代码来看,traverseTwoPhase 函数主要做了下面三件事情:

  1. 循环收集符合条件的父节点,存到 path 数组里面

traverseTwoPhase 会以当前节点(触发事件的目标节点)为起点,不断向上寻找 tag === HostComponent 的父节点,并把这些节点按顺序收集进 path 数组中。这里增加一下说明,有的同学可能不知道为啥只收集 tag === HostComponent,这是因为 HostComponent 指的是 DOM 元素对应的 Fiber 节点类型。在浏览器中,DOM 事件也只会在 DOM 节点之间传播,收集其他节点没有意义的。

我们来看下 path 数组的内容

path 数组的内容

看最后收集上来内容是 button 节点自身(这个 while 循环的起点,最开始就会被推进去)和 div 节点。

  1. 模拟事件在捕获阶段的传播顺序,收集捕获阶段相关的节点实例与回调函数

traverseTwoPhase 会从后往前遍历 path 数组,模拟事件的捕获顺序,收集事件在捕获阶段对应的回调和实例。

上一个小点我们说的是 path 数组从目标节点出发,向上收集,所以 path 数组中子节点在前面,祖先节点在后面,这个是和 DOM 节点的冒泡阶段是一样的。

从后往前遍历 path 数组,其实就是从父节点往下遍历子节点,直到遍历到目标节点的过程,遍历的顺序和 DOM 事件在捕获节点的传播的顺序是一样的。在整个的遍历过程中,fn 函数会对每个节点的回调情况进行一一检查,如果这个节点上有对应的事件,则实例会被收集到合成事件的 _dispatchInstances(SyntheticEvent._dispatchInstances)中,事件回调则会被收集到合成事件的 _dispatchListeners 属性(SyntheticEvent._dispatchListeners)中去,等待后续的执行。我们打一个断点调试看看:

image

我们可以看到,div 的实例存储在 _dispatchInstances 中,回调的事件在 _dispatchListeners 中,等待后续执行。

  1. 模拟事件在冒泡阶段的传播的顺序,收集冒泡阶段相关节点实例与回调函数

捕获阶段的工作完成后,traverseTwoPhase 会从前往后遍历 path 数组,模拟事件的冒泡顺序,收集事件在捕获阶段对应的回调和实例。

过程和步骤 2 基本一样,区别在于 path 数组的遍历顺序从倒序变成了正序。正序遍历一样会对回调的情况进行检查,如果节点上对应当前时间的冒泡回调不为空,那么节点实例和事件回调会分别被收集到 SyntheticEvent._dispatchInstancesSyntheticEvent._dispatchListeners 中。

所以,在事件回调的执行阶段,只需要按照顺序执行 SyntheticEvent._dispatchListeners 数组中的回调函数,就可以一口气模拟出整个完整的 DOM 事件流,也是 “捕获-目标-冒泡” 三个阶段。

总结

我们基于原生 DOM 事件流,对 React 事件系统的工作流进行了学习。但是有个问题一直还没回答,就是 React 是基于合成事件在 模拟 DOM 事件流,这样做的好处和动机是什么?这个问题也是经常会在面试中会被问起来的,那么在这个总结阶段就来梳理一下这个问题。

  1. 合成事件符合 W3C 规范,在底层抹平了不同浏览器之间的差异,在上层提供开发者统一的、稳定的、与 DOM 原生事件相同的事件接口。所以开发者就可以不再关注繁琐的底层兼容的问题,可以专注在业务逻辑的开发。
  2. 自研事件系统让 React 把控了事件处理的主动权,就像是已经有了 React、Vue 之类很成熟的框架,但是各个大厂依然是在自研自己的框架去覆盖自己的业务场景。React 把多个事件揉成一个事件,原生的 DOM 是不会帮他处理的,原生关注的是通用性,而 React 想的是可以通过自己的事件系统,让他很大程度上干预事件的表现,让他们符合自己的需求。

本文所有的代码和研究基础都是在 16.13.0 基础上的,下一篇文章会研究 React 17 上的事件系统,欢迎大家关注我