React事件机制的源码分析和思考

2021年10月13日 1,736次浏览

张盼宏(钟摆人)

文章基于React版本:17.0.1

参考对比React版本:16.8.6

引言

在浏览器中,JavaScript是非阻塞的,事件就是一种用来通知正在发生的相关事情的机制,表示基本的用户交互以及其他浏览器内部的事情,JavaScript在接收到这个通知后才会执行相关的事件处理函数,避免阻塞主流程。不管使用什么样的前端框架来开发,与事件打交道都是不可避免的,但是前端框架可能会对浏览器原生的事件机制进行一些封装和改造,例如React就在浏览器原生事件机制的基础上,实现了一套事件机制,将浏览器原生的事件合成为React抽象的SyntheticEvent,并实现了一套模拟浏览器原生事件冒泡和事件捕获的React事件机制。

本文将分别分析原生的事件机制和React事件机制,来了解React事件机制的实现原理以及使用时的一些注意事项。

一、原生事件机制

在原生Web应用开发时,执行事件动作的回调函数通常是绑定在要监听的dom节点上的。dom节点与dom节点之间存在包含关系时,如果一个dom节点被点击,它的父节点、祖先结点实际上是都被点击了的,绑定在上面的点击事件监听函数均应该被执行,那么各个事件监听函数的执行顺序应该是什么样的呢?

在早期W3C未制定统一的标准之前,浏览器的事件机制是分为微软主张的「冒泡」和网景主张的「捕获」两种阵营的,这两种机制的执行顺序是相反的:

  • 冒泡机制:从发生事件的节点开始,逐层向外,依次执行节点上的事件监听
  • 捕获机制:从发生事件的最外层节点开始,逐层向内,依次执行节点上的事件监听

浏览器之间的事件机制互不兼容,给web应用的开发带来了不少的麻烦,编写事件回调需要同时考虑到两种浏览器事件机制。为了解决这个问题,W3C在制定标准时,同时采用了「捕获机制」和「冒泡机制」,按照先捕获再冒泡的方式执行各层dom节点上对应的事件监听。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Static Template</title>

    <style>
      #grandfather {
        background-color: red;
      }

      #father {
        background-color: green;
        margin: 10px;
      }

      #son {
        background-color: blue;
        margin: 10px;
      }
    </style>
  </head>
  <body>
    <div id="grandfather">
      grandfather
      <div id="father">
        father
        <div id="son">
          son
        </div>
      </div>
    </div>
    <script lang="javascript">
      const grandfather = document.querySelector('#grandfather');
      const father = document.querySelector('#father');
      const son = document.querySelector('#son');

      const captureHandler = (name) => {
        console.log(name, 'click capture');
      };

      const bubblingHandler = (name) => {
        console.log(name, 'click bubbling');
      }

      // 添加捕获事件
      grandfather.addEventListener(
        'click',
        captureHandler.bind(null, 'grandfather'),
        true
      );
      father.addEventListener(
        'click',
        captureHandler.bind(null, 'father'),
        true
      );
      son.addEventListener(
        'click',
        captureHandler.bind(null, 'son'),
        true
      );

      // 添加冒泡事件
      grandfather.addEventListener(
        'click',
        bubblingHandler.bind(null, 'grandfather'),
      );
      father.addEventListener(
        'click',
        bubblingHandler.bind(null, 'father'),
      );
      son.addEventListener(
        'click',
        bubblingHandler.bind(null, 'son'),
      );
    </script>
  </body>
</html>

在浏览器执行如上的demo,有son、father、grandfather三层dom,各自绑定着对应的冒泡事件监听和捕获事件监听:

dom结点结构

点击运行截图

按照W3C的标准,在我们点击son节点时,首先会进行事件捕获,从最外层的document开始,首先捕获到点击事件的是grandfather的捕获事件监听,然后是father的捕获事件监听,到达发生点击事件的son节点捕获到点击事件,依次执行其上的捕获事件和冒泡事件,然后点击事件就会依次向外层冒泡,father节点的点击冒泡事件监听执行,grandfather节点的冒泡事件监听执行,直到document的点击冒泡事件执行。

