事件机制三顾

前端攻城狮在“攻城”之时往往有一利器那便是事件

此利器非常人所能习得之物。欲精其身,必先精其内,明其相辅相成之道。

咳~这么说下去我怕是马上就走到文化沙漠了哈哈哈。今天呢,就来研究研究前端乃至React的事件相关机制。 这一期有太多的理论知识需要做铺垫,且有的内容需要涉及到fiber之类的高深知识,我自己也是一知半解,所以如若有误,一定要告诉我,还要说明所有涉及到的源码都是粗略地读,没有仔细看里面的具体实现。

源码阅读版本16.13.x

一顾:DOM事件流

事件机制的历史要追溯到1988年了。

W3C协会早在1988年就开始了DOM标准的制定,W3C DOM标准可以分为 DOM1、DOM2、DOM3 三个版本。

从 DOM2 开始,DOM 的事件传播分三个阶段进行:事件捕获阶段、处于目标阶段和事件冒泡阶段。

下面给出这三个阶段的大概定义

不得不说的EventTarget.addEventListener()

在写addEventListener()的时候我还有些犹豫,前缀应该写什么呢?DOMElement,Document,Window貌似都行。看了MDN的定义之后,才知道应该是EventTarget

可以来品一下MDN的定义:

The EventTarget method addEventListener() sets up a function that will be called whenever the specified event is delivered to the target. Common targets are Element, Document, and Window, but the target may be any object that supports events (such as XMLHttpRequest).

这里值得注意的是:除了一些常见的例如DOM Element, Document, Window这类EventTarget可以绑定事件之外,还有一些”奇怪的东西”也能绑定,例如XMLHttpRequest

EventTarget.addEventListener()的参数

众所周知EventTarget.addEventListener()的前两个参数是做什么的:

其实我之前还记得第三个参数是useCapture但是我打开现在的mdn发现第三个参数已经变成了options????

先来自己验证一下整个事件流程

首先我们需要构造一个3层的嵌套结构。


function App() {
  return (
    <div className="bodyContainer">
      <div className="container1">
        <span className="tex1">Container 1</span>
        <div className="container2">
          <span className="tex1">Container 2</span>
          <div className="container3">
            <span className="tex1">Container 3</span>
          </div>
        </div>
      </div>
    </div>
  );
}

分别给每个层级的div加一个事件监听


import React, { useCallback, useEffect } from 'react';
import './App.css';

function App() {
  const onContainerBodyClick = useCallback(() => {
    console.log('click in container')
  }, []);

  const onContainer1Click = useCallback(() => {
    console.log('click in container 1');
  }, []);

  const onContainer2Click = useCallback(() => {
    console.log('click in container 2');
  }, []);

  const onContainer3Click = useCallback(() => {
    console.log('click in container 3');
  }, []);

  useEffect(() => {
    const $ContainerBodyEle = document.getElementById('containerBody');
    const $Container1Ele = document.getElementById('container1');
    const $Container2Ele = document.getElementById('container2');
    const $Container3Ele = document.getElementById('container3');

    $ContainerBodyEle.addEventListener('click', onContainerBodyClick);
    $Container1Ele.addEventListener('click', onContainer1Click);
    $Container2Ele.addEventListener('click', onContainer2Click);
    $Container3Ele.addEventListener('click', onContainer3Click);

    return () => {
      $ContainerBodyEle.removeEventListener('click', onContainerBodyClick);
      $Container1Ele.removeEventListener('click', onContainer1Click);
      $Container2Ele.removeEventListener('click', onContainer2Click);
      $Container3Ele.removeEventListener('click', onContainer3Click);
    }
  }, [onContainerBodyClick, onContainer1Click, onContainer2Click, onContainer3Click]);


  return (
    <div id="containerBody" className="bodyContainer">
      <div id="container1" className="container1">
        <span className="tex1">Container 1</span>
        <div id="container2" className="container2">
          <span className="tex1">Container 2</span>
          <div id="container3" className="container3">
            <span className="tex1">Container 3</span>
          </div>
        </div>
      </div>
    </div>
  );
}

export default App;

点击最外层

点击Container1

点击Container2

点击Container3

因为是验证所以想把过程贴的详细一些,就把每次的截图都贴出来了。

可以看到如果按照之前我们所说的冒泡机制,我们的输出结果是完全符合的。

