关于javascript:深入React合成事件机制原理

51次阅读

共计 11507 个字符,预计需要花费 29 分钟才能阅读完成。

点击进入 React 源码调试仓库。

为什么要本人实现一套事件机制

因为 fiber 机制的特点,生成一个 fiber 节点时,它对应的 dom 节点有可能还未挂载,onClick 这样的事件处理函数作为 fiber 节点的 prop,也就不能间接被绑定到实在的 DOM 节点上。
为此,React 提供了一种“顶层注册,事件收集,对立触发”的事件机制。

所谓“顶层注册”,其实是在 root 元素上绑定一个对立的事件处理函数。“事件收集”指的是事件触发时(实际上是 root 上的事件处理函数被执行),结构合成事件对象,依照冒泡或捕捉的门路去组件中收集真正的事件处理函数。“对立触发”产生在收集过程之后,对所收集的事件逐个执行,并共享同一个合成事件对象。这里有一个重点是绑定到 root 上的事件监听并非咱们写在组件中的事件处理函数,留神这个区别,下文会提到。

以上是 React 事件机制的简述,这套机制躲避了无奈将事件间接绑定到 DOM 节点上的问题,并且可能很好地利用 fiber 树的层级关系来生成事件执行门路,进而模仿事件捕捉和冒泡,另外还带来两个十分重要的个性:

  • 对事件进行归类,能够在事件产生的工作上蕴含不同的优先级
  • 提供合成事件对象,抹平浏览器的兼容性差别

本文会对事件机制进行具体解说,贯通一个事件从注册到被执行的生命周期。

事件注册

与之前版本不同,React17 的事件是注册到 root 上而非 document,这次要是为了渐进降级,防止多版本的 React 共存的场景中事件零碎发生冲突。

当咱们为一个元素绑定事件时,会这样写:

<div onClick={() => {/*do something*/}}>React</div>

这个 div 节点最终要对应一个 fiber 节点,onClick 则作为它的 prop。当这个 fiber 节点进入 render 阶段的 complete 阶段时,名称为 onClick 的 prop 会被辨认为事件进行解决。

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 去绑定事件。

接下来的绑定过程能够概括为如下几个关键点:

  • 依据 React 的事件名称寻找该事件依赖,例如 onMouseEnter 事件依赖了 mouseout 和 mouseover 两个原生事件,onClick 只依赖了 click 一个原生事件,最终会循环这些依赖,在 root 上绑定对应的事件。例如组件中为 onClick,那么就会在 root 上绑定一个 click 事件监听。
  • 根据组件中写的事件名辨认其属于哪个阶段的事件(冒泡或捕捉),例如 onClickCapture 这样的 React 事件名称就代表是须要事件在捕捉阶段触发,而 onClick 代表事件须要在冒泡阶段触发。
  • 依据 React 事件名,找出对应的原生事件名,例如click,并依据上一步来判断是否须要在捕捉阶段触发,调用addEventListener,将事件绑定到 root 元素上。
  • 若事件须要更新,那么先移除事件监听,再从新绑定,绑定过程反复以上三步。

通过这一系列过程,事件监听器 listener 最终被绑定到 root 元素上。

  // 依据事件名称,创立不同优先级的事件监听器。let listener = createEventListenerWrapperWithPriority(
    targetContainer,
    domEventName,
    eventSystemFlags,
    listenerPriority,
  );

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

  }

事件监听器 listener 是谁

下面提到的绑定事件的时候,绑定到 root 上的事件监听函数是 listener,然而这个 listener 并不是咱们间接在组件里写的事件处理函数。通过下面的代码可知,listener 是 createEventListenerWrapperWithPriority 的调用后果

为什么要创立这么一个 listener,而不是间接绑定写在组件里的事件处理函数呢?

其实 createEventListenerWrapperWithPriority 这个函数名曾经说出了答案:根据优先级创立一个事件监听包装器。有两个重点:优先级 事件监听包装器。这里的优先级是指事件优先级(对于事件优先级的具体介绍请移步 React 中的优先级)。

事件优先级是依据事件的交互水平划分的,优先级和事件名的映射关系存在于一个 Map 构造中。createEventListenerWrapperWithPriority会依据事件名或者传入的优先级返回不同级别的 事件监听包装器

总的来说,会有三种事件监听包装器:

  • dispatchDiscreteEvent: 解决离散事件
  • dispatchUserBlockingUpdate:解决用户阻塞事件
  • dispatchEvent:解决间断事件

这些包装器是真正绑定到 root 上的事件监听器 listener,它们持有各自的优先级,当对应的事件触发时,调用的其实是这个蕴含优先级的事件监听。

透传事件执行阶段标记

