面试官:讲一下 React 的事件系统​

大家好,今天的分享由团队的 uncle13 老师提供。

React 的事件系统在前端面试中较为常见,比如“说说 React 事件与原生事件的执行顺序”,“React 事件代理机制的原理”等等。相信今天的文章读完,你都能找到答案。

本文的干货较多,推荐大家收藏后再阅读。

浏览器事件系统

事件是某事发生的信号。所有的 DOM 节点都生成这样的信号(但事件不仅限于 DOM)。常见的事件有clickkeydown等。通过事件监听器我们可以分配一个处理程序给对应的信号,使得浏览器和 JS 可以进行交互。当事件发生时,浏览器会创建一个事件对象,将详细信息放入其中,并将其作为参数传递给处理程序。

React 事件系统

React 的事件系统是基于浏览器的事件机制下完全重写的,在使用上,和 DOM 元素的很相似,有几点区别:

  • React 事件的命名采用小驼峰式(camelCase),而不是纯小写。
  • 使用 JSX 语法时你需要传入一个函数作为事件处理函数,而不是一个字符串。
  • 在 React 中另一个不同点是你不能通过返回false的方式阻止默认行为。你必须显式的使用preventDefault
  • 但内在设计完全不一样,比如事件对象采用的是合成事件;事件全部挂载到document节点上,通过冒泡进行触发。原生事件(阻止冒泡)会阻止合成事件的执行,合成事件(阻止冒泡)不会阻止原生事件的执行。

特点

  1. 合成事件:React 不直接使用浏览器原生事件,而是使用自己的合成事件(SyntheticEvent)对象。这些对象包裹了原生事件,提供了跨浏览器的标准化行为,并拥有和原生事件相同的接口。
  2. 事件委托:React 将所有的事件监听器添加到最外层的容器元素上(通常是document),而不是每个单独的 DOM 元素。当事件发生时,React 决定哪个组件应该处理事件,这种方式称为事件委托。它减少了 DOM 操作,提高了性能。
  3. 自动绑定:在 React 组件中,事件处理器自动绑定到组件实例上,不需要手动绑定this
  4. 事件池:React 事件系统通过重用事件对象来减少内存分配和垃圾回收的次数。这是通过事件池实现的,事件对象在被处理后会被回收到池中,以便复用。
  5. 事务性:React 的事件处理是事务性的,这意味着在事件处理期间,React 会应用一系列的“事务”来保证状态的连贯性。

组成部分

  1. 事件注册:在组件挂载(mounting)和更新(updating)时,React 会遍历组件的虚拟 DOM,并注册必要的事件监听器。
  2. 事件监听器:React 使用addEventListener在 document 上添加事件监听器。对于每种类型的事件(如clickkeydown等),React 会添加一个监听器。
  3. 事件分发:当原生事件发生时,浏览器会将事件传递给 React 的监听器。React 会创建一个合成事件对象,并决定哪个组件应该处理这个事件。
  4. 事件处理器调用:React 会调用组件中定义的事件处理器函数,并传递合成事件对象。开发者可以在事件处理器中通过event.preventDefault()来阻止默认行为,或通过event.stopPropagation()来阻止事件冒泡。
  5. 事件清理:在组件卸载(unmounting)时,React 会移除相应的事件监听器,以避免内存泄漏。

React 为什么需要合成事件

  • 减少内存消耗,提升性能,不需要注册那么多的事件了,一种事件类型只在document上注册一次。
  • 统一规范,解决 ie 事件兼容问题,简化事件逻辑。
  • 跨端复用。比如当我们给input元素增加onChange事件,React 其实还帮我们注册了很多事件,比如keyDownkeyUpblur等,使得我们在向文本框输入内容的是你,是可以实时的得到内容的。然而原生只注册一个onchange的话,需要在失去焦点的时候才能触发这个事件,所以这个原生事件的缺陷 react 也帮我们弥补了。react 在给 document 注册事件的时候也是对兼容性做了处理。
