React18事件机制DOMPluginEventSystem解读

584 阅读13分钟

本文基于React版本18.0.0源码

简介

ReactJS 事件简介

一. 事件注册

ReactJS现阶段事件系统中提供了五个处理合成事件的Plugin,它们的主要职责将原生事件与ReactJS合成事件进行关联,能在事件处理阶段将对应的原生事件转换成最终ReactJS能处理的合成事件。

// 当前React18提供了以下5个合成事件Plugin
SimpleEventPlugin.registerEvents(); //  SimpleEventPlugin 处理一些常用的基础事件,详细请参见DOMEventProperties.js 中的registerSimpleEvents注册函数
EnterLeaveEventPlugin.registerEvents(); //  鼠标移动事件
ChangeEventPlugin.registerEvents(); //  修改事件
SelectEventPlugin.registerEvents(); // 	处理选择事件
BeforeInputEventPlugin.registerEvents(); //  处理控件输入前事件

EventRegistry.js为关联合成事件提供了两个API函数,registerEvents使用提供的API对数据结构registrationNameDependenciesallNativeEvents进行数据的更新:

  1. registerTwoPhaseEvent: 维护冒泡与捕获事件映射关系
  2. registerDirectEvent: 只提供了冒泡事件名称映射维护

EventRegistry.js_registerTwoPhaseEvent.png

同时支持冒泡捕获的事件,将调用两次registerDirectEvent,绑定两条映射关系,捕获事件将携带Capture字样 注册合成事件插件映射关系

1.1 registrationNameDependencies 合成事件与原生事件映射关系

shotcut_registrationNameDependencies key值为React 组件Props支持的监听函数,value为处理key事件所对应的Native事件名称

1.2 allNativeEvents 所有支持的Native事件

所有支持的原生事件

1.3 XXXXEventPlugin 功能

react_DOMPluginEventSystem_XXXXEventPlugin 每个Plugin承担着对合成事件的注册与收集功能,即 registerEventsextractEvents两个函数 :

函数功能
registerEvents执行ReactDOMRoot.createRoot初始化前(可以理解为事件绑定前),将合成事件名称与原生事件名称通过registrationNameDependencies全局变量来维护映射关系
extractEvents在事件触发阶段(可阅读dispatchEventsForPlugins函数),会通过domEventName去匹配创建SyntheticEvent(合成事件),并将事件目标节点及父节点的事件回调放入listener数组,最终将listenersevent pushDispatchQueue中,最终由executeDispatch完成apply调用

Plugin承担着原生事件匹配合成事件的重要工作,根据事件类型划分到多个Pluginpassivecapture属性影响着最终挂载在DOM节点上的事件监听数量。

二. 事件绑定

react_DOMPluginEventSystem 绑定合成事件流程图 从流程图可以看出React最终注册EventListener也是通过DOM提供的addEventListener来完成事件的绑定,唯一不同的是React的addEventListener全部绑定在rootContainercreateRoot函数传递进来的根DOM节点),从而通过代理的方式完成整个ReactJS生态链中的事件体系。

  1. createRoot阶段调用listenToAllSupportedEvents将所有allNativeEvents原生事件进行绑定
  2. nonDelegatedEvents 维护了不需要委托代理的事件,isCapturePhaseListener 将设置为了false,listenToNativeEvent将不会去注册捕获事件。listenToNativeEvent 最终执行的是addTrappedEventListenerisCapturePhaseListener被转化为eventSystemFlags来携带捕获事件、冒泡事件的信息,对应十进制4 表示捕获,0 表示冒泡
  3. getEventPriority 维护了不同事件对应的执行级别ContinuousEventPriority, DefaultEventPriority, DiscreteEventPriority getCurrentUpdatePriority,IdleEventPrioritycreateEventListenerWrapperWithPriority 通过bind偏函数的特性将domEventName, eventSystemFlags, targetContainer携带到DOM原生listener
  4. 针对支持Passivetouchstarttouchmovewheel 开启了 { passive : true }, 通过 isCapturePhaseListener 是否支持捕获来选择 调用 addEvent____ListeneraddEvent____ListenerWithPassiveFlagBubble :冒泡、Capture: 捕获

注: 在listenToNativeEvent 执行过程中selectionchange,将事件绑定到 Document 元素

2.1 addTrappedEventListener函数

