likes
comments
collection
share

深入探究 React 原生事件的工作原理

作者站长头像
站长
· 阅读数 22

本文对应的 react 版本是 18.2.0

ReactDOMreactdom 渲染器,它从 react-dom/client 中导出

当我们调用 ReactDOM.createRoot 方法时,createRoot 函数会调用 listenToAllSupportedEvents 方法

listenToAllSupportedEvents 函数定义在 react-dom-bindings/src/events/DOMPluginEventSystem 文件中

DOMPluginEventSystem 文件被执行时,react 会进行事件注册:

SimpleEventPlugin.registerEvents();
EnterLeaveEventPlugin.registerEvents();
ChangeEventPlugin.registerEvents();
SelectEventPlugin.registerEvents();
BeforeInputEventPlugin.registerEvents();

可以看到,react 将事件分为五类,分别由不同的事件插件进行处理:

  • SimpleEventPlugin 处理简单事件,例如 onClick
  • EnterLeaveEventPlugin 处理鼠标进出事件,例如 onMouseEnter
  • ChangeEventPlugin 处理修改事件,例如 onChange
  • SelectEventPlugin 处理选择事件,例如 onSelect
  • BeforeInputEventPlugin 处理输入前事件,例如 onBeforeInput

这些事件插件负责将浏览器原生事件转换为 react 事件,并将其分发到正确的组件中

事件注册前准备

下面以 SimpleEventPlugin 为例,讲解事件注册的流程。了解了 SimpleEventPlugin.registerEvents,其他四个事件也就更容易理解了。

虽然这个函数名叫做 registerEvents,但实际上这个函数还没到注册事件的步骤,它只是准备事件名。为此,这个函数会提供三个变量(见【获取所有事件】)。

简单事件

简单事件指只依赖自身的事件,例如 onClick 只依赖 click

有些事件依赖其他事件,例如 onMouseEnter 依赖 ["mouseout", "mouseover"]react 这么做简化了开发者自己监听事件的步骤。否则,开发者自己实现 onMouseEnter 这种事件,需要分别监听 mouseoutmouseover 事件。

获取所有事件

react 中的事件都是硬编码的,保存在 simpleEventPluginEvents 变量中

const simpleEventPluginEvents = ["click", "mousedown", "mouseenter", ...];

遍历 simpleEventPluginEvents 列表,react 将事件名处理成 domEventNamereactEventName,也就是 react 事件名和 dom 事件名的对应关系。

  • domEventNamedom 事件名全小写,例如 mousedownmouseenter
  • reactEventNamereact 事件名是驼峰形式,例如 onClickonMouseDown

react 中,为了处理 dom 事件,react 定义了一些与原生 dom 事件名对应的 react 事件名。

这些事件名以 on 开头,比如 onClickonFocus 等。同时,react 还定义了一些事件之间的依赖关系,即某些事件需要依赖于其他事件才能正常工作

为了实现这些功能,react 使用了三个变量(详细的事件名在文章底部):

  1. topLevelEventsToReactNames:这个变量是一个 Map,保存着原生 dom 事件名和对应的 react 事件名之间的映射关系。例如,click 事件对应着 onClick 事件。一共有 75 个映射关系
  2. registrationNameDependencies:这个变量是一个普通的对象,保存着 react 事件名和依赖事件名之间的关系。例如,onClick 事件依赖于 click 事件。一共有 166 个依赖关系。
  3. allNativeEvents:这个变量是一个 Set,保存了所有原生 dom 事件名。一共有 81 个事件名。

特殊处理

这里有 7 个事件不在 SimpleEventPluginEvents 变量中,因为它们是需要特殊处理的。

这些事件包括:

  • onAnimationEnd
  • onAnimationIteration
  • onAnimationStart
  • onDoubleClick
  • onFocus
  • onBlur
  • onTransitionEnd

其中,与 AnimationTransition 相关的事件分别是 AnimationEventTransitionEvent

由于浏览器兼容性的问题,react-dom 通过函数 getVendorPrefixedEventName 来实现对它们的兼容性处理(源码)