捕获和冒泡的顺序

在这个执行过程中有一点是值得注意的,浏览器绑定事件监听除了使用domxxx.addEventListener方法外,还可以使用对dom节点的onxxx属性赋一个 函数/或者JS字符串 的方式来添加对某个事件的监听,并且同一个dom节点上可以多次对同一个事件类型执行addEventListener来添加事件监听,那么这些事件监听的执行顺序又是怎么样的呢?

  • 对于使用addEventListener添加的对同一个节点的同一个事件的多个监听,按照其添加时的顺序依次执行
  • 使用onxxx属性绑定的事件回调,会在对应的dom节点的冒泡事件监听之前执行

二、React事件

2.1 事件池

在React 16中,为了减少频繁创建和销毁事件对象,提高React的性能,引入了 事件池 的概念,事件池内的事件对象是复用的,在一个事件对象使用完毕后,并不会将这个事件对象销毁,而是将其重置(各个属性均设置成null)之后放回事件池。因此

  • 在React 16中,事件对象不能直接在异步执行的代码中调用,需要先执行事件对象的SyntheticEvent.pertsist方法对事件对象进行持久化,移除事件池不再复用之后才能在异步执行的代码块中使用事件对象。否则在异步调用事件对象上的方法或者属性时,可能调用的是已经被重置后的事件对象,甚至可能调用的是其他事件的事件对象造成无法预料的后果!

事件池对性能带来提升在现代浏览器中非必要,并且这种抽象的概念会提高React的学习和使用的成本,所以在React 17中,已经移除了事件池的概念,事件对象不会再复用,虽然事件对象仍然有pertsist方法,但是该方法执行之后并不会有什么影响。

2.2 事件代理机制

原生事件绑定中,事件时通过dom.addEventListener直接绑定到对应的节点上的,React中的事件机制与原生的事件机制有较大的区别。React内部自己实现了一套事件绑定机制,并没有直接将事件的回调函数绑定到对应的节点上,而是利用浏览器的事件冒泡和捕获机制 在React的root节点(React v16版本为document对象) 上添加了所有浏览器原生冒泡事件和捕获事件的代理,当root节点内部某个事件被触发:

  • 事件被React的root节点捕获之后,就会执行React的捕获事件代理,合成事件对象并模拟浏览器的捕获事件机制将合成事件对象分发给对应的React捕获事件监听执行
  • 事件被冒泡到root节点之后,就会执行React的冒泡事件代理,将合成事件对象分发给对应的React冒泡事件监听执行

React的事件机制大概可以被分为事件绑定、合成事件对象、收集事件的执行链路、触发事件的回调几个阶段,为了更加直观的看到React事件机制,我绘制了如下的流程图(原图链接):

React事件机制

ReactDom (/ReactDom/src/events/EventRegistry.js) 维护了两个变量:

  • allNativeEvents 存储所有的浏览器原生事件
  • registrationNameDependencies React事件与原生事件的依赖关系

2.2.1 React事件的注册

根据不同的浏览器事件合成事件对象的方法等不同,React将同种类型的事件放在一个Plugin中,每个Plugin内部都有一个registerEvents函数,将Plugin内部的事件注册到 allNativeEvents 中。

在React 17中, 这个注册的过程是在React应用页面启动时就会执行的,调用ReactDom.render时就会将所有的委托事件的代理注册到root节点上,而在React 16中是在渲染时根据registractionNameDependencies将正在被渲染的React节点中使用到的事件依赖的原生事件的listner绑定到document上

  • registerEvents 将plugin内的React事件和其依赖的原生事件注册到上述的两个变量(allNativeEvents和registractionNameDependencies)中
  • extractEvents 负责合成事件以及收集事件的执行路径