function addTrappedEventListener(
  targetContainer: EventTarget, 										// 事件挂载节点,为createRoot传入的根DOM节点,一般情况下大家都喜欢使用root来定义根DOM即 div#root
  domEventName: DOMEventName,  											// 原生浏览器事件名称(小写),例如DOMEventProperties.js 中simpleEventPluginEvents 列举了一些常用的
  eventSystemFlags: EventSystemFlags, 							// 4 表示捕获,0 表示冒泡,详细位移计算请到EventSystemFlags.js查阅
  isCapturePhaseListener: boolean,    							// true 表示捕获阶段,false 表示冒泡阶段
  isDeferredListenerForLegacyFBSupport?: boolean,
) 

addTrappedEventListener入参listenToAllSupportedEvents执行完绑定操作后,通过浏览器的Event Listeners 工具去观察 div#root 事件,allNativeEvents中注册的事件已经全部被绑定。 addTrappedEventListener 在root节点上绑定的事件listener

并且Listener都指向了ReactDOMEventListener.js源文件,唯一不同的是对应着不同的function函数,根据原生是否支持冒泡、捕获、以及passive属性对应的事件会绑定多个listener

事件函数
离散事件dispatchDiscreteEvent
持续触发事件dispatchContinuousEvent
默认事件dispatchEvent

2.2 createEventListenerWrapperWithPriority函数

getEventPriority 根据原生事件domEventName去匹配对应的优先级别,该函数优先级主要划分了DiscreteEventPriorityContinuousEventPriorityDefaultEventPriority三个标准,当然domEventName值为message,返回的优先级别会依赖getCurrentSchedulerPriorityLevel的结果。


export function createEventListenerWrapperWithPriority(
  targetContainer: EventTarget,
  domEventName: DOMEventName,
  eventSystemFlags: EventSystemFlags,
): Function {
	// 根据domEventName 匹配对应优先级,重点关注:DiscreteEventPriority、ContinuousEventPriority、DefaultEventPriority
  const eventPriority = getEventPriority(domEventName);
  let listenerWrapper;
  // 根据事件名绑定不同的dispatchXXXXEvent函数
  switch (eventPriority) {
    case DiscreteEventPriority: 
      listenerWrapper = dispatchDiscreteEvent;
      break;
    case ContinuousEventPriority: 
      listenerWrapper = dispatchContinuousEvent;
      break;
    case DefaultEventPriority: 
    default:
      listenerWrapper = dispatchEvent;
      break;
  }
  
  //最终绑定到DOM节点的事件,addEventListener(type: string, callback: EventListenerOrEventListenerObject | null, options?: AddEventListenerOptions | boolean): void;
  return listenerWrapper.bind(
    null,
    domEventName,
    eventSystemFlags,
    targetContainer,
  );
}

createEventListenerWrapperWithPriority的返回值是什么?从上面代码片段得知最终只是返回了一个dispatchEventXXXbind新函数,从入参可以获取到最终的bind需要的所有参数, createEventListenerWrapperWithPriority入参 接下来只需要知道bind干了什么?

function.bind(thisArg[, arg1[, arg2[, ...]]])

// 伪代码,这里涉及到了偏函数、柯里化函数的概念
// thisArg 不需要携带this对象,传null即可,从第二个参数开始为返回值函数的入参
(null, domEventName, 'click', 0) => (nativeEvent) => void

//最终 createEventListenerWrapperWithPriority返回值为如下,这样就返回值就是一个 只需要一个可变入参的函数(这里只需要在传递一个nativeEvent)。
(nativeEvent) => dispatchEvent(null, domEventName, 'click', 0, nativeEvent)

2.3 关于EventListener.js绑定函数addEvent____ListeneraddEvent____ListenerWithPassiveFlag

EventListener.js提供的原生DOM绑定事件函数API,只是将listenerWrapper.bind生成的偏函数挂载到对应DOM节点。

注: 无论是哪种事件最终都会通过调用ReactDOMEventListener.js中的dispatchEvent 来完成最终事件处理,区别仅在于不同合成事件的执行优先级别不一样

三. 合成事件响应

ReactJS_DOMPluginEventSystem_dispatcher 无论是哪种事件类型的dispatchEvent在绑定阶段时就以 EventListener的形式绑定到DOM上(createRoot的入参DOM节点),想了解具体事件的走向,重点关注ReactDOMEventListener.js中个的dispatchEvent默认事件、dispatchContinuousEvent持续事件、dispatchDiscreteEvent离散事件。

  1. findInstanceBlockingEvent 寻找触发事件的FiberDOM 目标节点,这里需要注意的是返回值是阻塞事件的Fiber对象,真正触发事件的Fiber通过return_targetInst全局变量来维护
  2. dispatchEventForPluginEventSystem筛选每个Plugin中的extractEvents去匹配并创建合成事件(具体请参见SyntheticEvent.js中的createSyntheticEvent),accumulate____PhaseListeners格式函数负责收集从触发节点开始往父级一层一层寻找目标节点的Listener将其添加到dispatchQueue队列中
  3. executeDispatch 完成对合成事件的消费

