详解React合成事件

什么是 React 合成事件

React 合成事件(SyntheticEvent)是 React 模拟原生 DOM 事件所有能力的一个事件对象 ,即浏览器原生事件的跨浏览器包装器。它根据 W3C 规范 来定义合成事件,兼容所有浏览器,拥有与浏览器原生事件相同的接口。

众所周知,fiber的创建发生在beginWork时,fiber对应的dom元素生成发生在completeWork时,所以有fiber时不一定有对应dom,像onclick这样的事件看似绑定到DOM元素上,但实际事件处理函数作为fiber节点的prop,并不能直接被绑定到真实的DOM节点上,那么React为了解决这个问题,就提供了一种"顶层注册,事件收集,统一触发"的事件机制。

所谓的react事件机制,实际是在root元素上绑定一个统一的事件监听函数(listener),绑定到root上的事件监听不是我们在组件里写的事件处理函数,而是一个持有事件优先级,并能传递事件执行阶段标志(isCapture)的监听器,root上的事件处理函数被执行时,构造合成事件对象,按照冒泡或捕获的路径去组件中收集真正的事件处理函数,在收集过程之后,对所收集的事件逐一执行(这里可能对事件进行归类,在事件产生的任务上包含不同的优先级),并共享同一个合成事件对象(合成事件对象可以抹平浏览器的兼容性差异)。

一个完整的生命周期:

js 复制代码
const button = <button onClick={handleClick}>点击我</button>
  1. JSX 转换为 React 元素 : JSX 元素 <button onClick={handleClick}>点击我</button> 会被转换为以下形式的 React 元素对象:
js 复制代码
const buttonElement = React.createElement(
  'button',
  { onClick: handleClick },
  '点击我'
);
  1. React 元素对象buttonElement 现在是一个普通的 JavaScript 对象,表示一个 React 元素,它包含了元素的类型、属性和子元素等信息:
js 复制代码
{
  type: 'button',
  props: {
    onClick: handleClick,
    children: '点击我'
  }
}
  1. Fiber 对象: React 使用 Fiber 架构来管理组件的渲染和更新。当渲染这个 React 元素时,它会在 Fiber 树中生成对应的 Fiber 节点(Fiber 对象)来表示这个元素。一个简化的 Fiber 对象可能如下所示:
js 复制代码
const fiberNode = {
  tag: 'HostComponent',
  elementType: 'button',
  stateNode: buttonInstance, // 指向实际的 DOM 节点
  return: parentNodeFiber,   // 指向父级 Fiber 节点
  props: {
    onClick: handleClick
  },
  ...
};
  1. 事件识别: 当这个fiber节点进入render阶段的complete阶段时,名称为onClick的prop会被识别为事件进行处理。
js 复制代码
function setInitialDOMProperties(
  tag: string,
  domElement: Element,
  rootContainerElement: Element | Document,
  nextProps: Object,
  isCustomComponentTag: boolean,
): void {
  for (const propKey in nextProps) {
    if (!nextProps.hasOwnProperty(propKey)) {
      ...
    } else if (registrationNameDependencies.hasOwnProperty(propKey)) {
        // 如果propKey属于事件类型,则进行事件绑定
        ensureListeningTo(rootContainerElement, propKey, domElement);
      }
    }
  }
}

registrationNameDependencies是一个对象,存储了所有React事件对应的原生DOM事件的集合,这是识别prop是否为事件的依据。如果是事件类型的prop,那么将会调用ensureListeningTo去绑定事件。

  1. 事件绑定 :根据React的事件名称寻找该事件依赖,onClick只依赖了click一个原生事件,并根据组件中写的事件名识别其属于哪个阶段的事件(冒泡或捕获),例如onClickCapture这样的React事件名称就代表是需要事件在捕获阶段触发,而onClick代表事件需要在冒泡阶段触发。最终调用addEventListener,在root上绑定一个click事件监听器listener。
js 复制代码
  // 根据事件名称,创建不同优先级的事件监听器。
  let listener = createEventListenerWrapperWithPriority(
    targetContainer,
    domEventName,
    eventSystemFlags,// 传入事件执行阶段的标志
    listenerPriority,
  );

  // 绑定事件
  if (isCapturePhaseListener) {
    ...
    unsubscribeListener = addEventCaptureListener(
      targetContainer,
      domEventName,
      listener,
    );
  } else {
    ...
    unsubscribeListener = addEventBubbleListener(
      targetContainer,
      domEventName,
      listener,
    );

  }