事件Plugin注册

allNativeEvents变量的内容:

allNativeEvents变量

regeistrationNameDependencies变量的内容:

regeistrationNameDependencies变量

2.2.2 React事件的绑定

经过React内部事件注册后,就得到了存有所有浏览器原生事件名称和React事件与浏览器原生事件依赖关系的映射。接下来就是要根据allNativeEvents内的原生事件名,在React的root节点上创建并绑定所有的事件代理。

事件代理绑定

从上面的流程图可以看到,在调用ReactDom.render渲染入口时,内部会调用一个listenToAllSupportedEvents函数,该函数会遍历 allNativeEvents,为内部的所有原生事件创建一个对应的事件代理listener,并将这个listener通过root节点的addEventListener绑定到root节点的事件监听上。

通过浏览器调试工具,查看root节点绑定的事件:

root节点上的事件监听

除了少数几个事件(如selectionchange)外,其余的每个原生事件都被绑定了两个listener,分别在冒泡阶段和捕获阶段,并且其handler都是React事件代理的分发器dispatchXxxEvent。

React 17与React 16不同

React 16对于一种事件,一般会根据该事件是否有冒泡行为,在document上绑定冒泡/捕获阶段时触发的事件代理中的一个,该代理被触发时同时启动模拟React内部的捕获和冒泡过程

而React 17则是对一种事件在root节点上分别绑定冒泡事件的事件代理和捕获事件的事件代理,捕获事件的代理被触发时,React会分别模拟内部的捕获过程,冒泡事件的代理被触发时,React会分别模拟内部的冒泡过程。

scroll事件在原生的事件机制中是没有冒泡行为的

在React 16中,对于这种不冒泡的事件,则只在document上绑定了capture捕获阶段的事件代理,但是触发该事件代理后,React内部仍会模拟对scroll事件的冒泡。

对于这种原生不冒泡的事件,如果将其冒泡事件的监听委托到root节点上,就无法被触发,查看源码 对于不冒泡的事件,React 17是会直接将冒泡事件的listener绑定到对应的目标dom节点上的

function setInitialDOMProperties(
  tag: string,
  domElement: Element,
  rootContainerElement: Element | Document,
  nextProps: Object,
  isCustomComponentTag: boolean,
): void {
  for (const propKey in nextProps) {
    if (!nextProps.hasOwnProperty(propKey)) {
      continue;
    }
    const nextProp = nextProps[propKey];
    if (propKey === STYLE) {
      // ...
    } else if (propKey === DANGEROUSLY_SET_INNER_HTML) {
      // ...
    } else if (propKey === CHILDREN) {
      // ...
    } else if (
      propKey === SUPPRESS_CONTENT_EDITABLE_WARNING ||
      propKey === SUPPRESS_HYDRATION_WARNING
    ) {
      // Noop
    } else if (propKey === AUTOFOCUS) {
      // We polyfill it separately on the client during commit.
      // We could have excluded it in the property list instead of
      // adding a special case here, but then it wouldn't be emitted
      // on server rendering (but we *do* want to emit it in SSR).
    } else if (registrationNameDependencies.hasOwnProperty(propKey)) {
      if (nextProp != null) {
        // 对onScroll事件进行特殊处理,调用listenToNonDelegatedEvent将冒泡事件的listener绑定到对应的dom节点上
        if (propKey === 'onScroll') {
          listenToNonDelegatedEvent('scroll', domElement);
        }
        // 对于其他的原生会冒泡的事件,在注册的环节中就已经在React的root节点上注册了对应的listener,这里就不需要作处理了
      }
    } else if (nextProp != null) {
      setValueForProperty(domElement, propKey, nextProp, isCustomComponentTag);
    }
  }
}

2.2.3 React事件触发

React在root节点上添加了各个委托事件的监听器,分别监听对应事件的捕获和冒泡事件,然后将监听到的捕获或者冒泡事件通过dispatchEvent等函数将浏览器原生事件合成为SyntheicEvent,并收集事件的触发路径,然后分别模拟浏览器原生事件的捕获和冒泡,按照一定的顺序执行对应节点的事件回调函数。

