「React 深入」畅聊React 事件系统(v17、v18版本)

4,885 阅读10分钟

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

大家好,我是小杜杜,我们知道React自身提供了一套虚拟的事件系统,通过前两篇的学习,我们已经知道React事件系统与原生系统有哪些不同,React v16事件系统到底是如何运作的,接下来看看v17v18中,事件系统做了哪些更改,接下来就来详细的看看~

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

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

  • Reactv17v18v16的基础上做了哪些该改变?
  • v17中事件系统又是如何绑定,如何收集,如何触发的?
  • 对比与原生事件的捕获、冒泡,不同的版本,执行的顺序是什么?
  • 为什么要取消事件池?
  • ...

本文基于React v17.0.1源码、React v18.2.0

同时建议先看看前两篇,这样的话看这篇就容易一点,同时也能让更好的了解

先来看看整个事件系统的流程图:

深入React事件系统.png

React v17、v18 事件机制

React v17可以说是一个特殊的版本,因为这个版本并无新特性的存在,而是侧重于升级简化React本身,可以认为这个版本是垫脚石的版本

虽然在这个版本中没有新的hooks加入,也没有fiber架构的改变,但对于事件系统而言,则是一个重大的改变,接下我们一起来看看

而在v18中与v17中大体相同,我们就以v17为主,接下来一起看看

事件绑定

首先,在v17版本中,将顶层事件调整到container上,这样做的目的主要是为了:兼容性跨平台,可以兼容多个版本,非常有利于微前端(微前端会对应多个系统,存在对应多个react版本的问题)

同时在React v16中,React执行大多数事件都会调用documnet.addEventListener(), 而在v17中,在底层中调用rootNode.addEventListener(),如:

image.png

createRoot

当我们调用 document.getElementById('root')时,会走createRoot方法(源码位置:packages/react-dom/src/client/ReactDOMRoot.js中)

export function createRoot(
  container: Container,
  options?: RootOptions,
): RootType {
  ...
  return new ReactDOMRoot(container, options);
}

// ReactDOMRoot
function ReactDOMRoot(container: Container, options: void | RootOptions) {
  this._internalRoot = createRootImpl(container, ConcurrentRoot, options);
}

//createRootImpl
function createRootImpl(
  container: Container,
  tag: RootTag,
  options: void | RootOptions,
) {
  ...
  if (enableEagerRootListeners) {
    const rootContainerElement =
      container.nodeType === COMMENT_NODE ? container.parentNode : container;
    listenToAllSupportedEvents(rootContainerElement);
  } else {
   ..
  }
  ...
  return root;
}

可以看出执行的顺序为:createRoot => ReactDOMRoot => createRootImpl => listenToAllSupportedEvents

v18中的createRoot

image.png

可以看到最终走向还是listenToAllSupportedEvents函数

这里的markContainerAsRoot方法是指向对应的fiber节点

listenToAllSupportedEvents

listenToAllSupportedEvents:实际上就是整个事件绑定的开始

image.png

这里要特别注意rootContainerElementallNativeEvents

  • rootContainerElement:就是根节点root
  • allNativeEvents:是所有原生事件的集合(set类型),在这里会遍历所有的原生事件(除了一些特殊的),!nonDelegatedEvents.has(domEventName)则是判断这些原生事件哪些具有冒泡,有冒泡的则会绑定

可以简单的看下allNativeEvents这个集合:

image.png

常用的事件都在其中,如clickinputchangescroll等共有80个

v18中的listenToAllSupportedEvents

image.png

可以看出与v17中大差不差,但这里的allNativeEvents并不是v17allNativeEvents里面的原生事件有对应的更改,变为了81个

image.png

之后的走向与v17中的流向大体相同

listenToNativeEvent

listenToNativeEvent: 处理冒泡捕获的函数,我们还是以最熟悉的click来做解说 image.png

参数:

  • domEventName:对应的事件名,如click
  • isCapturePhaseListener:是否捕获,true为捕获,false为冒泡

