React 事件机制解析

1,114 阅读11分钟

beijing.png

前言

事件机制是 React 的核心特性之一,本篇文章我们将重点从源码 ( v17.0.0 ) 角度分析 React 事件机制是如何实现的,阅读完本篇文章,你大概将获取以下知识点:

  • React 事件是什么;
  • React 事件和原生事件的区别;
  • React 事件源码实现;

DOM 事件流和事件委托

在讲解 React 事件之前,先回顾下什么是 DOM 事件流以及事件委托。

事件流的三个阶段

  • 捕获阶段:事件从 document 节点自上而下向目标节点传播的阶段;
  • 目标阶段:真正的目标节点正在处理事件的阶段;
  • 冒泡阶段:事件从目标节点自下而上向 document 节点传播的阶段;

3.png

事件委托

事件委托的依据是事件冒泡。主要原理为:不给每个子节点单独设置事件监听器,而是将事件监听器设置在其父节点上。
事件委托的优点:

  • 可以大量节省内存占用,减少事件注册。比如 ul 上代理所有 li 的 click 事件就很不错。
  • 可以实现当新增子对象时,无需再对其进行事件绑定,对于动态内容部分尤为合适。

React事件介绍

合成事件

React 事件是合成事件( SynethicEvent ),是原生事件的跨浏览器包装器,且模拟了原生 DOM 事件的所有能力。它除兼容所有浏览器外,还拥有和浏览器原生事件相同的接口,包括 stopPropagation()preventDefault()

如果因为某些原因,当你需要使用浏览器的底层事件时,只需要使用 event.nativeEvent 来获取即可。

合成事件不会直接映射到原生事件,它通过 registrationNameDependencies 来记录合成事件和原生事件的映射关系:

// 列举部分合成事件和原生事件的映射关系如下:
registrationNameDependencies = {
    'onMouseEnter': ['mouseout', 'mouseover']
    'onMouseLeave': ['mouseout', 'mouseover'],
    'onChange': ['change', 'click', 'focusin', 'focusout', 'input', 'keydown', 'keyup', 'selectionchange'],
    ...
}

合成事件和原生事件的用法区别

React 元素的事件处理和 DOM 元素的很相似,但是有一点语法上的不同:

  • 事件名称命名不同:React 事件的命名采用小驼峰式(camelCase),而不是纯小写。
  • 事件处理函数写法不同:使用 JSX 语法时你需要传入一个函数作为事件处理函数,而不是一个字符串。
// 传统的 HTML 事件:
<button onclick="activateLasers()">Activate Lasers</button>
// React 合成事件:
<button onClick={activateLasers}>Activate Lasers</button>
  • 阻止默认行为方式不同
// 传统 HTML 可以采用 return false 方式
<a href="#" onclick="console.log('我是原生事件, return false 可以阻止我的默认行为.'); return false">
  Click me
</a>
// React 必须使用 e.preventDefault()
function ActionLink() {
  function handleClick(e) {
    e.preventDefault();
    console.log('The link was clicked.');
  }
  return (
    <a href="#" onClick={handleClick}>Click me</a>
  );
}

合成事件的意义

  • 统一规范,解决了不同浏览器之间的兼容性差异,更便于跨平台;

  • 事件几乎全部委托到 root 节点,而非 DOM 节点本身,减少了内存消耗;

    • root 节点是 ReactDOM.createRoot(root).render() 中的 root,即 React 应用挂载容器;
    • 注意 react v16和v17 这里有很大区别,v16是将事件委托到了 document上;
    • 并非所有所有事件都代理到了 root 节点,比如 audio、video 标签的 onplay、onpause 等;
  • 对事件进行归类 ( SyntheticMouseEvent、SyntheticUIEvent、SyntheticDragEvent 等),在事件产生的任务上进行了优先级划分, 从而起到干预事件的作用;

事件机制源码分析( v17.0.0 )