事件示例

构造一个简单的多层级DOM示例结构,每一层级绑定一个onClick事件Listener 事件分发测试代码 观察一下最终DispatchQueue队列中listener存放情况 事件处理DispatchQueue队列

3.1 寻找触发事件的Target元素

findInstanceBlockingEvent该函数主要负责从nativeEvent原生事件中提取触发事件的Fiber节点,当 寻找到触发事件的Fiber后通过ReactDOMEventListener.js文件中的return_targetInst变量缓存目标Fiber节点实例

// Returns a SuspenseInstance or Container if it's blocked.
// The return_targetInst field above is conceptually part of the return value.
export function findInstanceBlockingEvent(
  domEventName: DOMEventName,
  eventSystemFlags: EventSystemFlags,
  targetContainer: EventTarget,
  nativeEvent: AnyNativeEvent,
): null | Container | SuspenseInstance {
  // TODO: Warn if _enabled is false.

  return_targetInst = null;
  
  // 从原生事件中提取触发事件的DOM节点
  const nativeEventTarget = getEventTarget(nativeEvent);
  
  // 提取触发事件的DOM中所包含的React Fiber对象,当该DOM不存在Fiber对象,则会一级一级往上寻找父节点,
  // 并且获取Fiber
  let targetInst = getClosestInstanceFromNode(nativeEventTarget);

  if (targetInst !== null) {
    // 主要为了检查触发事件的节点Fiber所在的父节点有没有被卸载,如未卸载继续返回当前节Fiber,否则返回null
    const nearestMounted = getNearestMountedFiber(targetInst);
    if (nearestMounted === null) {
      // 当所属于的父节点被卸载了,则该事件将不会继续处理,return_targetInst 也将返回null
      // This tree has been unmounted already. Dispatch without a target.
      targetInst = null;
    } else {
      // tag 如果为HostRoot(根)或SuspenseComponent(懒加载组件) 时,直接返回阻塞节点Fiber,
      // dispatchEvent将直接return 一个对象,dispatchEventForPluginEventSystem将判断其返回值存在,放弃继续处理事件
      const tag = nearestMounted.tag;
      if (tag === SuspenseComponent) {
        const instance = getSuspenseInstanceFromFiber(nearestMounted);
        if (instance !== null) {
          // Queue the event to be replayed later. Abort dispatching since we
          // don't want this event dispatched twice through the event system.
          // TODO: If this is the first discrete event in the queue. Schedule an increased
          // priority for this boundary.
          return instance;
        }
        // This shouldn't happen, something went wrong but to avoid blocking
        // the whole system, dispatch the event without a target.
        // TODO: Warn.
        targetInst = null;
      } else if (tag === HostRoot) {
        const root: FiberRoot = nearestMounted.stateNode;
        if (isRootDehydrated(root)) {
          // If this happens during a replay something went wrong and it might block
          // the whole system.
          return getContainerFromFiber(nearestMounted);
        }
        targetInst = null;
      } else if (nearestMounted !== targetInst) {
        // If we get an event (ex: img onload) before committing that
        // component's mount, ignore it for now (that is, treat it as if it was an
        // event on a non-React tree). We might also consider queueing events and
        // dispatching them after the mount.
        targetInst = null;
      }
    }
  }
  
  // 最终触发事件DOM节点的Fiber
  return_targetInst = targetInst;
  // We're not blocked on anything.
  return null;
}
  1. getEventTarget : 提取nativeEvent 事件中的DOM信息
  2. getClosestInstanceFromNode:根据DOM信息提取节点中的Fiber对象,即包含'__reactFiber$'属性的信息
  3. getNearestMountedFiber: 检测Fiber 所属 的顶层父节点是否已经被卸载,即顶层父节点是否为node.tag === HostRoot
  4. getSuspenseInstanceFromFiber
  5. getContainerFromFiber 判断传入的Fiber是否为HostRoot,成立则获取stateNode.containerInfo,否则为null

3.2 事件委托分发处理