到这里咱们先梳理一下,root 上绑定的是这个持有优先级的事件监听,触发它会使组件中实在的事件得以触发。但到目前为止有一点并未包含在内,也就是事件执行阶段的辨别。组件中注册事件尽管能够以事件名 +“Capture”后缀的模式辨别未来的执行阶段,但这和真正执行事件其实是两回事,所以当初关键在于如何将注册事件时显式申明的执行阶段真正落实到执行事件的行为上。

对于这一点咱们能够关注 createEventListenerWrapperWithPriority 函数中的其中一个入参:eventSystemFlags。它是事件零碎的一个标记,记录事件的各种标记,其中一个标记就是IS_CAPTURE_PHASE,这表明了以后的事件是捕捉阶段触发。当事件名含有 Capture 后缀时,eventSystemFlags 会被赋值为 IS_CAPTURE_PHASE。

之后在以优先级创立绑定到 root 上的事件监听时,eventSystemFlags 会作为它执行时的入参,传递进去。因而,在事件触发的时候就能够晓得组件中的事件是以冒泡或是捕捉的程序执行。

function dispatchDiscreteEvent(
  domEventName,
  eventSystemFlags,
  container,
  nativeEvent,
) {
  ...
  discreteUpdates(
    dispatchEvent,
    domEventName,
    eventSystemFlags, // 传入事件执行阶段的标记
    container,
    nativeEvent,
  );
}

小结

当初咱们应该能分明两点:

  1. 事件处理函数不是绑定到组件的元素上的,而是绑定到 root 上,这和 fiber 树的构造特点无关,即事件处理函数只能作为 fiber 的 prop。
  2. 绑定到 root 上的事件监听不是咱们在组件里写的事件处理函数,而是一个持有事件优先级,并能传递事件执行阶段标记的监听器。

目前,注册阶段的工作曾经实现,上面会讲一讲事件是如何被触发的,让咱们从绑定到 root 上的监听器切入,看看它做了什么。

事件触发 – 事件监听器 listener 做了什么

它做的事件能够用一句话概括:负责以不同的优先级权重来触发真正的事件流程,并传递事件执行阶段标记(eventSystemFlags)。

比方一个元素绑定了 onClick 事件,那么点击它的时候,绑定在 root 上的 listener 会被触发,会最终使得组件中的事件被执行。

也就是说绑定到 root 上的事件监听 listener 只是相当于一个传令官,它依照事件的 优先级 去安顿接下来的工作:事件对象的合成 将事件处理函数收集到执行门路 事件执行,这样在前面的调度过程中,scheduler 能力获知当前任务的优先级,而后开展调度。

如何将优先级传递进来?

利用 scheduler 中的 runWithPriority 函数,通过调用它,将优先级记录到利用 scheduler 中,所以调度器能力在调度的时候晓得当前任务的优先级。runWithPriority的第二个参数,会去安顿下面提到的三个工作。

以用户阻塞的优先级级别为例:

function dispatchUserBlockingUpdate(
  domEventName,
  eventSystemFlags,
  container,
  nativeEvent,
) {
    ...
    runWithPriority(
      UserBlockingPriority,
      dispatchEvent.bind(
        null,
        domEventName,
        eventSystemFlags,
        container,
        nativeEvent,
      ),
    );
}

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

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

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

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,它承载了本次合成的事件对象和收集到事件执行门路上的事件处理函数。

listeners 是事件执行门路,event 是合成事件对象,收集组件中真正的事件到执行门路,以及事件对象的合成通过 extractEvents 实现。

事件对象的合成和事件的收集

到这里咱们应该分明,root 上的事件监听被触发会引发事件对象的合成和事件的收集过程,这是为真正的事件触发做筹备。

合成事件对象

在组件中的事件处理函数中拿到的事件对象并不是原生的事件对象,而是通过 React 合成的 SyntheticEvent 对象。它解决了不同浏览器之间的兼容性差别。形象成对立的事件对象,解除开发者的心智累赘。

事件执行门路

当事件对象合成结束,会将事件收集到事件执行门路上。什么是事件执行门路呢?

在浏览器的环境中,若父子元素绑定了雷同类型的事件,除非手动干涉,那么这些事件都会依照冒泡或者捕捉的程序触发。

在 React 中也是如此,从触发事件的元素开始,根据 fiber 树的层级构造向上查找,累加下级元素中所有雷同类型的事件,最终造成一个具备所有雷同类型事件的数组,这个数组就是事件执行门路。通过这个门路,React 本人模仿了一套事件捕捉与冒泡的机制。

下图是事件对象的包装和收集事件(冒泡的门路为例)的大抵过程

因为不同的事件会有不同的行为和解决机制,所以合成事件对象的结构和收集事件到执行门路须要通过插件实现。一共有 5 种 Plugin:SimpleEventPlugin,EnterLeaveEventPlugin,ChangeEventPlugin,SelectEventPlugin,BeforeInputEventPlugin。它们的使命齐全一样,只是解决的事件类别不同,所以外部会有一些差别。本文只以 SimpleEventPlugin 为例来解说这个过程,它解决比拟通用的事件类型,比方 click、input、keydown 等。