listener是createEventListenerWrapperWithPriority函数依据优先级创建一个事件监听包装器,这里的优先级是根据事件的交互程度划分的,优先级和事件名的映射关系存在于一个Map结构中,createEventListenerWrapperWithPriority会根据事件名或者传入的优先级返回不同级别的事件监听包装器。总的来说,会有三种事件监听包装器:

  • dispatchDiscreteEvent: 处理离散事件
  • dispatchUserBlockingUpdate:处理用户阻塞事件
  • dispatchEvent:处理连续事件

事件监听器listener负责以不同的优先级权重来触发真正的事件流程,并传递事件执行阶段标志(eventSystemFlags)。

  1. 事件触发: 点击onClick事件的时候,绑定到root上的事件监听listener相当于一个传令官,它按照事件的优先级 去安排接下来的工作:事件对象的合成将事件处理函数收集到执行路径事件执行,这样在后面的调度过程中,scheduler才能获知当前任务的优先级,然后展开调度。
js 复制代码
function dispatchUserBlockingUpdate(
  domEventName,
  eventSystemFlags,
  container,
  nativeEvent,
) {
    ...
    runWithPriority(
      UserBlockingPriority,
      dispatchEvent.bind(
        null,
        domEventName,
        eventSystemFlags,
        container,
        nativeEvent,
      ),
    );
}

dispatchUserBlockingUpdate调用runWithPriority,并传入UserBlockingPriority优先级,这样就可以将UserBlockingPriority的优先级记录到Scheduler中,后续React计算各种优先级都是基于这个UserBlockingPriority优先级。

除了传递优先级,它做的其它重要的事情就是触发事件对象的合成将事件处理函数收集到执行路径事件执行 这三个过程,也就是到了事件的执行阶段。root上的事件监听最终触发的是dispatchEventsForPlugins

这个函数体可看成两部分:事件对象的合成和事件收集事件执行,涵盖了上述三个过程。

js 复制代码
function dispatchEventsForPlugins(
  domEventName: DOMEventName,
  eventSystemFlags: EventSystemFlags,
  nativeEvent: AnyNativeEvent,
  targetInst: null | Fiber,
  targetContainer: EventTarget,
): void {
  const nativeEventTarget = getEventTarget(nativeEvent);
  const dispatchQueue: DispatchQueue = [];

  // 事件对象的合成,收集事件到执行路径上
  extractEvents(
    dispatchQueue,
    domEventName,
    targetInst,
    nativeEvent,
    nativeEventTarget,
    eventSystemFlags,
    targetContainer,
  );

  // 执行收集到的组件中真正的事件
  processDispatchQueue(dispatchQueue, eventSystemFlags);
}

dispatchEventsForPlugins函数中事件的流转有一个重要的载体:dispatchQueue,它承载了本次合成的事件对象和收集到事件执行路径上的事件处理函数。

  1. 事件对象的合成和事件的收集 : 因为不同的事件会有不同的行为和处理机制,所以合成事件对象的构造和收集事件到执行路径需要通过插件实现。一共有5种Plugin:SimpleEventPlugin,EnterLeaveEventPlugin,ChangeEventPlugin,SelectEventPlugin,BeforeInputEventPlugin 。它们的使命完全一样,只是处理的事件类别不同,所以内部会有一些差异。SimpleEventPlugin处理比较通用的事件类型,比如click、input、keydown等。
js 复制代码
function extractEvents(
  dispatchQueue: DispatchQueue,
  domEventName: DOMEventName,
  targetInst: null | Fiber,
  nativeEvent: AnyNativeEvent,
  nativeEventTarget: null | EventTarget,
  eventSystemFlags: EventSystemFlags,
  targetContainer: EventTarget,
): void {
  const reactName = topLevelEventsToReactNames.get(domEventName);
  if (reactName === undefined) {
    return;
  }
  let EventInterface;
  switch (domEventName) {
    // 赋值EventInterface(接口)
  }

  // 构造合成事件对象
  const event = new SyntheticEvent(
    reactName,
    null,
    nativeEvent,
    nativeEventTarget,
    EventInterface,
  );

  const inCapturePhase = (eventSystemFlags & IS_CAPTURE_PHASE) !== 0;

  if (/*...*/) {
    ...
  } else {
    // scroll事件不冒泡
    const accumulateTargetOnly =
      !inCapturePhase &&
      domEventName === 'scroll';

    // 事件对象分发 & 收集事件
    accumulateSinglePhaseListeners(
      targetInst,
      dispatchQueue,
      event,
      inCapturePhase,
      accumulateTargetOnly,
    );
  }
  return event;
}
  1. 创建合成事件对象

这个统一的事件对象由SyntheticEvent函数构造而成,它自己遵循W3C的规范又实现了一遍浏览器的事件对象接口,这样可以抹平差异,而原生的事件对象只不过是它的一个属性(nativeEvent)。