内容:

  • getEventListenerSet(target):它的作用是存储对应的事件名,防止重复添加监听器

image.png

也就是 cancel => cancel__capturecancel__bubble

  • getListenerSetKey:获取对应的事件名,也就是click__bubble
  • 如果未绑定规则,则会调用addTrappedEventListener,最终添加到listenerSet

addTrappedEventListener

通过addTrappedEventListener的作用是在对应的监听器 image.png

  • createEventListenerWrapperWithPriority:这个函数是整个事件的重中之重,它是用来判断事件执行的优先级,返回对应的监听器
  • 之后就是在原生事件中添加不同的监听器

createEventListenerWrapperWithPriority

简单来看下 createEventListenerWrapperWithPriority 这个函数

image.png

可以看出,它是根据eventPriority来判断优先级,不同的优先级返回不同的监听函数

可以简单的了解下,最终的目的都是进行事件收集事件调用,这块比较复杂,建议了解就好~

  • dispatchDiscreteEvent:离散事件监听器,优先级为 0
  • dispatchUserBlockingUpdate:用户阻塞事件监听器,优先级为1
  • dispatchEvent:连续事件或其他事件监听器,优先级为2

所以执行事件的优先级为:dispatchEvent => dispatchUserBlockingUpdate => dispatchDiscreteEvent

addEventBubbleListener

接下来就是对应的挂载了:

image.png

dispatchEvent

在上面的过程中,在无论走哪种监听器,都会调用dispatchEvent,它的优先级最高的原因是,它是同步,而其余两个监听器是异步的。

可以说dispatchEvent是合成事件的核心内容,最终走向dispatchEventsForPlugins,同时也是这函数触发了事件收集的功能

dispatchEventsForPlugins

源码位置:packages/react-dom/src/events/DOMPluginEventSystem.js

image.png

参数:

  • domEventName:事件名称
  • eventSystemFlags:事件处理的阶段,0:冒泡阶段,4:捕获阶段
  • nativeEvent:原生事件的事件源(event)
  • targetInstDOM元素对应的节点,即fiber节点
  • targetContainer:根节点

内容:

  • dispatchQueue:就是事件队列,收集到的事件都会存储到这
  • extractEvents:收集事件
  • processDispatchQueue 执行事件

extractEvents(收集事件)

extractEvents:它的作用就是用来生成不同的事件,由于每个事件都有稍许差异,所以导致有不同的插件,但这些插件的目的都一样,都是为了生成对应的事件

