写给本身看的React源码解析(四):React事件系统的实现原理

前言

React有着独特的事件机制-合成事件,React的初学者确定碰到过这种问题,使用event.stopPropagation();,却仍是没法禁止当前组件的事件冒泡,这就是React的事件机制的缘由,它并不与DOM事件相同。react

本文将从源码的角度来解析,React的事件系统究竟是如何实现的?git

DOM事件流

DOM事件流属于比较基础的知识点,本文不会详细的再叙述,只列出须要一些关键点。W3C标准约定了一个事件的传播过程要通过如下 3 个阶段:github

  • 1.事件捕获阶段
  • 2.目标阶段
  • 3.事件冒泡阶段

经过DOM事件流,咱们常常会用到一直常见的性能优化思路:事件委托数组

React事件系统也是基于事件委托这个特性实现的。浏览器

React事件系统

React中,除了一些不可冒泡的事件外,其它的事件都不会被绑定在具体的元素上,而是统一被绑定到document上(17版本以后修改成绑定到React的根DOM组件上),当事件在具体的DOM节点上被触发后,最终都会冒泡到document上,React根组件上所绑定的统一事件处理程序会将事件分发到具体的组件实例。性能优化

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

React合成事件

合成事件是React自定义的事件对象,它在底层抹平了不一样浏览器的差别,在上层面向开发者暴露统一的、稳定的、与DOM原生事件相同的事件接口。app

虽然合成事件并非原生DOM事件,但它保存了原生DOM事件的引用。当你须要访问原生DOM事件对象时,能够经过合成事件对象的e.nativeEvent属性获取到原生DOM事件。dom

React事件的绑定

事件的绑定是在组件的首次渲染链路的completeWork方法中完成的。关于首次渲染的流程,能够看我以前的文章异步

写给本身看的React源码解析(一):你的React代码是怎么渲染成DOM的?

completeWork主要作了三件事情:

  • 建立 DOM 节点(createInstance)
  • 将 DOM 节点插入到 DOM 树中(appendAllChildren)
  • 为 DOM 节点设置属性(finalizeInitialChildren)。

finalizeInitialChildren方法中,会遍历节点的props。当遍历到事件相关的props时,就会触发事件的注册链路。

本文基于16.13版本的React源码。注意:17版本的事件系统在源码上的改动比较大,源码链路上跟本文并不一致。

ensureListeningTo中,会获取当前DOM中的document对象,而后经过调用legacyListenToEvent,将统一的事件监听函数注册到document上面。

legacyListenToEvent中,其实是经过调用legacyListenToTopLevelEvent来处理事件和document之间的关系的。 legacyListenToTopLevelEvent直译过来是“监听顶层的事件”,这里的“顶层”就能够理解为事件委托的最上层,也就是document节点。

注意:在17版本中,流程中不会存在ensureListeningTolegacyListenToEvent方法,React会在finalizeInitialChildren方法下的setInitialProperties根据节点的tag类型,传入不一样的参数并调用listenToNonDelegatedEvent方法。在这个方法里,会直接调用addTrappedEventListener添加事件到React的根组件DOM元素上。

listenToNonDelegatedEvent源码地址

咱们接着来看,最终注册到document上的并非某一个DOM节点上对应的具体回调逻辑,而是一个统一的事件分发函数listener,它的本体是一个dispatchEvent

React事件的触发

事件的触发其实就是对于dispatchEvent函数的调用。

咱们根据下面的这个demo来走流程

import React from 'react';
import { useState } from 'react'
function App() {
  const [state, setState] = useState(0);
  return (
    <div className="App"> <div className="container" onClickCapture={() => console.log('捕获通过 div')} onClick={() => console.log('冒泡通过 div')} > <p>{state}</p> <button onClick={() => { setState(state + 1) }}>点击+1</button> </div> </div>
  );
}
export default App;
复制代码

这个demo的功能很简单,每次点击按钮都会给state加1。并给container这个div上添加了两个点击事件,一个捕获事件,一个冒泡事件。下图是这个demo的fiber树结构。

收集的逻辑过程在traverseTwoPhase函数

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 数组中
  2. 模拟事件在捕获阶段的传播顺序,收集捕获阶段相关的节点实例与回调函数
  3. 模拟事件在冒泡阶段的传播顺序,收集冒泡阶段相关的节点实例与回调函数

收集父节点

traverseTwoPhase会以触发事件的目标节点为起点,经过getParent方法,不断向上寻找tag===HostComponent的父节点,并将这些节点按顺序收集进path数组中。tag===HostComponent的节点是DOM元素对应的的fiber节点类型,也就是说只收集DOM元素对应的节点。

按照demo中的fiber树来讲,最后收集到的节点为div#containerdiv.Appbutton节点。

模拟捕获顺序,收集节点实例与回调函数

for (i = path.length; i-- > 0;) {
    fn(path[i], 'captured', arg);
  }
复制代码

path数组是从子节点出发,向上收集得来的。因此,模拟事件的捕获顺序,须要从后往前遍历path数组。在遍历的过程当中,fn函数检测每一个节点的事件回调,若该节点上对应当前事件的捕获回调不为空,那么节点fiber实例会被收集到合成事件的SyntheticEvent._dispatchInstances中,事件回调则会被收集到合成事件的SyntheticEvent._dispatchListeners属性。

模拟冒泡顺序,收集节点实例与回调函数

for (i = 0; i < path.length; i++) {
    fn(path[i], 'bubbled', arg);
  }
复制代码

这里功能跟上一步一致,区别只是从前日后来遍历path数组。

最后,咱们来看下SyntheticEvent对象上的_dispatchInstances_dispatchListeners

咱们只要按顺序调用执行回调函数,就可以模拟出DOM事件流,也就是 “捕获-目标-冒泡”这三个阶段。

react17中对于事件系统的更新

上文的源码解析是基于16.13.x版本的,17版本以后的事件系统,有了挺大的区别。

  • 1.事件系统改成挂载到React的根组件dom上
  • 2.onScroll事件再也不冒泡
  • 3.onFocusonBlur事件已在底层切换为原生的focusinfocusout事件
  • 4.onClickCapture如今使用的是实际浏览器中的捕获监听器(合成事件只会存在listenToNonDelegatedEvent添加的冒泡事件)
  • 5.事件池SyntheticEvent再也不复用,在点击事件中使用异步方法也将能够获取到点击事件。不须要再使用e.persist()方法

感谢

若是本文对你有所帮助,请帮忙点个赞,感谢!

相关文章
相关标签/搜索