react18中的事件系统

1,227 阅读3分钟

一、前言

在react17之前事件系统是将所有合成事件都挂载在document上。通过dispatchEvent代理所有的事件处理逻辑。当事件触发时,会收集当前事件源到根元素的所有同类型事件(此处包括捕获和冒泡)。生成事件列表,最后遍历事件列表,执行事件函数。

react17的主要变化是将事件挂载在容器上(通常为id为app的div上)。而到了react18事件系统上有了较大的改变。今天就让我们一起来看看react18中的事件系统。

为什么有事件系统

在开始正文之前,先来思考一个问题。react为什么会有事件系统?

这是因为,对于不同的浏览器事件存在不同的兼容性。react试图实现一个兼容所有浏览器的框架。为此,react实现了一个兼容全浏览器的事件系统来抹平浏览器之间的差异。

二 、新的事件系统

旧的事件系统存在的问题

  function Index() {
    const refObj = React.useRef(null)
    useEffect(() => {
      const handler = () => {
        console.log('事件监听')
      }
      refObj.current.addEventListener('click', handler)
      return () => {
        refObj.current.removeEventListener('click', handler)
      }
    }, [])
    const handleClick = () => {
      console.log('冒泡阶段执行')
    }
    const handleCaptureClick = () => {
      console.log('捕获阶段执行')
    }
    return <button ref={refObj} onClick={handleClick} onClickCapture={handleCaptureClick} >点击</button>
  }

在旧的事件系统中触发button的点击事件打印出的内容将是:事件监听 -> 捕获阶段执行 -> 冒泡阶段执行。这是不符合预期的

在新的事件系统中触发button的点击事件打印出的内容将是:捕获阶段执行 -> 事件监听 -> 冒泡阶段执行。这是符合预期的

为什么会出现这样的问题?

原因就是:在旧的事件系统中事件的捕获和冒泡都是模拟的,本质上都是在冒泡阶段执行的。

那么新的事件系统是如何解决这个问题的呢?

一、事件绑定

在新的事件系统中,createRoot方法会一次性注册完全部的事件

function createRoot (container, options) {
    ...省略
    listenToAllSupportedEvents(rootContainerElement); // $FlowFixMe[invalid-constructor] Flow no longer supports calling new on functions
}

让我们看到listenToAllSupportedEvents方法: 方法中出现了两个变量比较关键:allNativeEvents和nonDelegatedEvents

allNativeEvents保存了大多数的浏览器事件:包括click、keydown等81种

nonDelegatedEvents中保存了在js中不冒泡的事件:包括scroll、load、play等

function listenToAllSupportedEvents (rootContainerElement) {
      /* allNativeEvents 是一个 set 集合,保存了大多数的浏览器事件 */
      if (!rootContainerElement[listeningMarker]) {
        rootContainerElement[listeningMarker] = true;
        allNativeEvents.forEach(function (domEventName) {
          // We handle selectionchange separately because it
          // doesn't bubble and needs to be on the document.
          if (domEventName !== 'selectionchange') {
            /* nonDelegatedEvents 保存了 js 中,不冒泡的事件 */
            if (!nonDelegatedEvents.has(domEventName)) {
              /* 在冒泡阶段绑定事件 */
              listenToNativeEvent(domEventName, false, rootContainerElement);
            }
            /* 在捕获阶段绑定事件 */
            listenToNativeEvent(domEventName, true, rootContainerElement);
          }
        });
      }
    }

我们看到通过listenToNativeEvent方法分别对冒泡和捕获阶段进行事件绑定。

如果是不冒泡的事件,只对该事件进行捕获阶段的绑定;否则会对该事件冒泡和捕获阶段都绑定。我们可以明确的一点是react是通过listenToNativeEvent进行事件绑定的。让我们来看看listenToNativeEvent方法

function listenToNativeEvent (domEventName, isCapturePhaseListener, target) {
    ...省略
   addTrappedEventListener(target, domEventName, eventSystemFlags, isCapturePhaseListener);
}

省略了其他一些代码我们来到了addTrappedEventListener方法