js 复制代码
// 构造合成事件对象
const event = new SyntheticEvent(
    reactName,
    null,
    nativeEvent,
    nativeEventTarget,
    EventInterface,
);
  1. 收集事件到执行路径
js 复制代码
accumulateSinglePhaseListeners(
  targetInst,
  dispatchQueue,
  event,
  inCapturePhase,
  accumulateTargetOnly,
);

函数内部最重要的操作无疑是收集事件到执行路径,为了实现这一操作,需要在fiber树中从触发事件的源fiber节点开始,向上一直找到root,形成一条完整的冒泡或者捕获的路径。同时,沿途路过fiber节点时,根据事件名,从props中获取我们真正写在组件中的事件处理函数,push到路径中,等待下一步的批量执行。

下面是该过程精简后的源码

js 复制代码
export function accumulateSinglePhaseListeners(
  targetFiber: Fiber | null,
  dispatchQueue: DispatchQueue,
  event: ReactSyntheticEvent,
  inCapturePhase: boolean,
  accumulateTargetOnly: boolean,
): void {
  // 根据事件名来识别是冒泡阶段的事件还是捕获阶段的事件
  const bubbled = event._reactName;
  const captured = bubbled !== null ? bubbled + 'Capture' : null;

  // 声明存放事件监听的数组
  const listeners: Array<DispatchListener> = [];

  // 找到目标元素
  let instance = targetFiber;

  // 从目标元素开始一直到root,累加所有的fiber对象和事件监听。
  while (instance !== null) {
    const {stateNode, tag} = instance;

    if (tag === HostComponent && stateNode !== null) {
      const currentTarget = stateNode;

      // 事件捕获
      if (captured !== null && inCapturePhase) {
        // 从fiber中获取事件处理函数
        const captureListener = getListener(instance, captured);
        if (captureListener != null) {
          listeners.push(
            createDispatchListener(instance, captureListener, currentTarget),
          );
        }
      }

      // 事件冒泡
      if (bubbled !== null && !inCapturePhase) {
        // 从fiber中获取事件处理函数
        const bubbleListener = getListener(instance, bubbled);
        if (bubbleListener != null) {
          listeners.push(
            createDispatchListener(instance, bubbleListener, currentTarget),
          );
        }
      }
    }
    instance = instance.return;
  }
  // 收集事件对象
  if (listeners.length !== 0) {
    dispatchQueue.push(createDispatchEntry(event, listeners));
  }
}

无论事件是在冒泡阶段执行,还是捕获阶段执行,都以同样的顺序push到dispatchQueue的listeners中,而冒泡或者捕获事件的执行顺序不同是由于清空listeners数组的顺序不同。

注意,每次收集只会收集与事件源相同类型的事件,比如子元素绑定了onClick,父元素绑定了onClick和onClickCapture:

js 复制代码
<div
  className="parent"
  onClick={onClickParent}
  onClickCapture={onClickParentCapture}
>
  父元素

  <div
    className="child"
    onClick={onClickChild}
   >
     子元素
   </div>
</div>

那么点击子元素时,收集的将是onClickChildonClickParent

  1. 事件执行: 经过事件和事件对象收集的过程,得到了一条完整的事件执行路径,还有一个被共享的事件对象,dispatchQueue的结构如下面这样:
js 复制代码
[
  {
    event: SyntheticEvent,
    listeners: [ listener1, listener2, ... ]
  }
]

之后进入到事件执行过程,从头到尾循环该路径,依次调用每一项中的监听函数。这个过程的重点在于事件冒泡和捕获的模拟,以及合成事件对象的应用,如下是从dispatchQueue中提取出事件对象和时间执行路径的过程。

js 复制代码
export function processDispatchQueue(
  dispatchQueue: DispatchQueue,
  eventSystemFlags: EventSystemFlags,
): void {
  const inCapturePhase = (eventSystemFlags & IS_CAPTURE_PHASE) !== 0;
  for (let i = 0; i < dispatchQueue.length; i++) {

    // 从dispatchQueue中取出事件对象和事件监听数组
    const {event, listeners} = dispatchQueue[i];

    // 将事件监听交由processDispatchQueueItemsInOrder去触发,同时传入事件对象供事件监听使用
    processDispatchQueueItemsInOrder(event, listeners, inCapturePhase);
  }
  // 捕获错误
  rethrowCaughtError();
}