这时候我想看看options里的capture属性和useCapture有什么区别?

我们在Container2上分别设置下options: { capture: true }useCapture: true

$Container2Ele.addEventListener('click', onContainer2Click, { capture: true });

$Container2Ele.addEventListener('click', onContainer2Click, true);

发现打印的结果都如下

查看DOM的事件监听发现最终都体现为这个样子

所以我的结论是,options中的capture属性useCapture配置的是同一个功能。将Container2的事件在捕获阶段就执行,所以最先打印的是click in container 2

二顾:React事件机制

在介绍完最基本的DOM的事件流向机制之后,我们来学习一下React的事件机制。

友情提示: 会有很多源码阅读…没有办法,搞懂原理就离不开去瞟几眼源码的命运…

首先我们先把之前的例子改成React中的事件写法


import React, { useCallback, useEffect } from 'react';
import './App.css';

function App() {
  const onContainerBodyClick =() => {
    console.log('react container')
  }

  const onContainer1Click = () => {
    console.log('react container 1');
  }

  const onContainer2Click = () => {
    console.log('react container 2');
  }

  const onContainer3Click =() => {
    console.log('react container 3');
  };

  return (
    <div id="containerBody" className="bodyContainer" onClick={onContainerBodyClick}>
      <div id="container1" className="container1" onClick={onContainer1Click}>
        <span className="tex1">Container 1</span>
        <div id="container2" className="container2" onClick={onContainer2Click}>
          <span className="tex1">Container 2</span>
          <div id="container3" className="container3" onClick={onContainer3Click}>
            <span className="tex1">Container 3</span>
          </div>
        </div>
      </div>
    </div>
  );
}

export default App;

然后点击Container 3打印结果如下

效果和DOM的事件打印出来一摸一样。但是真的是一摸一样吗???

这个时候把React事件和DOM事件同时绑定到对应的元素上。

这个时候区别瞬间就出来了!

React的事件全部都是晚于DOM事件的执行。

为什么React的事件全部都会晚于DOM事件的执行呢??

我们都知道React会把事件代理到document上(< React v17.x),如图

React提供了一种“顶层注册,事件收集,统一触发”的事件机制。

SyntheticEvent(合成事件)

SyntheticEvent是一个,这个类实例出来的对象是一个合成事件,顾名思义SyntheticEvent是有合成之意的。

引入一个问题

既然React提供了合成事件,那么合成事件原生事件是如何最终对应起来并发挥作用的呢??

EventPlugin(React 事件插件)

事件插件可以认为是React将不同的合成事件处理函数封装成了一个模块

每个模块只处理自己对应的合成事件,这样不同类型的事件种类就可以在代码上解耦,例如针对onChange事件有一个单独的LegacyChangeEventPlugin插件来处理,针对onMouseEnteronMouseLeave 使用 LegacyEnterLeaveEventPlugin 插件来处理。

React会在一开始就会把事件插件全部都加载进来

injectEventPluginsByName({
    SimpleEventPlugin: LegacySimpleEventPlugin,
    EnterLeaveEventPlugin: LegacyEnterLeaveEventPlugin,
    ChangeEventPlugin: LegacyChangeEventPlugin,
    SelectEventPlugin: LegacySelectEventPlugin,
    BeforeInputEventPlugin: LegacyBeforeInputEventPlugin
});

事件绑定

其实仔细关注一下我们是如何注册一个React事件的


<div id="container1" className="container1" onClick={onContainer1Click}>

onClick其实是一个prop,而我们的事件函数onContainer1Click是通过传参的方式传到了组件(其实是fiber节点,但是我对fiber的了解很少,所以这里没办法展开讲)。

事件注册其实就是将我们在JSX中所编写的事件相关的操作绑定到document上的一种操作。

事件绑定主要是在初始化DOM的事件(当然DOM更新的时候也会有,但我们这里不讨论)。在初始化的时候会调用一个方法叫做setInitialDOMProperties()

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,这里判断某个propKey是否在registrationNameDependencies中,registrationNameDependencies是一个对象,存储了所有React事件对应的原生DOM事件的集合。

补充两个比较重要的对象

第一个是registrationNameModule

它包含了 React 事件到它对应的 plugin 的映射, 大致长下面这样,它包含了 React 所支持的所有事件类型,这个对象最大的作用是判断一个组件的 prop 是否是事件类型,这在处理原生组件的 props 时候将会用到,如果一个 prop 在这个对象中才会被当做事件处理。