上面提到了合成事件会将事件全部绑定到了 root 节点上,那么:

  • 事件是怎么注册上去的?
  • 当事件触发的时候是如何获取到真实事件对象的?
  • 事件冒泡和捕获是如何模拟的?
  • 事件又是如何触发的?

带着这些问题,接着往下看。

事件注册

与之前版本不同,React17的事件是注册到 root 上而非 document,这样做的好处可以避免多版本的 React 共存时事件系统发生冲突( 比如:微前端应用 )。

在初始化 createRoot 阶段,会内部调用 listenToAllSupportedEvents 函数,将所有原生事件添加到了 root 上。

/* rootContainerElement: React 应用的根 DOM 节点 */
export function listenToAllSupportedEvents(rootContainerElement: EventTarget) {
    ...
    // ** allNativeEvents是一个事件集合, 包含了80中事件类型 **
    // 通过 listenToNativeEvent 函数注册事件
    allNativeEvents.forEach(domEventName => {
      if (!nonDelegatedEvents.has(domEventName)) {
        listenToNativeEvent(
          domEventName,
          // false 表示冒泡事件
          false,
          ((rootContainerElement: any): Element),
          null,
        );
      }
      listenToNativeEvent(
        domEventName,
        true,
        ((rootContainerElement: any): Element),
        null,
      );
    });
  }
}

在 listenToAllSupportedEvents 方法中遍历了 allNativeEvents,先来打印看下 allNativeEvents 是什么:

2.png

allNativeEvents 是所有事件的集合,遍历的过程中,每个事件都分别按照 冒泡和捕获 两种方式注册了一遍,当然不可以冒泡的事件除外。

咱们再来看下 listenToNativeEvent 方法:

export function listenToNativeEvent(
  domEventName: DOMEventName,
  // 是否捕获阶段监听
  isCapturePhaseListener: boolean,
  rootContainerElement: EventTarget,
  targetElement: Element | null,
  eventSystemFlags?: EventSystemFlags = 0,
): void {
  let target = rootContainerElement;
  // ...
  const listenerSet = getEventListenerSet(target);
  const listenerSetKey = getListenerSetKey(
    domEventName,
    isCapturePhaseListener,
  );
  // listenerSetKey 只被添加一次
  if (!listenerSet.has(listenerSetKey)) {
    if (isCapturePhaseListener) {
      eventSystemFlags |= IS_CAPTURE_PHASE;
    }
    addTrappedEventListener(
      target,
      domEventName,
      eventSystemFlags,
      isCapturePhaseListener,
    );
    listenerSet.add(listenerSetKey);
  }
}

在这个方法中根据 target 创建了 listenerSet 集合 ( 不同 target 对应不同的 listenerSet ),根据事件名称和是否可捕获得到集合的key,形如: click__capture,将不在集合中的事件全部调用了 addTrappedEventListener 方法。

在 addTrappedEventListener 内部创建了 带有优先级的事件监听器 ( 后面会介绍 ), 且将新创建的事件监听器注册到 target 上。

最终在根 DOM 节点上,每个原生事件都绑定了其对应优先级所对应的监听器。

至此,事件注册就完了。

1.png

事件合成

上文提到在 addTrappedEventListener 方法中创建了带有优先级的事件监听器,,那这个监听器是什么?

  let listener = createEventListenerWrapperWithPriority(
    targetContainer,
    domEventName,
    eventSystemFlags,
  );

从源码来看 listener 是通过 createEventListenerWrapperWithPriority 方法创建的。从名称可以猜到, 它创建一个有优先级的事件监听包装器。

createEventListenerWrapperWithPriority 源码:

export function createEventListenerWrapperWithPriority(
  targetContainer: EventTarget,
  domEventName: DOMEventName,
  eventSystemFlags: EventSystemFlags,
): Function {
  // 根据 事件名称 获取事件优先级
  const eventPriority = getEventPriorityForPluginSystem(domEventName);
  let listenerWrapper;
  switch (eventPriority) {
    case DiscreteEvent:
      listenerWrapper = dispatchDiscreteEvent;
      break;
    case UserBlockingEvent:
      listenerWrapper = dispatchUserBlockingUpdate;
      break;
    case ContinuousEvent:
    default:
      listenerWrapper = dispatchEvent;
      break;
  }
  return listenerWrapper.bind(
    null,
    domEventName,
    eventSystemFlags,
    targetContainer,
  );
}

从源码来看,listener 最终的得到是三类事件监听包装器之一,它们分别如下:

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

这三类事件监听包装器才是真正注册到 root 节点上的事件监听器。当浏览器发生了原生事件的调用,它将按照事件的优先级去安排接下来的工作:事件对象的合成、将事件处理函数收集到执行路径、 事件执行,在后面的调度过程中,scheduler 才能获知当前任务的优先级,然后展开调度。

顺便看下事件优先级定义:

export type EventPriority = 0 | 1 | 2;
// 离散事件,click、mousedown 这类单点触发不持续的事件,优先级最低
export const DiscreteEvent: EventPriority = 0;
// 用户阻塞事件,drag、mousemove、wheel 这类持续触发的事件,优先级相对较高
export const UserBlockingEvent: EventPriority = 1;
// 连续事件,load、error、waiting 这类大多与媒体相关的事件为主的事件需要及时响应,所以优先级最高
export const ContinuousEvent: EventPriority = 2;

如果一个节点同时注册了 DiscreteEvent 和 ContinuousEvent 两类型事件,那么 ContinuousEvent 类事件是会比 DiscreteEvent 类事件先执行。

这三类事件虽然有优先区分,但是最终都调用了 dispatchEvent,dispatchEvent 中有一个非常关键的方法 dispatchEventsForPlugins,这个方法是最核心的,它完成了 事件对象的合成、将事件处理函数收集到执行路径、 事件执行,咱们来分析下源码:

function dispatchEventsForPlugins(
  domEventName: DOMEventName,
  eventSystemFlags: EventSystemFlags,
  nativeEvent: AnyNativeEvent,
  targetInst: null | Fiber,
  targetContainer: EventTarget,
): void {
  // 获取event.target
  const nativeEventTarget = getEventTarget(nativeEvent);
  // 收集到的事件都会存储到这个事件队列
  const dispatchQueue: DispatchQueue = [];
  // 事件对象的合成,将事件收集到执行路径上
  extractEvents(
    dispatchQueue,
    domEventName,
    targetInst,
    nativeEvent,
    nativeEventTarget,
    eventSystemFlags,
    targetContainer,
  );
  // 执行收集到的组件中的事件
  processDispatchQueue(dispatchQueue, eventSystemFlags);
}

dispatchEventsForPlugins 中主要调用了 extractEvents 和 processDispatchQueue 两个方法。

  • extractEvents:事件对象的合成,将事件收集到执行路径上;
  • processDispatchQueue:执行收集到的组件中的事件;

先来看下 事件对象是如何合成,又是如何将事件收集到执行路径上的:

function extractEvents(
  dispatchQueue: DispatchQueue,
  domEventName: DOMEventName,
  targetInst: null | Fiber,
  nativeEvent: AnyNativeEvent,
  nativeEventTarget: null | EventTarget,
  eventSystemFlags: EventSystemFlags,
  targetContainer: EventTarget,
) {
  SimpleEventPlugin.extractEvents(
    dispatchQueue,
    domEventName,
    targetInst,
    nativeEvent,
    nativeEventTarget,
    eventSystemFlags,
    targetContainer,
  );
  const shouldProcessPolyfillPlugins =
    (eventSystemFlags & SHOULD_NOT_PROCESS_POLYFILL_EVENT_PLUGINS) === 0;
  if (shouldProcessPolyfillPlugins) {
    EnterLeaveEventPlugin.extractEvents(
      // 参数同上
    );
    ChangeEventPlugin.extractEvents(
      // 参数同上
    );
    SelectEventPlugin.extractEvents(
      // 参数同上
    );
    BeforeInputEventPlugin.extractEvents(
      // 参数同上
    );
  }
}