以下是 SimpleEventPlugin 中结构合成事件对象并收集事件的代码。

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;
}

创立合成事件对象

这个对立的事件对象由 SyntheticEvent 函数结构而成,它本人遵循 W3C 的标准又实现了一遍浏览器的事件对象接口,这样能够抹平差别,而原生的事件对象只不过是它的一个属性(nativeEvent)。

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

收集事件到执行门路

这个过程是将组件中真正的事件处理函数收集到数组中,期待下一步的批量执行。

先看一个例子,指标元素是 counter,父级元素是 counter-parent。

class EventDemo extends React.Component{state = { count: 0}
  onDemoClick = () => {console.log('counter 的点击事件被触发了');
    this.setState({count: this.state.count + 1})
  }
  onParentClick = () => {console.log('父级元素的点击事件被触发了');
  }
  render() {const { count} = this.state
    return <div
      className={'counter-parent'}
      onClick={this.onParentClick}
    >
      <div
        onClick={this.onDemoClick}
        className={'counter'}
      >
        {count}
      </div>
    </div>
  }
}

当点击 counter 时,父元素上的点击事件也会被触发,相继打印出:

'counter 的点击事件被触发了'
'父级元素的点击事件被触发了'

实际上这是将事件以冒泡的程序收集到执行门路之后导致的。收集的过程由 accumulateSinglePhaseListeners 实现。

accumulateSinglePhaseListeners(
  targetInst,
  dispatchQueue,
  event,
  inCapturePhase,
  accumulateTargetOnly,
);

函数外部最重要的操作无疑是收集事件到执行门路,为了实现这一操作,须要在 fiber 树中从触发事件的源 fiber 节点开始,向上始终找到 root,造成一条残缺的冒泡或者捕捉的门路。同时,沿途路过 fiber 节点时,依据事件名,从 props 中获取咱们真正写在组件中的事件处理函数,push 到门路中,期待下一步的批量执行。

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

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:

<div
  className="parent"
  onClick={onClickParent}
  onClickCapture={onClickParentCapture}
>
  父元素

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

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

收集的后果如下

合成事件对象如何参加到事件执行过程

下面咱们说过,dispatchQueue 的构造如上面这样

[
  {
    event: SyntheticEvent,
    listeners: [listener1, listener2, ...]
  }
]

event 就代表着合成事件对象,能够将它认为是这些 listeners 共享的一个事件对象。当清空 listeners 数组执行到每一个事件监听函数时,这个事件监听能够扭转 event 上的 currentTarget,也能够调用它下面的 stopPropagation 办法来阻止冒泡。event 作为一个共享资源被这些事件监听生产,生产的行为产生在事件执行时。

事件执行

通过事件和事件对象收集的过程,失去了一条残缺的事件执行门路,还有一个被共享的事件对象,之后进入到事件执行过程,从头到尾循环该门路,顺次调用每一项中的监听函数。这个过程的重点在于事件冒泡和捕捉的模仿,以及合成事件对象的利用,如下是从 dispatchQueue 中提取出事件对象和工夫执行门路的过程。

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 到门路里的。那么执行程序的差别是如何体现的呢?答案是循环门路的程序不一样导致了执行程序有所不同。

首先回顾一下 dispatchQueue 中的 listeners 中的事件处理函数排列程序:触发事件的指标元素的事件处理函数排在第一个,下层组件的事件处理函数顺次往后排。

<div onClick={onClickParent}>
  父元素
  <div onClick={onClickChild}>
     子元素
  </div>
</div>
listeners: [onClickChild, onClickParent]

从左往右循环的时候,指标元素的事件先触发,父元素事件顺次执行,这与冒泡的程序一样,那捕捉的程序天然是从右往左循环了。模仿冒泡和捕捉执行事件的代码如下:

其中判断事件执行阶段的根据 inCapturePhase,它的起源在下面的 透传透传事件执行阶段标记 的内容里曾经提到过。

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 挂载到事件对象上,最终传入事件处理函数,咱们得以获取到这个事件对象。

总结

源码中事件零碎的代码量很大,我能活着进去次要是带着这几个问题去看的代码:绑定事件的过程是怎么样的、事件零碎和优先级的分割、真正的事件处理函数到底如何执行的。

总结一下事件机制的原理:因为 fiber 树的特点,一个组件如果含有事件的 prop,那么将会在对应 fiber 节点的 commit 阶段绑定一个事件监听到 root 上,这个事件监听是持有优先级的,这将它和优先级机制分割了起来,能够把合成事件机制当作一个协调者,负责去协调 合成事件对象、收集事件、触发真正的事件处理函数 这三个过程。

欢送扫码关注公众号,发现更多技术文章

正文完
 0