另外,对于 onDoubleClickonFocusonBlur 这三个事件,它们的 reactEventName 与对应的 domEventName 不同,因此需要特殊处理:

  • onDoubleClick 对应的 domEventNamedbclick
  • onFocus 对应的 domEventNamefocusin
  • onBlur 对应的 domEventNamefocusout

这些细节处理有助于确保 react 应用程序在不同浏览器上的正确运行

事件注册

react 中,事件注册是非常重要的,因为它关系到组件的交互和性能

react 事件注册来自于 listenToAllSupportedEvents 函数

事件注册分为 3 种情况:

  • 绑定在 document
  • 绑定在 div#root
  • 绑定在目标元素 target

react@18 开始,事件绑定在页面根元素中(也就是 div#root),不再绑定在 document

绑定在 document

只有 selectionchange 事件是绑定在 document

绑定在 target

这些事件是不会冒泡的(不需要委托),它们的事件是绑定在事件事件发生的元素身上,这部分事件有普通事件和媒体事件组成,都是硬编码在代码中

绑定在 div#root

allNativeEvents 中排除掉 nonDelegatedEvents 事件,剩下的事件都是绑定在 div#root

注册

在事件注册前判断浏览器是否支持 passive,如果支持,则将 passive 设置为true,浏览器永远不会调用 event.preventDefault(),用于提升性能

这个属性用于 touchstarttouchmovewheel 事件中

然后调用函数分别注册事件,源码

  • addEventBubbleListener:冒泡事件
  • addEventCaptureListener:捕获事件
  • addEventCaptureListenerWithPassiveFlag:捕获事件,passivetrue
  • addEventBubbleListenerWithPassiveFlag: 冒泡事件,passivetrue

在注册事件时事件监听器 react 会经过一系列的处理,最后返回一个 (e) => { .... }

react 处理的这一步,我们叫做合成事件

事件优先级

react 将事件优先级分为 4 种:

  • 离散事件优先级,例如:点击事件,input 输入等触发的更新任务,优先级最高 SyncLane
  • 连续事件优先级,例如:滚动事件,拖动事件等,连续触发的事件 优先级次之,为 InputContinuousLane
  • 默认事件优先级,例如:setTimeout 触发的更新任务,为 DefaultLane
  • 闲置事件优先级,优先级最低,为 IdleLane

react 在事件注册时根据事件名设置不同的优先级,getEventPriority

特殊处理了 message 事件(它的优先级是根据 Scheduler 回调来调度的)

事件队列(合成事件)

这一部分是 react-dom 的核心,react 这么做的目的是为了解决各浏览器之间的差异

事件队列 dispatchQueue 包含两个参数:

  • event:对应的是 react 合成事件,见【收集合成事件】
  • listeners[]:目标节点对应的事件监听器,从目标节点开始,一直到祖先节点,见【收集事件监听器】

收集合成事件

合成事件 SyntheticEventreact 最核心的一部分

提取合成事件也分为 5 个插件:

  • SimpleEventPlugin.extractEvents(...)
  • EnterLeaveEventPlugin.extractEvents(...)
  • ChangeEventPlugin.extractEvents(...)
  • SelectEventPlugin.extractEvents(...)
  • BeforeInputEventPlugin.extractEvents(...)

其中 EnterLeaveEventPluginChangeEventPluginSelectEventPluginBeforeInputEventPlugin 插件的提取事件只在冒泡阶段执行

这些函数内部,react 都会创建一个基本的合成事件,然后再根据事件名分成若干种合成事件

这些合成事件都是由 createSyntheticEvent 函数创建的

react 按照事件名分成了 12 种合成事件:

合成事件有一些基本属性(其他属性对应不同的对应不同的实例,可以点击上面查看源码):

  • _reactNamereact 事件名
  • _targetInsttarget 对应的 fiber
  • type:原生事件类型,比如 click
  • nativeEvent:原生事件信息,event
  • target:原生节点,比如 onClick
  • preventDefaultreact 实现
  • stopPropagationreact 实现
  • persistreact 实现
  • isPersistentreact 实现

除了这些属性之外,reactnativeEvent 中信息都提取到了合成事件中,通过下面这段代码实现

// Interface 是 react 定义的各种合成事件
// nativeEvent 是原生事件
for (const propName in Interface) {
  if (!Interface.hasOwnProperty(propName)) {
    continue;
  }
  const normalize = Interface[propName];
  // normalize 如果存在,说明 propName 对应的属性在合成事件中是一个函数
  // react 这么设计的原因是为了消除不同浏览器之间的差异
  // 见[WheelEventInterface 事件]
  if (normalize) {
    this[propName] = normalize(nativeEvent);
  } else {
    this[propName] = nativeEvent[propName];
  }
}

WheelEventInterface

这里用 WheelEventInterface 事件来说明 react 为什么要定义 normalize 源码

比如 wheel 事件中的 deltaY 属性:

  • webkit 中是 wheelDeltaY
  • IE9 以下是 wheelDelta

react 把这些可能存在兼容问题的属性值写成了函数,没有兼容问题的属性值写成了 0

当代码执行到这里时,react 会根据它自身的判断来达到消除浏览器之间的差异

收集事件监听器

遍历目标节点(target)到祖先节点(div#root),找到所有注册了该事件类型的监听器,将它们存储在一个数组中:

listeners = [targetListener, ..., rootListener]

监听器的属性包括:

  • instancedom 节点对应的 fiber
  • listener:监听器函数
  • currentTarget:目标节点 target

在收集监听器的函数中,react 从目标节点开始向上遍历,instance.return 得到的值是当前节点的父节点

// 目标节点
let instance = targetFiber;
while (instance !== null) {
  // 用 vite 创建的项目
  // stateNode: img,            tag: 5
  // stateNode: a ,             tag: 5
  // stateNode: div,            tag: 5
  // stateNode: div.app,        tag: 5
  // stateNode: null,           tag: 5
  // stateNode: null,           tag: 8
  // stateNode: FiberRootNode,  tag: 3
  // tag 类型是 WorkTag,文件:packages/react-reconciler/src/ReactWorkTags.js
  // stateNode 与 fiber 相关的 dom
  const { stateNode, tag } = instance;

  // 省略若干代码 ...

  const listener = getListener(instance, reactEventName);
  if (listener != null) {
    listeners.push(
      createDispatchListener(instance, listener, lastHostComponent)
    );
  }

  // 省略若干代码 ...

  // 当前节点的父节点
  instance = instance.return;
}

当监听器和合成事件都准备好后,将他们放入 dispatchQueue 队列中

执行事件队列

执行事件队列分为冒泡和捕获阶段

在上一节中我们得到事件监听器是这样存储的

listeners = [targetListener, ..., rootListener]

再执行事件队列时,需要判断当前处于什么阶段:

  • 如果当前是捕获阶段,从根节点开始(也就是从队列的最后一个开始执行)
  • 如果当前是冒泡节点,从目标节点开始(也就是从队列的第一个开始执行)

在执行回调函数时,React 会记录错误并继续执行应用程序,它确保了在 React 组件中发生的错误不会导致整个应用程序崩溃

总结

  1. 在调用 listenToAllSupportedEvents 函数时,react-dom-bindings/src/events/DOMPluginEventSystem 被执行,初始化事件挂载相关的参数
    • topLevelEventsToReactNames
    • registrationNameDependencies
    • allNativeEvents
  2. 事件注册,将事件挂载到对应的节点
  3. 处理事件监听器
    • 合成事件
    • 事件监听器收集
  4. 事件触发时执行事件

一个不理解的地方

事件队列是一个数组 Array<DispatchEntry>

深入探究 React 原生事件的工作原理

我测试了多种事件,dispatchQueue 都没有出现多项的情况,冒泡或者捕获的事件时存放在 listeners 属性中的,所以我不知道在什么情况下会 dispatchQueue 出现多项

react 中事件名映射

  1. allNativeEvents

    深入探究 React 原生事件的工作原理

  2. topLevelEventsToReactNames

    深入探究 React 原生事件的工作原理

  3. registrationNameDependencies

    深入探究 React 原生事件的工作原理

    深入探究 React 原生事件的工作原理