react原理:合成事件机制

830 阅读6分钟

点击这里进入react原理专栏

这篇文章讲解以下react的合成事件机制。一提到合成事件,有些人可能就会简单的认为,react合成事件就是为我们提供了一个兼容不同浏览器的事件对象。但其实合成事件机制并没有这么简单,比如看下面的代码输出(点击inner):

class App extends React.Component {
  componentDidMount() {
    outer.addEventListener('click', function() {
      console.log('native event click outer')
    })
    
    inner.addEventListener('click', function() {
      console.log('native event click inner')
    })
  }
  
  handleClickInner = () => {
    console.log('react event click inner')
  }
  
  handleClickOuter = () => {
    console.log('react event click outer')
  }
  
  render() {
    return (
    	<div className='outer' onClick={this.handleClickInner}>
      	<div className='inner' onClick={this.handleClickOuter}></div>
      </div>
    )
  }
}

答案是:native event click inner -> native event click outer -> react event click inner -> react event click outer

如果你不知道为什么是这个顺序的话,这篇文章会帮到你。

下面介绍一下react的合成事件机制

总体介绍

首先,react的合成事件机制绝不仅仅是封装了以兼容各个浏览器的事件对象这么简单,而是自己实现了一套事件触发机制。首先我们要明确两个概念:react事件和原生事件。

react事件就是我们在jsx中写的类似onClick={this.handleClick}这样的代码。而使用addEventListener添加的事件就是原生事件。react只会对react事件采用合成事件机制来进行处理,而不会处理原生事件。

react的事件处理分为两个阶段:初始渲染时的顶层注册,以及事件触发时的模拟派发。

顶层注册

react合成事件机制采用了事件代理的思想,将每个react事件依赖的原生事件绑定到了root元素上。这里提到了react事件依赖的原生事件。比如react事件:onMouseEnter,这个事件依赖了两个原生事件:mouseoutmouseoverreact会将所有react事件依赖的原生事件都绑定到root元素上,并且捕获阶段和冒泡阶段的事件都会绑定。可以在chrome的开发者工具中看到root元素上绑定的事件。

事件绑定.png

这些原生事件的挂载是在ReactDOM.render中挂载的。ReactDOM.render会调用createRootImpl方法,该方法会调用listenToAllSupportedEvents方法,循环所有事件,通过addTrappedEventListener函数进行监听。

现在还有一个问题,监听函数listener是什么?其实,root元素上的事件监听函数就是一个触发器,触发器的创建也在addTrappedEventListener函数中

function addTrappedEventListener(targetContainer, domEventName, eventSystemFlags, isCapturePhaseListener, isDeferredListenerForLegacyFBSupport) {
  var listener = createEventListenerWrapperWithPriority(targetContainer, domEventName, eventSystemFlags);
  // ... 
}

createEventListenerWrapperWithPriority从名字也可以看出,这个方法创建了一个带有优先级的触发器

function createEventListenerWrapperWithPriority(targetContainer, domEventName, eventSystemFlags) {
  var eventPriority = getEventPriorityForPluginSystem(domEventName);
  var 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);
}

而不论是那种优先级,最终都会调用dispatchEvent函数,因此这个函数就是最终的触发器。

这样,react就完成了事件的顶层注册。当我们触发点击事件时,会进入模拟派发阶段。

模拟派发

如果我们触发了一个click事件,根据DOM事件流,首先在事件捕获阶段,root元素的click事件绑定的捕获阶段监听函数会被触发,也就是dispatchEvent函数。dispatchEvent函数会根据event.target属性确定事件源对象,并收集从源对象到fiber根节点的所有dom节点以及绑定的事件处理函数,之后一次调用这些事件处理函数,这样就模拟了事件捕获阶段的触发过程。

而在事件冒泡阶段,执行流程类似,当事件冒泡到root元素时,触发dispatchEvent函数,之后收集事件处理函数,并依次执行。

这里要明确一点:如果我们在某个div上绑定了onClick事件,react并不会把事件绑定到这个div上,而是调用这个div的事件处理函数来模拟事件的触发。

知道了大致流程后,我们来看一下源码

dispatchEvent最终会调用dispatchEventForPluginEventSystem函数

function dispatchEventForPluginEventSystem(domEventName, eventSystemFlags, nativeEvent, targetInst, targetContainer) {
  // ...
  batchedEventUpdates(function () {
    return dispatchEventsForPlugins(domEventName, eventSystemFlags, nativeEvent, ancestorInst);
  });
}

这里的batchedEventUpdates暂时不看,这涉及到state更新相关的内容,它会调用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);
}

extractEvents$5方法会调用extractEvents$4

function extractEvents$4(dispatchQueue, domEventName, targetInst, nativeEvent, nativeEventTarget, eventSystemFlags, targetContainer) {
  // ...
  var _listeners = accumulateSinglePhaseListeners(targetInst, reactName, nativeEvent.type, inCapturePhase, accumulateTargetOnly);
  
  if (_listeners.length > 0) {
    var _event = new SyntheticEventCtor(reactName, reactEventType, null, nativeEvent, nativeEventTarget);
    dispatchQueue.push({
      event: _event,
      listeners: _listeners
    });
  }
}