{
    onBlur: SimpleEventPlugin,
    onClick: SimpleEventPlugin,
    onClickCapture: SimpleEventPlugin,
    onChange: ChangeEventPlugin,
    onChangeCapture: ChangeEventPlugin,
    onMouseEnter: EnterLeaveEventPlugin,
    onMouseLeave: EnterLeaveEventPlugin,
    ...
}

第二个是registrationNameDependencies,这个对象就是回答我们最开始提的那个问题的答案,他就是将SyntheticEvent原生DOM事件一一对应起来的对象。

{
    onBlur: ['blur'],
    onClick: ['click'],
    onClickCapture: ['click'],
    onChange: ['blur', 'change', 'click', 'focus', 'input', 'keydown', 'keyup', 'selectionchange'],
    onMouseEnter: ['mouseout', 'mouseover'],
    onMouseLeave: ['mouseout', 'mouseover'],
    ...
}

如果是事件类型的prop,那么将会调用ensureListeningTo()去绑定事件。

function ensureListeningTo(rootContainerElement, registrationName, domElement) {
  const isDocumentOrFragment =
    rootContainerElement.nodeType === DOCUMENT_NODE ||
    rootContainerElement.nodeType === DOCUMENT_FRAGMENT_NODE
  const doc = isDocumentOrFragment
    ? rootContainerElement
    : rootContainerElement.ownerDocument
  listenTo(registrationName, doc)  // <------- 看看这
}

可以简单的理解为ensureListeningTo就是去完成事件绑定这步操作的。

export function listenTo(
  registrationName: string,
  mountAt: Document | Element,
) {
  const isListening = getListeningForDocument(mountAt)
  const dependencies = registrationNameDependencies[registrationName]

  for (let i = 0; i < dependencies.length; i++) {
    const dependency = dependencies[i]
    if (!(isListening.hasOwnProperty(dependency) && isListening[dependency])) {
      switch (dependency) {
        case TOP_SCROLL:
          trapCapturedEvent(TOP_SCROLL, mountAt)  // <------- 看看这
          break
        case TOP_FOCUS:
        case TOP_BLUR:
          trapCapturedEvent(TOP_FOCUS, mountAt)
          trapCapturedEvent(TOP_BLUR, mountAt)
          // We set the flag for a single dependency later in this function,
          // but this ensures we mark both as attached rather than just one.
          isListening[TOP_BLUR] = true
          isListening[TOP_FOCUS] = true
          break
        case TOP_CANCEL:
        case TOP_CLOSE:
          if (isEventSupported(getRawEventName(dependency))) {
            trapCapturedEvent(dependency, mountAt) // <------- 看看这
          }
          break
        case TOP_INVALID:
        case TOP_SUBMIT:
        case TOP_RESET:
          // We listen to them on the target DOM elements.
          // Some of them bubble so we don't want them to fire twice.
          break
        default:
          // By default, listen on the top level to all non-media events.
          // Media events don't bubble so adding the listener wouldn't do anything.
          const isMediaEvent = mediaEventTypes.indexOf(dependency) !== -1
          if (!isMediaEvent) {
            trapBubbledEvent(dependency, mountAt) // <------- 还有这
          }
          break
      }
      isListening[dependency] = true
    }
  }
}

这里就是大佬分享的最主要的那部分了,我们不需要看懂整个listenTo()

export function trapBubbledEvent(
  topLevelType: DOMTopLevelEventType,
  element: Document | Element,
) {
  if (!element) {
    return null
  }
  const dispatch = isInteractiveTopLevelEventType(topLevelType)
    ? dispatchInteractiveEvent
    : dispatchEvent

  addEventBubbleListener(  // <------- 看看这
    element,
    getRawEventName(topLevelType),
    // Check if interactive and wrap in interactiveUpdates
    dispatch.bind(null, topLevelType),
  )
}

export function trapCapturedEvent(
  topLevelType: DOMTopLevelEventType,
  element: Document | Element,
) {
  if (!element) {
    return null
  }
  const dispatch = isInteractiveTopLevelEventType(topLevelType)
    ? dispatchInteractiveEvent
    : dispatchEvent

  addEventCaptureListener(  // <------- 看看这
    element,
    getRawEventName(topLevelType),
    // Check if interactive and wrap in interactiveUpdates
    dispatch.bind(null, topLevelType),
  )
}