a. 事件的合成

通过React绑定在fiber节点上的事件回调函数拿到的事件对象,并不是浏览器原生的事件对象,而是经过React合成处理后的SyntheticEvent对象。使用SyntheticEvent对象可以抹平不同浏览器之间事件的差异,将不同浏览器的事件对象抽象成统一的格式。

React在构造SyntheticEvent对象时,会将浏览器的原生事件对象保存在SyntheticEvent.nativeEvent属性中,在需要时可以通过改属性获取浏览器原生的事件对象来进行一些操作。

b. 事件链路的收集

React事件链路的收集,简而言之就是收集从触发事件的节点开始向上直到最外层所有的需要执行的事件回调函数。

以点击click事件为例,React会根据当前事件的target属性,也就是触发click事件的原生dom节点,找到 最近的fiber节点 (距离最近的且有internalInstanceKey属性的结点,没有该属性就检查parentNode,逐层向上检查)

每一个由fiber节点渲染出的dom节点都有一个 [internalInstanceKey] 属性 (见下面的源码),保存着该节点对应的fiber节点的引用,没有该属性的dom节点则不是由React管理的,也就不存在需要由React代理的事件,自然可以跳过。

const randomKey = Math.random()
  .toString(36)
  .slice(2);
const internalInstanceKey = '__reactFiber$' + randomKey;
const internalPropsKey = '__reactProps$' + randomKey;
const internalContainerInstanceKey = '__reactContainer$' + randomKey;
const internalEventHandlersKey = '__reactEvents$' + randomKey;
const internalEventHandlerListenersKey = '__reactListeners$' + randomKey;
const internalEventHandlesSetKey = '__reactHandles$' + randomKey;

dom节点上fiber节点的引用

然后沿着这个fiber节点逐层向上,收集每一层上对应的事件回调,click事件时就是onClick事件回调,将其push到 listeners 数组中,直到最外层的root节点为止,然后将listeners数组以及合成事件对象组成的对象push到 dispatchQueue队列 中。

c. 事件回调函数的执行

收集到事件的执行路径之后,接下来就是要模拟浏览器原生事件冒泡/捕获,按照一定的顺序执行 dispatchQueue队列 中的回调函数,源码如下。

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

React收集fiber节点上绑定的回调函数的顺序是从内层到外层的,与事件冒泡的顺序类似,所以要模拟事件冒泡过程就是将 dispatchQueue 中的 listeners 正序遍历执行,模拟事件捕获就是按照相反的顺序,将 dispatchQueue 中的 listeners 倒序遍历执行。

从上面的代码可以看出,在事件回调执行的过程中,event对象是被所有的事件回调共享的,回调函数在执行的过程中可以对事件对象进行一些操作,例如改变事件对象的 currentTarget 属性,这些改变在是在这一个事件的整个链路上都生效的。

如果某个事件回调函数内部调用了event对象的 stopPropagation() 方法,事件的链路就会中断执行,后面的事件回调将不在被触发。

三、对React事件机制的思考

3.1 重新实现事件机制的优点

  • 抹平不同浏览器事件机制之间的不同,提高兼容性 :React通过合成事件的方式,按照W3C的规范,重新实现一个事件对象,将原来不同浏览器存在差异的浏览器事件对象抽象成一个统一的合成事件对象,按照React规范,在编写React代码时就不需要再编写兼容这些差异的代码。
  • 更好地控制事件的执行链路: 在React内部,事件是有优先级的,在调度时可能需要中断、暂停或者继续某些事件的执行链路,如果不在React内部收集事件的执行路径然后模拟捕获和冒泡的过程,而是直接将事件监听绑定到dom节点上,事件的执行链路则完全是由浏览器在控制,React就无法对其进行调度了。