function listen(target, eventType, callback{
  if (target.addEventListener) {
    target.addEventListener(eventType, callback, false);
    return {
      removefunction remove({
        target.removeEventListener(eventType, callback, false);
      }
    };
  } else if (target.attachEvent) {
    target.attachEvent('on' + eventType, callback);
    return {
      removefunction remove({
        target.detachEvent('on' + eventType, callback);
      }
    };
  }
}

事件注册

在 React 中,事件注册的过程涉及将 React 组件内部定义的事件处理器(event handlers)关联到实际的 DOM 元素上。这个过程是自动完成的,开发者只需在组件中声明事件处理函数即可。以下是事件注册的简化流程:

  1. 组件渲染阶段:当 React 组件渲染时,它会构建一个虚拟 DOM 树(fiber tree),这个树反映了组件的状态和结构。
  2. 事件处理器注册:在渲染过程中,React 会遍历虚拟 DOM 树,并为那些有事件处理器的元素注册事件。对于每个需要注册事件的元素,React 会调用enqueueSetState方法来安排一个更新。
  3. 合成事件系统:React 并不直接将事件处理器绑定到 DOM 元素上,而是使用了一个合成事件系统。这个系统在底层使用了一个事件委托(event delegation)机制,将所有的事件处理器绑定到最外层的容器上,通常是document
  4. listenTo函数:在 React 的内部,listenTo函数用于在 document 上监听特定的事件。这个函数会调用trapCapturedEvent,后者负责添加特定的事件监听器。
  5. trapCapturedEvent函数:这个函数负责添加捕获事件监听器到指定的元素。它使用addEventListener方法来监听原生事件,并将事件的处理委托给dispatchEvent方法。
  6. dispatchEvent方法:当原生事件触发时,dispatchEvent方法会被调用。这个方法负责创建和分发合成事件(synthetic events),这些合成事件是对原生事件的跨浏览器封装。
  7. 事件冒泡和捕获:React 的合成事件系统会模拟事件的冒泡和捕获阶段,确保事件按照预期传递给所有的事件处理器。
  8. 更新和调和:当事件处理器被调用时,它们可能会引起组件的状态变化。这时,React 会调度一个更新,重新渲染组件,并更新 DOM。整个事件注册过程是高度优化的,React 通过重用事件对象、使用事件池来减少内存分配和垃圾回收的次数,从而提高性能。开发者不需要手动干预事件的注册和卸载,React 会自动管理这一切。

下面从源码的角度简单讲一下:

finalizeInitialChildren函数中,通过调用setInitialProperties来为即将渲染的 DOM 元素设置属性。对于受控组件,会特别调用ensureListeningTo函数以确保事件监听的正确设置。

// 确保受控组件始终监听 onChange 事件,即使没有显式的监听器。
ensureListeningTo(rootContainerElement, 'onChange');
function ensureListeningTo(rootContainerElement, registrationName{
  // 判断是否是文档或文档片段
  const isDocumentOrFragment =
    rootContainerElement.nodeType === DOCUMENT_NODE ||
    rootContainerElement.nodeType === DOCUMENT_FRAGMENT_NODE;
  // 获取文档对象
  const doc = isDocumentOrFragment
    ? rootContainerElement
    : rootContainerElement.ownerDocument;
  // 调用 listenTo 函数进行事件监听
  listenTo(registrationName, doc);
}

ensureListeningTo函数中,首先判断 DOM 元素类型是否为DOCUMENT_NODE,这与我们之前的描述一致。接着,listenTo函数会调用trapCapturedEvent方法,后者使用addEventListener来监听一个特定的事件,该事件最终会触发dispatchEvent方法。

export function trapCapturedEvent(
  topLevelType: DOMTopLevelEventType,
  element: Document | Element,
{
  if (!element) {
    return null;
  }
  // 根据 topLevelType 判断是否为交互式事件
  const dispatch = isInteractiveTopLevelEventType(topLevelType)
    ? dispatchInteractiveEvent
    : dispatchEvent;
  // 添加捕获事件监听器
  addEventCaptureListener(
    element,
    getRawEventName(topLevelType),
    // 将 dispatch 方法绑定到对应的 topLevelType
    dispatch.bind(null, topLevelType),
  );
}

通过上述步骤,React 完成了事件的注册声明。在 React 中,所有事件的触发都是通过统一的dispatchEvent方法进行派发的,而不是在注册的时候直接绑定声明回调函数。这种设计允许 React 对事件进行统一的管理和优化,同时确保了事件处理的统一性和一致性。

在 React 中,所有事件的触发都通过dispatchEvent方法统一进行派发。以下是dispatchEvent方法的优化版本,并添加了中文注释:

export function dispatchEvent(
  topLevelType: DOMTopLevelEventType,
  nativeEvent: AnyNativeEvent,
{
  // 如果事件系统被禁用,则不进行派发
  if (!_enabled) {
    return;
  }
  // 获取原生事件的目标元素
  const nativeEventTarget = getEventTarget(nativeEvent);
  // 获取与目标元素相关的 React 实例
  let targetInst = getClosestInstanceFromNode(nativeEventTarget);
  // 如果目标实例存在,但尚未挂载,则忽略该事件
  if (
    targetInst !== null &&
    typeof targetInst.tag === 'number' &&
    !isFiberMounted(targetInst)
  ) {
    targetInst = null;
  }
  // 获取事件回调的上下文信息
  const bookKeeping = getTopLevelCallbackBookKeeping(
    topLevelType,
    nativeEvent,
    targetInst,
  );
  try {
    // 使用批处理更新来处理事件,允许在同一个周期内进行 preventDefault
    batchedUpdates(handleTopLevel, bookKeeping);
  } finally {
    // 释放事件回调的上下文信息
    releaseTopLevelCallbackBookKeeping(bookKeeping);
  }
}

dispatchEvent方法中,调用了batchedUpdates(handleTopLevel, bookKeeping)handleTopLevel会遍历找出所有的父节点,并调用runExtractedEventsInBatch中的extractEvents生成合成事件,最后调用runEventsInBatch执行。

export function runExtractedEventsInBatch(
  topLevelType: TopLevelType,
  targetInst: null | Fiber,
  nativeEvent: AnyNativeEvent,
  nativeEventTarget: EventTarget,
{
  // 提取合成事件
  const events = extractEvents(
    topLevelType,
    targetInst,
    nativeEvent,
    nativeEventTarget,
  );
  // 以批处理方式运行事件
  runEventsInBatch(events, false);
}

生成合成事件后,会调用accumulateTwoPhaseDispatches(event),该方法最终会调用traverseTwoPhase

/**
 * 模拟两阶段(捕获/冒泡)事件分发的遍历过程。
 */

export function traverseTwoPhase(inst, fn, arg{
  const path = [];
  // 收集从目标实例到根节点的路径
  while (inst) {
    path.push(inst);
    inst = getParent(inst);
  }
  // 按捕获阶段遍历路径
  let i;
  for (i = path.length; i-- > 0; ) {
    fn(path[i], 'captured', arg);
  }
  // 按冒泡阶段遍历路径
  for (i = 0; i < path.length; i++) {
    fn(path[i], 'bubbled', arg);
  }
}

在模拟过程中,所有事件监听函数及其对应的节点都会被加入到合成事件event的属性中。

function accumulateDirectionalDispatches(inst, phase, event{
  // 开发环境下,检查实例是否为空
  if (__DEV__) {
    warningWithoutStack(inst, 'Dispatching inst must not be null');
  }
  // 获取指定阶段的监听器
  const listener = listenerAtPhase(inst, event, phase);
  if (listener) {
    // 累积监听器和实例到合成事件中
    event._dispatchListeners = accumulateInto(
      event._dispatchListeners,
      listener,
    );
    event._dispatchInstances = accumulateInto(event._dispatchInstances, inst);
  }
}

最后,runEventsInBatch会依次执行所有的事件:

export function runEventsInBatch(events{
  if (events !== null) {
    // 将事件累积到事件队列中
    eventQueue = accumulateInto(eventQueue, events);
  }
  // 处理事件队列前,将其设置为 null,以便在处理过程中检测是否有新事件加入
  const processingEventQueue = eventQueue;
  eventQueue = null;
  if (!processingEventQueue) {
    return;
  }
  // 遍历并执行事件队列中的事件
  forEachAccumulated(
    processingEventQueue,
    executeDispatchesAndReleaseTopLevel,
  );
}

executeDispatchesAndReleaseTopLevel中,会判断合成事件是否isPersistent,不是的话会释放。

const executeDispatchesAndRelease = function(
  event: ReactSyntheticEvent,
  simulated: boolean,
{
  if (event) {
    executeDispatchesInOrder(event, simulated);
    if (!event.isPersistent()) {
      event.constructor.release(event);
    }
  }
};

这也就是为什么当我们在需要异步读取操作一个合成事件对象的时候,需要执行event.persist(),不然 React 就会释放掉。

最后

还没有使用过我们刷题网站(https://fe.ecool.fun/)或者前端面试题宝典的同学,如果近期准备或者正在找工作,千万不要错过,题库主打无广告和更新快哦~。

老规矩,也给我们团队的辅导服务打个广告。