trapCapturedEvent & trapBubbledEvent就是我之前说的,如同这个函数的名字,给捕获(或冒泡)阶段的事件设置陷阱。

也就是设置监听事件。

源码中的addEventCaptureListener & addEventBubbleListener都只是element.addEventListener包裹了一层。其本意就是去设置监听器了。

到这个阶段,绑定就彻底完成了。

完成绑定之后的监听

第一步绑定完成之后,把绑定到对应位置的监听函数叫做listener。但这个listener并不是我们在组件里所写的那一个函数,而是一个通过createEventListenerWrapperWithPriority()包裹生成的函数。

在看这个这么长名字的函数其实已经明白这个函数是做什么的了。创建一个有优先级eventListener包裹器

其实不同的事件都有各自不同的优先级

值得补充

到现在位置我们遗漏了一个知识点,就是我们改怎么区分事件执行阶段呢?

事件注册(事件绑定)的时候我们可以通过区分onClick or onClickCapture来区分是在该事件是以捕获阶段的顺序执行还是冒泡阶段的顺序执行。

但是到了真正执行事件的时候呢?我们已经把对应的事件函数通过createEventListenerWrapperWithPriority()包裹了,并不是我们本身的那个事件处理函数了。

这时候createEventListenerWrapperWithPriority()包裹形成的函数本身接受一个参数eventSystemFlags用于区分系统各个阶段。

比如eventSystemFlags其中一个标记叫IS_CAPTURE_PHASE表明了当前的事件是捕获阶段触发。当事件名含有Capture后缀时,eventSystemFlags会被赋值为IS_CAPTURE_PHASE

可以看这张图


最后的事件触发

这里的触发流程极其复杂(for me),所以我直接引用React 事件系统工作原理 - 网易大前端文章中的解释

最后坚持一会,源码马上就要看完啦。

我们知道由于所有类型种类的事件都是绑定为React的 dispatchEvent 函数,所以就能在全局处理一些通用行为。

这里的dispatchEventForLegacyPluginEventSystem()我在别的源码版本中就是dispatchEvent()…具体不知道其中的渊源。

export function dispatchEventForLegacyPluginEventSystem(
  topLevelType: DOMTopLevelEventType,
  eventSystemFlags: EventSystemFlags,
  nativeEvent: AnyNativeEvent,
  targetInst: null | Fiber,
): void {
  const bookKeeping = getTopLevelCallbackBookKeeping(  // <------- 看看这
    topLevelType,
    nativeEvent,
    targetInst,
    eventSystemFlags
  );

  try {
    // Event queue being processed in the same cycle allows
    // `preventDefault`.
    batchedEventUpdates(handleTopLevel, bookKeeping);
  } finally {
    releaseTopLevelCallbackBookKeeping(bookKeeping);
  }
}
  1. 任意一个事件触发,执行dispatchEvent函数。
  2. dispatchEvent 执行 batchedEventUpdates(handleTopLevel)batchedEventUpdates 会打开批量渲染开关并调用 handleTopLevel。
  3. handleTopLevel 会依次执行 plugins 里所有的事件插件
  4. 如果一个插件检测到自己需要处理的事件类型时,则处理该事件。

其实这个阶段最主要的目的就是模拟捕获和冒泡的过程。

至于源码,因为涉及到React Fiber相关的知识,我在这方面还有欠缺,暂时没办法展开讲。

终于能回到我们的Demo了

看源码可能头都已经大了🤦‍♂️。这个时候在我们初步了解过一些深层的原理之后,我们就可以理解一下之前的现象。

如图中所示React事件都会代理到document上

简单的概括总结一下

而这一切的一切,是需要DOM事件冒泡到document才能执行的。如果我们在其中一个元素的原生DOM事件上添加组织冒泡行为:

const onContainer2Click = useCallback((e) => {
  e.stopPropagation();
  console.log('click in container 2');
}, []);

三顾:回顾总结

React 17更换了事件绑定的位置。

React 17取消了事件复用

官方的解释是事件对象的复用在现代浏览器上性能已经提高的不明显了,反而还很容易让人用错,所以干脆就放弃这个优化。

现在返回来看React事件系统图

参考