3.2 React事件与原生事件的执行顺序

浏览器原生在一个dom节点上绑定事件一般有如下的3中方式:

  • 直接向dom节点的onxxx属性

    document.querySelector('#foo').onclick = hanlder;
    
  • 在html中直接在onxxx属性内联编写事件处理的js代码

    <div onclick="handler()">click me</div>
    
  • 通过dom节点的addEventListner添加事件监听

    document.querySelector('#foo').addEventListner('click', handler, false);// 添加冒泡事件的监听器
    document.querySelector('#foo').addEventListner('click', handler, true); // 添加捕获事件的监听器
    

第一种添加事件的方式和第二种添加事件的方式是浏览器同一个特性的不同使用方法,使用这种方式添加的事件回调函数会相互覆盖,事件被触发时,只有最后绑定的事件回调函数会最终得到执行。

在React中,我们可以通过ref或者直接调用document上提供的方法操作dom,为dom节点直接添加原生事件监听器,那么我们通过使用React添加的事件回调函数和我们直接操作dom添加的事件回调函数,其执行顺序是怎样的呢?下面的代码段中,分别使用原生添加事件监听的两种方式向dom节点添加事件监听以及通过React添加事件监听,观察不同方式添加的事件监听的执行顺序:

import { useEffect, useRef } from "react";
import "./styles.css";

export default function App() {
  const sonRef = useRef<HTMLDivElement>(null);
  const parentRef = useRef<HTMLDivElement>(null);

  const clickHandler = (name: string, source: string) => {
    console.log(source, name, "has been clicked");
  };

  const clickCaptureHandler = (name: string, source: string) => {
    console.log(source, name, "has been captured");
  };

  useEffect(() => {
    const parent = parentRef.current;
    const son = sonRef.current;

    if (parent) {
      parent.onclick = () => {
        console.log("parent onclick");
      };
    }

    if (son) {
      son.onclick = () => {
        console.log("son onclick");
      };
    }

    parent?.addEventListener(
      "click",
      clickCaptureHandler.bind(null, "parent", "native"),
      true
    );
    son?.addEventListener(
      "click",
      clickCaptureHandler.bind(null, "son", "native"),
      true
    );
    parent?.addEventListener(
      "click",
      clickHandler.bind(null, "parent", "native")
    );
    son?.addEventListener(
      "click", 
      clickHandler.bind(null, "son", "native")
    );

    if(parent) parent.onclick = clickHandler.bind(null, "parent", "onclick");
    if(son) son.onclick = clickHandler.bind(null, "son", "onclick");
  }, []);

  return (
    <div className="App">
      <div
        ref={parentRef}
        onClick={clickHandler.bind(null, "parent", "react")}
        onClickCapture={clickCaptureHandler.bind(null, "parent", "react")}
      >
        <div
          ref={sonRef}
          onClick={clickHandler.bind(null, "son", "react")}
          onClickCapture={clickCaptureHandler.bind(null, "son", "react")}
        >
          click me
        </div>
      </div>
    </div>
  );
}

点击son节点之后,观察调试工具console:

输出结果

可以看到不同方式绑定的事件回调函数的执行顺序:

React捕获事件 -> 原生捕获事件 -> onxxx绑定的事件 -> 原生冒泡事件 -> React冒泡事件

React的捕获事件先于原生捕获事件执行,但是React的冒泡事件确实滞后于原生的冒泡事件执行的。为什么事件执行顺序是这样的呢?这个就与前面所说的React的事件代理机制有关了,上面的代码的dom层级以及事件的捕获和冒泡事件可以用下面这张图来表示:

图种红色的箭头表示捕获执行的路径,蓝色的箭头表示冒泡执行的路径

react事件和原生事件的执行流程图