React 内部有五种 EventPlugin,不同的事件由不同的 EventPlugin 合成,它们分别是:

  1. BeforeInputEventPlugin
  2. ChangeEventPlugin
  3. EnterLeaveEventPlugin
  4. SelectEventPlugin
  5. SimpleEventPlugin

extractEvents 方法内部调用了几个 EventPlugin 的 extractEvents 方法,这几个 extractEvents 的作用一样,针对不同的事件生成不同的合成事件对象且将事件收集到执行路径上。接下来我们以 SimpleEventPlugin.extractEvents 为例来讲解:

// SimpleEventPlugin.js
// 注意区分上一个 extractEvents 方法
function extractEvents(
  dispatchQueue: DispatchQueue,
  domEventName: DOMEventName,
  targetInst: null | Fiber,
  nativeEvent: AnyNativeEvent,
  nativeEventTarget: null | EventTarget,
  eventSystemFlags: EventSystemFlags,
  targetContainer: EventTarget,
): void {
  // 获取合成事件名称 eg: onClick
  const reactName = topLevelEventsToReactNames.get(domEventName);
  if (reactName === undefined) {
    return;
  }
  let SyntheticEventCtor = SyntheticEvent;
  let reactEventType: string = domEventName;
  // 获取不同事件的合成事件构造函数
  switch (domEventName) {
  	// ...
  }
  // 是否是捕获阶段
  const inCapturePhase = (eventSystemFlags & IS_CAPTURE_PHASE) !== 0;
    // 收集事件到执行路径
    const listeners = accumulateSinglePhaseListeners(
      targetInst,
      reactName,
      nativeEvent.type,
      inCapturePhase,
      accumulateTargetOnly,
    );
    if (listeners.length > 0) {
      // 创建合成事件对象
      const event = new SyntheticEventCtor(
        reactName,
        reactEventType,
        null,
        nativeEvent,
        nativeEventTarget,
      );
      // 将合成事件以及对应的监听函数添加到队列中
      dispatchQueue.push({event, listeners});
    }
}
export {registerSimpleEvents as registerEvents, extractEvents};

extractEvents方法中有三个非常重要的地方:

  1. 合成事件对象event 是通过 构造器 SyntheticEventCtor() 生成的,这个对象保存了整个事件的信息,并且将作为参数传递给真正的事件处理函数。
  2. ccumulateSinglePhaseListeners 收集事件到执行路径, 接下来看下实现:
export function accumulateSinglePhaseListeners(
  targetFiber: Fiber | null,
  reactName: string | null,
  nativeEventType: string,
  inCapturePhase: boolean,
  accumulateTargetOnly: boolean,
): Array<DispatchListener> {
  // 捕获事件名称
  const captureName = reactName !== null ? reactName + 'Capture' : null;
  // 最终的合成事件名称
  const reactEventName = inCapturePhase ? captureName : reactName;
  const listeners: Array<DispatchListener> = [];
  let instance = targetFiber;
  let lastHostComponent = null;
  // Accumulate all instances and listeners via the target -> root path.
  while (instance !== null) {
    const {stateNode, tag} = instance;
    // Handle listeners that are on HostComponents (i.e. <div>)
    // HostComponent === 5 一个常量
    if (tag === HostComponent && stateNode !== null) {
      lastHostComponent = stateNode;
      // Standard React on* listeners, i.e. onClick or onClickCapture
      if (reactEventName !== null) {
        // 获取instance中名称为 reactEventName 的对应事件
        const listener = getListener(instance, reactEventName);
        if (listener != null) {
          listeners.push(
            createDispatchListener(instance, listener, lastHostComponent),
          );
        }
      }
    }
    // 只收集当前target的event
    if (accumulateTargetOnly) {
      break;
    }
    // 继续向上递归
    instance = instance.return;
  }
  return listeners;
}