accumulateSinglePhaseListeners就会收集事件触发路径,并返回收集到的事件处理函数数组。之后创建一个合成事件对象。这也就是为什么react官方文档中说所有事件共享一个事件对象。

接下来看一下accumulateSinglePhaseListeners

function accumulateSinglePhaseListeners(targetFiber, reactName, nativeEventType, inCapturePhase, accumulateTargetOnly) {
  var captureName = reactName !== null ? reactName + 'Capture' : null;
  var reactEventName = inCapturePhase ? captureName : reactName;
  var listeners = [];
  var instance = targetFiber;
  var lastHostComponent = null;

  while (instance !== null) {
    var _instance2 = instance,
        stateNode = _instance2.stateNode,
        tag = _instance2.tag;
    // HostComponent就是浏览器原生标签,比如div,button
    if (tag === HostComponent && stateNode !== null) {
      lastHostComponent = stateNode;

      if (reactEventName !== null) {
        var listener = getListener(instance, reactEventName);

        if (listener != null) {
          listeners.push(createDispatchListener(instance, listener, lastHostComponent));
        }
      }
    }
    
    if (accumulateTargetOnly) {
      break;
    }

    instance = instance.return;
  }

  return listeners;
} 

这个方法主体就是一个循环,从事件源fiber开始向上查找,如果遇到了HostComponent,收集对应fiber对象的事件监听函数,放入listeners数组。注意,这个方法只会收集捕获和冒泡阶段两者中一个阶段的事件处理函数。捕获阶段触发dispatchEvent之后收集捕获阶段的事件函数,冒泡阶段触发dispatchEvent收集冒泡阶段额事件函数。

事件函数收集完毕后,开始模拟触发,调用processDispatchQueue方法,该方法会调用processDispatchQueueItemsInOrder来循环listeners数组。

function processDispatchQueueItemsInOrder(event, dispatchListeners, inCapturePhase) {
  var previousInstance;

  if (inCapturePhase) {
    for (var i = dispatchListeners.length - 1; i >= 0; i--) {
      var _dispatchListeners$i = dispatchListeners[i],
          instance = _dispatchListeners$i.instance,
          currentTarget = _dispatchListeners$i.currentTarget,
          listener = _dispatchListeners$i.listener;

      if (instance !== previousInstance && event.isPropagationStopped()) {
        return;
      }

      executeDispatch(event, listener, currentTarget);
      previousInstance = instance;
    }
  } else {
    for (var _i = 0; _i < dispatchListeners.length; _i++) {
      var _dispatchListeners$_i = dispatchListeners[_i],
          _instance = _dispatchListeners$_i.instance,
          _currentTarget = _dispatchListeners$_i.currentTarget,
          _listener = _dispatchListeners$_i.listener;

      if (_instance !== previousInstance && event.isPropagationStopped()) {
        return;
      }

      executeDispatch(event, _listener, _currentTarget);
      previousInstance = _instance;
    }
  }
}

这个方法也是一个循环。注意我们的listeners数组中函数的存放顺序,是从事件源对象到顶层fiber,这和冒泡阶段的顺序是对应的。因此,如果当前阶段是捕获阶段,那么反向遍历该数组即可,如果是冒泡阶段,就正向遍历。

executeDispatch方法就会执行执行事件回调函数。

这里再次提醒,对于模拟派发的流程,会在事件捕获阶段执行一次,在冒泡阶段再执行一次。

举个🌰

回到文章开篇的例子,点击inner,输出结果是什么呢?

class App extends React.Component {
  componentDidMount() {
    outer.addEventListener('click', function() {
      console.log('native event click outer')
    })
    
    inner.addEventListener('click', function() {
      console.log('native event click inner')
    })
  }
  
  handleClickInner = () => {
    console.log('react event click inner')
  }
  
  handleClickOuter = () => {
    console.log('react event click outer')
  }
  
  render() {
    return (
    	<div className='outer' onClick={this.handleClickInner}>
      	<div className='inner' onClick={this.handleClickOuter}></div>
      </div>
    )
  }
}

一步步分析一下:初次渲染时,react事件和原生事件都被注册。当点击inner时,首先进入事件捕获阶段,触发root元素的click事件,触发捕获阶段的dispatchEvent,但是发现没有绑定捕获阶段的监听函数,因此直接跳过。

接下来进入目标阶段,触发inner的原生click事件,输出native event click inner

之后进入冒泡阶段,来到outer,触发outer的原生事件,输出native event click outer。之后冒泡到root元素,开始收集事件触发路径,收集到的listeners数组为[handleClickInner, handleClickOuter]。因为时冒泡阶段,因此正序遍历数组,输出为react event click innerreact event click outer

因此答案是:native event click inner -> native event click outer -> react event click inner -> react event click outer

总结

这篇文章讲解了react事件机制的两个主体流程,顶层注册和模拟派发。相信大家也能够理解模拟两个字的含义了。文章中有一个函数batchedEventUpdates没有讲到,这涉及到了状态更新的内容,之后会有一篇文章专门将事件触发和状态更新结合。