因为React的事件代理都是绑定在Root节点上的,结合文章开头提到的浏览器事件的捕获冒泡机制:

  • 捕获阶段
    • 在浏览器事件由外层向内层捕获时,到达Root节点就会触发React的捕获事件代理,此时通过React绑定的Parent React Capture事件和Son React Capture事件就会依次得到执行。
    • 继续向内层捕获事件,通过addEventListner绑定在Parent节点上的捕获事件被触发执行。
    • 以此类推,Son节点捕获事件,通过addEventListener绑定在Son节点上的捕获事件被触发执行。
  • 到达target
    • 当捕获到触发事件的target节点之后,就会停止继续向内层捕获,先按照addEventListener添加时的顺序一次执行绑定在其上的捕获事件,再执行onclick属性绑定的回调函数,最后按照addEventListener添加时的顺序一次执行绑定在其上的冒泡事件
  • 冒泡阶段
    • 事件捕获到target节点并执行其事件函数,就会开始冒泡的过程,首先会冒泡到Parent节点,按照类似的顺序执行Parent节点上的onclick回调和addEventListener绑定的冒泡事件
    • 继续向外层冒泡,冒泡到Root节点,触发绑定在Root节点上的React冒泡事件的代理,此时通过React绑定的Son Bubbling事件和Parent Bubbling事件才会依次得到执行。

通过React事件机制绑定的事件回调和直接通过原生绑定的事件回调的执行顺序比较复杂,一般不建议混合使用React的事件回调和浏览器原生的事件回调,避免产生意料之外的bug。

3.3 阻止事件传播的注意事项

在浏览器原生的事件机制中,调用event对象的stopPropagation方法就可以阻止事件的传播, 调用此方法的事件回调仍然会执行到结束 ,但是 后续的还未执行的事件回调将不会得到执行

同样的,在React的SyntheticEvent对象上,也有stopPropagation方法,可以阻止React合成事件的传播。

a. 原生事件调用stopPropagation

结合上一小节中的React事件和原生事件的执行顺序,这个问题比较容易得到答案:

React捕获仍会执行

由于React的捕获事件是先于原生捕获事件和原生冒泡事件执行的,所以无论在原生捕获事件或者是冒泡事件内调用event.stopPropagation方法,React的捕获事件都已经执行完了

React冒泡将不被执行

由于React的冒泡事件是在原生捕获事件和原生冒泡事件之后执行的,所以无论是在原生捕获事件还是原生冒泡事件内调用event.stopPropagation方法,都会导致React的冒泡事件不被执行

将上一小节中,parent节点的冒泡事件监听修改为如下,然后点击son节点:

parent?.addEventListener("click", (e) => {
  clickHandler.bind(null, "parent", "native")();
  e.stopPropagation();
});

查看浏览器的控制台输出如下:

输出结果

b. React事件调用stopPropagation

要知道React事件内调用合成事件的stopPropagation方法后原生事件是否还会执行,除了上一小节React事件和原生事件的执行顺序意外,还需要知道SyntheticEvent.stopPropagation方法的具体实现,是否会调用原生事件的stopPropagation,这个方法的核心实现如下:

function createSyntheticEvent(Interface: EventInterfaceType){
  function SyntheticBaseEvent(){
    //...
  }
  Object.assign(SyntheticBaseEvent.prototype, {
    //...
    stopPropagation: function() {
      const event = this.nativeEvent;
      if (!event) {
        return;
      }

      if (event.stopPropagation) {
        event.stopPropagation();
        // $FlowFixMe - flow is not aware of `unknown` in IE
      } else if (typeof event.cancelBubble !== 'unknown') {
        // The ChangeEventPlugin registers a "propertychange" event for
        // IE. This event does not support bubbling or cancelling, and
        // any references to cancelBubble throw "Member not found".  A
        // typeof check of "unknown" circumvents this issue (and is also
        // IE specific).
        event.cancelBubble = true;
      }

      this.isPropagationStopped = functionThatReturnsTrue;
    },
    //...
  })
  return SyntheticBaseEvent;
}    