DOMPluginEventSystem.js中的``dispatchEventsForPlugins`函数为最终触发阶段的核心函数,比较完整的展示了执行流程:

  1. 事件的收集与匹配
  2. 事件队列dispatchQueue事件的消费执行

事件派发流程图

  1. dispatchEventForPluginEventSystem 函数首先验证 targetInst 的合法性,并检查 targetContainer是否与当前根节点Fiber 中的 container 相等。若条件符合,则通过 batchedUpdates执行dispatchEventsForPlugins` 函数
  2. extractEvents 会调用合成事件系统中注册的 Plugins 中的 extractEvents 方法,根据 domEventName 创建合成事件 SyntheticEvent
  3. 合成事件 Plugins.extractEvents 会调用 DOMPluginEventSystem.js 中以 accumulate_____PhaseListeners 命名的函数,将 Fiber 节点注册的合成事件监听器转换为 DispatchListener,并最终将 { event: SyntheticEvent, listeners: [DispatchListener] } 的格式存放到 dispatchQueue
  4. executeDispatch 以冒泡的方式执行 dispatchQueue 中的 listeners

3.2.1 事件的匹配与收集

DispatchQueue 队列由 DOMPluginEventSystem.js 中的 extractEvents 函数完成合成事件的匹配与收集工作。extractEvents 是一个同步函数,按照 SimpleEventPlugin -> EnterLeaveEventPlugin -> ChangeEventPlugin -> SelectEventPlugin -> BeforeInputEventPlugin 的顺序,依次筛选需要处理的合成事件及其对应的冒泡事件监听器。

事件处理DispatchQueue队列

extractEvents主要由两部分组合而成:

  1. 合成事件生成: 根据domEventName原生事件名称(全小写英文单词)去匹配合适的EventPlugin插件,匹配生成SyntheticEvent合成事件
  2. 获取DOM节点上的合成事件listener函数:DOMPluginEventSystem.js提供了accumulate____PhaseListeners格式的函数,该系列函数主要通过reactName 去匹配获取合成事件listener,在递归搜索targetFiber以及所有的父节点的EventListener回调(针对持续触发事件存在部分差异,详细请阅读accumulateTwoPhaseListenersaccumulateEnterLeaveTwoPhaseListeners),最终存放至dispatchQueue

如下图所示: Fiber 节点的Eventlistener在内存中的结构

3.2.2 事件的消费执行

// 触发dispatch队列里面的事件
export function processDispatchQueue(
  dispatchQueue: DispatchQueue,  			// extractEvents 匹配与收集的事件缓存
  eventSystemFlags: EventSystemFlags, // 主要包含了 冒泡、捕获这些事件设置,由绑定阶段的createEventListenerWrapperWithPriority 通过bind传递而来
): void {
  const inCapturePhase = (eventSystemFlags & IS_CAPTURE_PHASE) !== 0;
  
  // 触发当前节点递归到根节点路径上同名的事件
  // 该顺序主要是保持了SimpleEventPlugin -> EnterLeaveEventPlugin -> ChangeEventPlugin -> SelectEventPlugin -> BeforeInputEventPlugin
  for (let i = 0; i < dispatchQueue.length; i++) {
    const { event, listeners } = dispatchQueue[i];
    processDispatchQueueItemsInOrder(event, listeners, inCapturePhase);
    //  event system doesn't use pooling.
  }
  // This would be a good time to rethrow if any of the event handlers threw.
  rethrowCaughtError();
}

processDispatchQueueItemsInOrder 函数职责:

  1. stopPropagation() 方法防止调用相同事件的传播
  2. 保证了合成事件的执行顺序: 冒泡事件、捕获事件
// 
function processDispatchQueueItemsInOrder(
  event: ReactSyntheticEvent,
  dispatchListeners: Array<DispatchListener>,
  inCapturePhase: boolean,
): void {
  let previousInstance;
    
  // 在前文已经介绍了listeners的存放顺序 
  // react事件系统的冒泡、捕获最终通过数组的正反序读取来决定
  if (inCapturePhase) {
     // 捕获事件
     // 触发顺序为: 从子节点到父节点的顺序依次执行
    for (let i = dispatchListeners.length - 1; i >= 0; i--) {
      const { instance, currentTarget, listener } = dispatchListeners[i];
      
      // 判断执行队列中是否有listener调用了stopPropagation(),检测到后会中断后续的listener执行
      if (instance !== previousInstance && event.isPropagationStopped()) {
        return;
      }
      
      // 消费处理事件
      executeDispatch(event, listener, currentTarget);
      previousInstance = instance;
    }
  } else {
    // 冒泡事件
    for (let i = 0; i < dispatchListeners.length; i++) {
      const { instance, currentTarget, listener } = dispatchListeners[i];
      
      // 判断执行队列中是否有listener调用了stopPropagation(),检测到后会中断后续的listener执行
      if (instance !== previousInstance && event.isPropagationStopped()) {
        return;
      }
      
      // 消费处理事件
      executeDispatch(event, listener, currentTarget);
      previousInstance = instance;
    }
  }
}