冒泡和捕获的执行顺序是不一样的,但是当初在收集事件的时候,无论是冒泡还是捕获,事件都是直接push到路径里的。判断事件执行阶段的依据inCapturePhase,用循环路径的顺序不一样来改变执行顺序。从左往右循环的时候,目标元素的事件先触发,父元素事件依次执行,这是冒泡的顺序,那捕获的顺序自然是从右往左循环了。

js 复制代码
function processDispatchQueueItemsInOrder(
  event: ReactSyntheticEvent,
  dispatchListeners: Array<DispatchListener>,
  inCapturePhase: boolean,
): void {
  let previousInstance;

  if (inCapturePhase) {
    // 事件捕获倒序循环
    for (let i = dispatchListeners.length - 1; i >= 0; i--) {
      const {instance, currentTarget, listener} = dispatchListeners[i];
      if (instance !== previousInstance && event.isPropagationStopped()) {
        return;
      }
      // 执行事件,传入event对象,和currentTarget
      executeDispatch(event, listener, currentTarget);
      previousInstance = instance;
    }
  } else {
    // 事件冒泡正序循环
    for (let i = 0; i < dispatchListeners.length; i++) {
      const {instance, currentTarget, listener} = dispatchListeners[i];
      // 如果事件对象阻止了冒泡,则return掉循环过程
      if (instance !== previousInstance && event.isPropagationStopped()) {
        return;
      }
      executeDispatch(event, listener, currentTarget);
      previousInstance = instance;
    }
  }
}

至此,我们写在组件中的事件处理函数就被执行掉了,合成事件对象在这个过程中充当了一个公共角色,每个事件执行时,都会检查合成事件对象,有没有调用阻止冒泡的方法,另外会将当前挂载事件监听的元素作为currentTarget挂载到事件对象上,最终传入事件处理函数,我们得以获取到这个事件对象。

我也是作为学习者来整理一下思路,具体例子可参考原作者链接:juejin.cn/post/692244...

React 为什么使用合成事件

React 使用合成事件的主要原因有以下几点:

  1. 跨浏览器兼容性: 合成事件系统可以屏蔽不同浏览器之间的差异,使得开发者不必担心不同浏览器实现事件处理的细节。React负责将底层浏览器事件封装成统一的合成事件对象(fiber对象),并提供提供了一种"顶层注册,事件收集,统一触发"的统一的事件机制,使得开发者能够在不同浏览器环境中获得一致的行为。
  2. 性能优化: React 的合成事件系统可以对事件进行池化重用,减少内存消耗,并通过事件委托和事件处理的优化策略来提高性能。合成事件系统会在事件处理完成后将事件对象重新添加到事件池中,以便下次使用,而不是频繁地创建和销毁事件对象。
  3. 方便的事件处理: 使用合成事件可以简化事件处理的编码方式。开发者只需要将事件处理函数作为属性传递给React元素,而无需手动管理原生浏览器事件的绑定和解绑。这种方式使得代码更加清晰简洁,并且易于维护和扩展。
  4. 组件化开发: 合成事件系统与React的组件化开发理念相契合。在React中,事件处理函数是与组件紧密相关的,通过将事件处理函数绑定到特定的组件上,可以更好地组织和管理组件之间的交互行为。

总的来说,React 使用合成事件可以提供更好的跨浏览器兼容性性能优化方便的事件处理方式以及与组件化开发理念的一致性,使得开发者能够更轻松地构建复杂的交互式界面。

相关推荐
测试199827 分钟前
2024软件测试面试热点问题
自动化测试·软件测试·python·测试工具·面试·职场和发展·压力测试
马剑威(威哥爱编程)1 小时前
MongoDB面试专题33道解析
数据库·mongodb·面试
阿伟来咯~2 小时前
记录学习react的一些内容
javascript·学习·react.js
吕彬-前端2 小时前
使用vite+react+ts+Ant Design开发后台管理项目(五)
前端·javascript·react.js
学前端的小朱2 小时前
Redux的简介及其在React中的应用
前端·javascript·react.js·redux·store
bysking3 小时前
【前端-组件】定义行分组的表格表单实现-bysking
前端·react.js
独行soc3 小时前
#渗透测试#SRC漏洞挖掘#深入挖掘XSS漏洞02之测试流程
web安全·面试·渗透测试·xss·漏洞挖掘·1024程序员节
理想不理想v4 小时前
‌Vue 3相比Vue 2的主要改进‌?
前端·javascript·vue.js·面试
sszmvb12345 小时前
测试开发 | 电商业务性能测试: Jmeter 参数化功能实现注册登录的数据驱动
jmeter·面试·职场和发展
测试杂货铺5 小时前
外包干了2年,快要废了。。
自动化测试·软件测试·python·功能测试·测试工具·面试·职场和发展