从SyntheticEvent.stopPropagation的实现上可以看出,合成事件在停止传播时,同时也会停止原生事件的传播,所以在React事件内阻止事件的继续传播,需要分两种情况讨论:

  • 如果是在React捕获事件内调用停止事件传播,那么原生捕获事件和原生冒泡事件均不会被执行
  • 如果是在React冒泡事件内调用停止事件传播,那么原生捕获事件和原生冒泡事件均会继续执行

将上一小节的son节点的React捕获事件修改为如下函数:

onClickCapture={(e) => {
  clickCaptureHandler.bind(null, "son", "react")();
  e.stopPropagation();
}}

输出结果

可以看到,原生事件都没有被执行。

3.4 阻止浏览器的默认行为

浏览器的很多标签都是具有默认行为的,比如点击a标签页面会跳转。但是在平时开发的时候,我们可能希望阻止某些元素在某些事件上的默认行为。

原生事件阻止默认行为

在浏览器dom.addEventListner添加的事件监听器中,可以

  • 通过调用 Event.preventDefault() 的方式来阻止浏览器的默认行为
  • 如果是使用onxxx方式绑定的事件回调,还可以通过在回调函数内返回false的方式阻止浏览器的默认行为(不推荐,不是所有的浏览器都支持)
React事件阻止默认行为

在SyntheticEvent对象,有对原生事件的preventDefault的封装

function createSyntheticEvent(Interface: EventInterfaceType){
  function SyntheticBaseEvent(){
    //...
  }
  Object.assign(SyntheticBaseEvent.prototype, {
    preventDefault: function() {
      this.defaultPrevented = true;
      const event = this.nativeEvent;
      if (!event) {
        return;
      }

      if (event.preventDefault) {
        event.preventDefault();
        // $FlowFixMe - flow is not aware of `unknown` in IE
      } else if (typeof event.returnValue !== 'unknown') {
        event.returnValue = false;
      }
      this.isDefaultPrevented = functionThatReturnsTrue; // function functionThatReturnsTrue() {return true}
    },
    //...
  })
  return SyntheticBaseEvent;
}

可以看到,在React的SyntheticEvent合成事件中调用preventDefault方法,内部会调用原生事件对象的preventDefault方法,所以在React事件回调中,直接调用prevntDefault方法即可阻止浏览器的默认行为。

a. 阻止<a/>标签的跳转

所以要阻止a标签的默认跳转,只需要在a标签的点击事件回调内,调用React合成事件的preventDefault方法即可。

export default function App(){
  return (
    <div
      className="App"
      onWheel={(e) => {
        console.log("on app wheel", e.cancelable);
      }}
    >
      <a href="http://www.baidu.com" onClick={(e) => e.preventDefault()}>百度一下,你就知道</a>
    </div>
  );
}

b. 阻止滚轮事件的默认行为

浏览器在input标签的type属性为number时,控件上会有一些默认的操作和行为

number输入框

当鼠标的焦点在input标签上时,如果滚动鼠标的滚轮,input内的数字是会发生变化的,这样的默认行为在大部分时候都是没有用的,甚至可能在用户不知情的情况下改变用户输入的值而造成意外的结果。

如何阻止input标签的滚轮事件呢?首先想到的肯定是像阻止a标签的跳转事件一样,直接在事件回调里调用preventDefault方法来阻止滚轮事件的默认行为,避免改变用户输入的值,就像这样:

<input type="number" onWheel={e => e.preventDefault()}/>

在浏览器内运行一下看看是否奏效:

number输入框

当焦点在输入框里时,滚动滚轮,输入框里的值依旧会发生变化,似乎并没有生效,为什么在滚轮事件中调用preventDefault方法不能阻止浏览器的默认行为呢?打开浏览器的控制台,我们就会看到一堆相同的错误:

控制台报错信息

Unable to preventDefault inside passive event listener invocation.