function addTrappedEventListener (targetContainer, domEventName, eventSystemFlags, isCapturePhaseListener, isDeferredListenerForLegacyFBSupport) {
        ...省略
      if (isCapturePhaseListener) {
        if (isPassiveListener !== undefined) {
          unsubscribeListener = addEventCaptureListenerWithPassiveFlag(targetContainer, domEventName, listener, isPassiveListener);
        } else {
          // 绑定捕获事件
          unsubscribeListener = addEventCaptureListener(targetContainer, domEventName, listener);
        }
      } else {
        if (isPassiveListener !== undefined) {
          unsubscribeListener = addEventBubbleListenerWithPassiveFlag(targetContainer, domEventName, listener, isPassiveListener);
        } else {
          // 绑定冒泡事件
          unsubscribeListener = addEventBubbleListener(targetContainer, domEventName, listener);
        }
      }
    }

此处我们关注addEventCaptureListeneraddEventBubbleListener。这两个方法就是绑定捕获和冒泡事件的方法

function addEventBubbleListener (target, eventType, listener) {
  target.addEventListener(eventType, listener, false);
  return listener;
}

function addEventCaptureListener (target, eventType, listener) {
  target.addEventListener(eventType, listener, true);
  return listener;
}

此时如果发生点击事件就会触发两次listener(此处的listener就是旧系统中的dispatchEvent,react对不同类型使用了不同的执行函数此处不展开说明,有兴趣可以查看源码)

二、事件触发

接下来我们触发一次点击事件。(此处我们只考虑此处的listener就是旧系统中的dispatchEvent)依次执行dispatchEvent -> dispatchEventForPluginEventSystem -> batchedUpdates -> dispatchEventsForPlugins

其中batchedUpdates是批量更新的逻辑。这里我们不展开讨论。

我们重点关注dispatchEventsForPlugins

function dispatchEventsForPlugins (domEventName, eventSystemFlags, nativeEvent, targetInst, targetContainer) {
  /* 找到发生事件的元素——事件源 */
  var nativeEventTarget = getEventTarget(nativeEvent);
  /* 待更新队列 */
  var dispatchQueue = [];
  /* 找到待执行的事件 */
  extractEvents$5(dispatchQueue, domEventName, targetInst, nativeEvent, nativeEventTarget, eventSystemFlags);
  /* 执行事件 */
  processDispatchQueue(dispatchQueue, eventSystemFlags);
}

nativeEventTarget:为事件源

dispatchQueue:为更新队列

extractEvents$5:为找到待执行事件的函数

processDispatchQueue:为执行事件的函数

我们来举一个列子

    function Index () {
      const handleClick = () => {
        console.log('button 冒泡阶段执行')
      }
      const handleCaptureClick = () => {
        console.log('button 捕获阶段执行')
      }
      const handleParentClick = () => {
        console.log('div 冒泡阶段')
      }
      const handleParentCaptureClick = () => {
        console.log('div 捕获阶段')
      }
      return <div onClick={handleParentClick} onClickCapture={handleParentCaptureClick}>
        <button onClick={handleClick} onClickCapture={handleCaptureClick} >点击</button>
      </div>
    }

当点击button触发点击事件。在捕获和冒泡阶段都会执行dispatchEventsForPlugins函数。因此我打印了捕获和冒泡阶段dispatchQueue的值

捕获阶段

123

冒泡阶段

234

我们可以看到dispatchQueue中只有一个元素。包含了event和listeners

event:代表事件源合成的event

listeners:是个对象,包含了三个属性;

currentTarget:发生事件的 DOM 元素。 

instance : button 对应的 fiber 元素。 

listener :一个数组,存放绑定的事件处理函数本身

通过currentTarget中的instance属性来锁定button的friber元素。接下来通过friber就能找到props中的事件。通过return向上遍历找到所有的同类型的事件。比如onClick或者onCLickCaptrue。如此一来就得到了listener。(用来存放绑定事件本身)。最后执行这个listener数组。这里如果是冒泡阶段则从前开始执行。如果是冒泡阶段则从后开始执行。

因为事件源是react自己合成的。这里如果一个事件中执行了e.stopPropagation那么事件源就能感知到。接下来就可以阻止事件冒泡。

如此依赖就模拟了整个事件流的过程并实现了阻止事件冒泡。

三、总结

最后我们来总结整个过程:

首先事件初始化合成事件 -> 捕获和冒泡分别绑定事件 -> 进行事件收集 -> 执行捕获阶段的事件 -> 执行冒泡阶段的事件

如果本人有写的不对的地方,希望可以在指出,我会实时纠正。有疑惑的也欢迎在评论区讨论。