function executeDispatch(
  event: ReactSyntheticEvent,
  listener: Function,
  currentTarget: EventTarget,
): void {
  const type = event.type || 'unknown-event';
  event.currentTarget = currentTarget;
  // invokeGuardedCallbackAndCatchFirstError 在 React 中起到了保护作用,通过捕获和处理用户代码中的错误,确保即使在发生错误时,React 应用也能继续运行。这对于提高应用的稳定性和用户体验至关重要。感兴趣的可以阅读一下shared/ReactErrorUtils
  invokeGuardedCallbackAndCatchFirstError(type, listener, undefined, event);
  event.currentTarget = null;
}

本质上其实通过try catch保证页面执行函数异常不中断。

export function invokeGuardedCallbackAndCatchFirstError<
  A,
  B,
  C,
  D,
  E,
  F,
  Context,
>(
  name: string | null,
  func: (a: A, b: B, c: C, d: D, e: E, f: F) => void,
  context: Context,
  a: A,
  b: B,
  c: C,
  d: D,
  e: E,
  f: F,
): void {
  invokeGuardedCallback.apply(this, arguments);
  if (hasError) {
    const error = clearCaughtError();
    if (!hasRethrowError) {
      hasRethrowError = true;
      rethrowError = error;
    }
  }
}

//invokeGuardedCallback 的实现
function invokeGuardedCallbackProd<A, B, C, D, E, F, Context>(
  name: string | null,
  func: (a: A, b: B, c: C, d: D, e: E, f: F) => mixed,
  context: Context,
  a: A,
  b: B,
  c: C,
  d: D,
  e: E,
  f: F,
) {
  const funcArgs = Array.prototype.slice.call(arguments, 3);
  try {
    func.apply(context, funcArgs);
  } catch (error) {
    this.onError(error);
  }
}

四. React事件DOM原生事件的差异

1. 事件处理方式

React 合成事件

  • 合成事件:React 使用合成事件(Synthetic Events),这是一个跨浏览器包装器,标准化了事件对象和行为,提供一致的接口。
  • 事件委托:React 通过事件委托机制,将所有事件处理程序附加到根元素上,而不是每个子元素。这提高了性能和内存使用效率,特别是在有大量子元素的情况下。
  • 统一接口:合成事件为所有事件提供了统一的接口,消除了跨浏览器差异。

原生 DOM 事件

  • 原生事件:原生 DOM 事件是由浏览器直接触发的事件,每个事件处理程序直接附加到对应的 DOM 元素上。
  • 事件处理:每个元素都可以独立地处理自己的事件,这可能导致在复杂应用中存在大量事件处理程序,从而增加内存和性能开销。
  • 跨浏览器差异:原生 DOM 事件在不同浏览器中可能表现不同,开发者需要处理这些差异。

2. 性能优化

React 合成事件

  • 标准化:合成事件为所有事件提供一致的接口,消除了浏览器之间的差异,使得开发者不必处理不同浏览器中的细微差别。
  • 稳定性:通过合成事件,React 可以在各种浏览器和版本中提供稳定的事件处理行为。

原生 DOM 事件

  • 差异化:不同浏览器对事件的实现可能存在差异,开发者需要编写额外的代码来处理这些差异,确保跨浏览器兼容性。

3. 跨浏览器兼容性

React 合成事件

  • 标准化:合成事件为所有事件提供一致的接口,消除了浏览器之间的差异,使得开发者不必处理不同浏览器中的细微差别。
  • 稳定性:通过合成事件,React 可以在各种浏览器和版本中提供稳定的事件处理行为。

原生 DOM 事件

  • 差异化:不同浏览器对事件的实现可能存在差异,开发者需要编写额外的代码来处理这些差异,确保跨浏览器兼容性。

4. 事件命名

React 合成事件

  • 驼峰命名法:React 使用驼峰命名法来定义事件处理程序,例如 onClickonMouseEnter 等。
  • 一致性:所有事件处理程序都遵循一致的命名规则,便于阅读和维护。

原生 DOM 事件

  • 小写命名法:原生 DOM 事件通常使用小写命名法,例如 onclickonmouseenter 等。
  • 不一致性:不同事件的命名规则可能不一致,增加了理解和使用的复杂性。