查看MDN文档,得知passive是dom.addEventListener的一个可选的option

  • passive: Boolean,设置为true时,表示 listener 永远不会调用 preventDefault()。如果 listener 仍然调用了这个函数,客户端将会忽略它并抛出一个控制台警告。查看 使用 passive 改善的滚屏性能 了解更多.

我们在React编写onWheel事件回调时,并没有对回调设置passive,查看React的源码,在addEventTrappedListener函数中,有这样一段代码

function addEventTrappedListener(...){
  //...
  let isPassiveListener = undefined;
  if (passiveBrowserEventsSupported) {
    // Browsers introduced an intervention, making these events
    // passive by default on document. React doesn't bind them
    // to document anymore, but changing this now would undo
    // the performance wins from the change. So we emulate
    // the existing behavior manually on the roots now.
    // https://github.com/facebook/react/issues/19651
    if (
      domEventName === 'touchstart' ||
      domEventName === 'touchmove' ||
      domEventName === 'wheel'
    ) {
      isPassiveListener = true;
    }
  }
  //...
  if(isCapturePhaseListener){
    if (isPassiveListener !== undefined) {
      unsubscribeListener = addEventCaptureListenerWithPassiveFlag(
        targetContainer,
        domEventName,
        listener,
        isPassiveListener,
      );
    } else {
      unsubscribeListener = addEventCaptureListener(
        targetContainer,
        domEventName,
        listener,
      );
    }
  }else{
    //...
  }
  //...
}

由此可见,对于touchstart、touchmove和wheel事件,React会默认为其添加passive标志,告诉浏览器不需要等待事件处理函数执行完成就可以执行浏览器的默认行为,这样做的目的是为了避免用户在滑动屏幕或者鼠标滚轮时,浏览器需要等待事件处理函数执行完成才执行默认行为(一般是页面或者控件的滚动),导致的滚动卡顿,尤其是在事件处理函数耗时较长时,卡顿会非常明显。

所以在onWheel事件中调用preventDefault来阻止浏览器的默认行为是无效的,那此时我们应该怎么阻止数字输入框的鼠标滚轮事件呢?由于React在绑定事件回调时并没有有关passive的参数,也没有onXxxPassive或者onXxxNoPassive的绑定方式,所以如果需要阻止数字输入框的鼠标滚轮事件,我们只能通过使用原生事件绑定,绑定一个passive为false的事件处理函数到这个输入框上,然后在这个事件处理函数中调用preventDefault来阻止浏览器的默认行为

export default function App() {
  const inputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    inputRef.current?.addEventListener(
      "wheel",
      (e) => {
        e.preventDefault();
      },
        { passive: false }
    );
  }, []);

  return (
    <div className="App">
      <input type="number" ref={inputRef} />
    </div>
  );
}

这样数字输入框的默认行为就被成功阻止了。

四、总结

出于提高React的兼容性和对事件的执行流程的优化调度的需要,React在内部模拟浏览器原生事件机制,实现了一个独立的可以由React控制的事件机制。为了能够渐进升级,并为后面的React 18作准备,React 17在React 16的基础上对事件机制的实现和一些细节进行了一些调整,比如React 17在进行事件委托、触发React事件的冒泡和捕获的方式等的实现和执行的细节均与React 16相比有较大的改动。

同时本文通过一些demo代码,对React的事件处理函数和原生的事件处理函数的执行先后顺序进行了比较,同时使用React以及原生事件时,各种事件会按照:React捕获事件监听 => 原生捕获事件监听 => 通过onxxx绑定的事件监听 => 原生冒泡事件监听 => React冒泡事件监听 的顺序执行。在不同阶段的事件监听中通过不同的方式调用stopPropagation也会产生不同的效果。

了解了React事件机制的运行原理之后,下一步我们将了解React对不同优先级的事件是如何进行调度的

参考

React 17.0.1: https://github.com/facebook/react/tree/17.0.1

React 16.8.6: https://github.com/facebook/react/tree/16.8.6

探索React合成事件: https://segmentfault.com/a/1190000038251163