ccumulateSinglePhaseListeners 方法最终得到的是 listeners,而 listeners 就是被递归收集到的整个 instance 链上的事件集合。

  1. events 和对应 listeners 添加到了 dispatchQueue 中,得到了一个完整的待触发事件队列。

这里注意,每次收集只会收集与事件名称相同类型的事件,比如子元素绑定了 onClick,父元素绑定了 onClick 和 onClickCapture,那么点击子元素时,收集的将是子元素和父元素的 onClick,父级的 onClickCapture 并没有被收集。

接下来咱们看一个例子:

    onAncestorClick = () => {
        console.log('祖先元素点击冒泡事件');
    }
    onAncestorClickCapture = () => {
        console.log('祖先元素点击捕获事件');
    }
    onParentClick = e => {
        console.log('父级元素点击冒泡事件');
    }
    onChildClick = () => {
        console.log('孙子元素点击冒泡事件');
    }
    render() {
        return (
            <div
                className={'ancestor'}
                onClick={this.onAncestorClick}
                onClickCapture={this.onAncestorClickCapture}
            >
                祖先
                <div onClick={this.onParentClick} className={'parent'}>
                    父级
                    <div className={'child'} onClick={this.onChildClick}>
                        子级
                    </div>
                </div>
            </div>
        )
    }

当点击 div.child 节点时,咱们看下得到的 dispatchQueue 是什么:

5.png

从打印结果来看,生成两个 dispatchQueue,第一个 dispatchQueue 中只触发捕获事件,第二个 dispatchQueue 中只触发了冒泡事件,从单一 dispatchQueue 来看是只将相同事件名称的事件监听器收集起来,这里可能有人问,为什么会有两个 dispatchQueue?那是因为开始注册事件的时候调用了两次 listenToNativeEvent,普通事件是可以捕获的,只不过在业务中并没有定义捕获事件而已。

事件触发

在事件对象合成 以及 收集完事件之后,紧接着就调用 processDispatchQueue 方法来执行收集到的事件。

export function processDispatchQueue(
  dispatchQueue: DispatchQueue,
  eventSystemFlags: EventSystemFlags,
): void {
  const inCapturePhase = (eventSystemFlags & IS_CAPTURE_PHASE) !== 0;
  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();
}

循环 dispatchQueue 中每一项,又通过 processDispatchQueueItemsInOrder 将每一项中的 listeners 依次执行:

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;
      }
      // 执行事件
      executeDispatch(event, listener, currentTarget);
      previousInstance = instance;
    }
  } else {
    // 冒泡: 正序循环
    for (let i = 0; i < dispatchListeners.length; i++) {
      const {instance, currentTarget, listener} = dispatchListeners[i];
      // 阻止冒泡
      if (instance !== previousInstance && event.isPropagationStopped()) {
        return;
      }
      // 执行事件
      executeDispatch(event, listener, currentTarget);
      previousInstance = instance;
    }
  }
}

processDispatchQueueItemsInOrder 方法接收到的参数中,如果 inCapturePhase 是 true, 表示捕获阶段事件,那么需要将 dispatchListeners 倒序遍历;反之如果 inCapturePhase 是 false, 表示冒泡阶段事件,那么 dispatchListeners 正序遍历即可。React 就是通过这种方式模拟出了整个冒泡和捕获过程。

以上就是 React 事件从注册到合成到触发的整个过程。

总结

React 事件在 createRoot 阶段几乎将所有事件全部绑定到了 root 节点,对应的事件监听器被分为了三种类型,且它们有各自优先级,但这三类事件监听器最终都调用了 dispatchEvent 方法,紧接着合成了事件对象,且将事件收集到执行路径,最后进行了事件触发。

以上就是 React 事件的全部过程,如果有不对的地方还请大家评论指正。