我们以最普遍的 SimpleEventPluginextractEvents为例(源码位置在:react-dom/src/events/plugins/SimpleEventPlugin.jsimage.png

大概讲以下步骤

  • 1.通过topLevelEventsToReactNames.get(domEventName)来获取对应的合成事件名称,如:onMouseOver
  • 2.SyntheticEventCtor()是合成函数的构造函数
    1. 然后通过switch来匹配对应的合成事件的构造函数
    1. inCapturePhase判断是否捕获阶段,下面的是冒泡阶段
  • 5、通过accumulateSinglePhaseListeners()函数来获取当前阶段的所有事件
  • 6、最后通过new SyntheticEventCtor()生成对应的事件源,插入队列中

accumulateSinglePhaseListeners

accumulateSinglePhaseListeners这个函数会获取存储在Fiber上的Props的对应事件,然后通过createDispatchListener返回的对象加入到监听集合上,如果是不会冒泡的函数则会停止(比如:scroll),反之会向上递归 image.png

可以看出 scroll函数不再进行冒泡,如果是scroll,accumulateTargetOnly就会为true,执行过一次就不会再执行了

processDispatchQueue(执行事件)

最后再来看看执行事件:processDispatchQueue

image.png

执行的时候还是先会判断是否是捕获阶段,之后就会遍历对应的合成事件,然后取出对应的事件源监听的函数,最后会调用processDispatchQueueItemsInOrder函数

这个函数就会通过inCapturePhase来模拟对应的冒泡与捕获

  • event.isPropagationStopped():用来判断是否阻止冒泡(e.stopPropagation),如果阻止冒泡,就会在这一步退出,从而模拟事件流的过程
  • executeDispatch():执行事件的函数

最后可以看看顺便看看onClick的合成对象: image.png

扩展:事件池的取消

关于事件池的取消可以看看官方的: image.png 简单的说,就是没啥用,也没有对应的性能提高,所以就没了,但去除事件池后,自然也不存在持久化的问题,所以在setTimeout可以获得对应的事件源

img2.gif

顺便一提,e.persistent()还是可以继续使用,只不过没有什么效果

总结

总的来说,在v17中没有了事件池的概念,从顶层到根节点的转变,也让其更加适应多版本,正如官方所说,这个版本就是垫脚石,为以后做准备的版本

此外,onScrollonFocusonBlur并不会冒泡

对比 v16事件机制

执行顺序:原生事件 vs 合成事件

按照上面的讲解,Reactv17v18并没有进行太大的变化,但在测试的时候遇到这样一个bug,虽然跟事件机制没有啥关系,但顺便提一下吧~

测试代码:

import React, {useEffect} from "react";

export default function App(props) {

  useEffect(() => {
    const div = document.getElementById("div")
    const button = document.getElementById("button")

    div.addEventListener("click", () => console.log("原生冒泡:div元素"))
    button.addEventListener("click", () => console.log("原生冒泡:button元素"))

    div.addEventListener("click", () => console.log("原生捕获:div元素"), true)
    button.addEventListener("click", () => console.log("原生捕获:button元素"), true)

    document.addEventListener("click", () => console.log("document元素冒泡"))
    document.addEventListener("click", () => console.log("document元素捕获"), true)
  }, [])

  return (
    <div 
      id="div"
      onClick={() => console.log('React冒泡:div元素')}
      onClickCapture={() => console.log('React捕获:div元素')}
    >
      <button
        id="button"
        onClick={() => console.log('React冒泡:button元素')}
        onClickCapture={() => console.log('React捕获:button元素')}
      >
        执行顺序 v16/v17/v18
      </button>
    </div>
 

我们分别看看这段代码在 v16v17v18的环境上运行,是什么效果

React v16

image.png

documnet捕获 => 原生捕获 => 原生冒泡 => 合成事件捕获 => 合成事件冒泡 => documnet冒泡

React v17

image.png

documnet捕获 => 合成事件捕获 => 原生捕获 => 原生冒泡 => 合成事件冒泡 => documnet冒泡

React v18:

image.png

可以看到v18的顺序与v17一样

对比下版本的走向 未命名文件 (1).png

扩展 v18严格模式下的useEffect

可能有的小伙伴会好奇,在v18中会执行两遍原生的事件,这个实际上并不是事件机制重复执行,而是因为<React.StrictMode/>这个标签,也就是严格模式

image.png

v18中的严格模式会让useEffect执行两遍,导致监听了两次,所以看到打印的时候会有两遍

所谓的严格模式是用于突出显示应用程序中潜在问题的工具,它不会呈现任何可见的UI。

注意,严格模式并不会影响生产环境,生成环境下useEffect还是会执行一遍,这应该算个bug吧~

所以只要去掉严格模式,打印的结果就与v17一样了

不同版本的对比

v16v17、v18
顶层documenroot
事件池存在,导致setTimeout无法获取不存在
是否是真的捕获事件不是,实际上是冒泡

End

参考

相关文章

结语

经过了三周的时间,终于把React v16 ~ v18的事件系统更完,看源码过程确实比较痛苦,好歹是坚持下来了

有关事件系统的文章就更完了,可能以现在的水平无法解释的更好,理解的更深,可能过段时间再看源码,会有不一样的感觉,如有不对的地方还请告知~

读源码并不是一蹴而就,要经过长时间的理解,第一遍看可能并不会理解为何要这样去做,这样做是否合理,随着时间的累加,回过头发现并没有那么难,实践永远是最好的老师,建议大家亲自弄弄,看看React中究竟是如何执行的~

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