React 合成事件详解

4,747 阅读6分钟

合成事件:

  1. 注册
  2. 触发
  3. 合成事件和原生事件的执行顺序

注册

React 17 将事件委托放在了 root 上而不是以前的 document 上。这个是一个垫脚石的功能,这样做有一个原因是:当同个项目里,有多个 React 根节点时(也可能是React多版本共存),避免可能的一些操作(如阻止冒泡)会影响到其他 React 节点的正常工作。

ReactDOM.render 时,将会在创建根节点 Fiber 时(createRootImpl),对所有可监听的事件进行注册(listenToAllSupportedEvents)。

触发

我们在 React 元素上设置事件时,如:

<div onClick={() => setB(1234)}>{b}</div>

当点击该元素时,且处于冒泡模式时:

元素会冒泡到根节点,根节点获取事件源后,会找到对应的 Fiber,然后遍历获取节点的父节点去收集事件直到根节点 ,然后按照顺序地执行。

当处于捕获模式时:

根节点会捕获到事件源,然后会找到对应的 Fiber,然后遍历获取节点的父节点去收集事件直到根节点 ,然后按照顺序地执行。

获取节点的父节点去收集该父节点事件的流程大概如下:

dispatchDiscreteEvent -> 
discreteUpdates -> 
discreteUpdatesImpl -> 
dispatchEvent -> 
attemptToDispatchEvent -> 
dispatchEventForPluginEventSystem -> 
batchedEventUpdates -> 
batchedEventUpdatesImpl -> 
dispatchEventsForPlugins -> 
extractEvents$5 
(
extractEvents$4 -> accumulateSinglePhaseListeners ==> 	
dispatchQueue.push({
    event: _event,
    listeners: _listeners
  })
) ->
processDispatchQueue 
(
processDispatchQueueItemsInOrder -> executeDispatch -> invokeGuardedCallbackAndCatchFirstError -> invokeGuardedCallback -> invokeGuardedCallbackProd -> func.apply(context, funcArgs)
)

流程很长,但最主要的两个方法就是 extractEvents$4processDispatchQueue ,前者是收集该节点及该节点到根节点之间的事件,将它们保存在 dispatchQueue 里,然后调用 processDispatchQueue 来依次执行里面的方法。

合成事件和原生事件的执行顺序

这里有个点,就是合成事件和原生事件的执行顺序。React 17 版本以上 和 React 16.14.0 版本有些不一样(捕获模式)。看下代码:

// 代码改造于 https://segmentfault.com/a/1190000038251163
import {createRef, Component} from 'react';

class App extends Component {
  parentRef;
  childRef;
  constructor(props) {
    super(props);
    this.parentRef = createRef();
    this.childRef = createRef();
  }
  componentDidMount() {
    console.log("React componentDidMount!");
    this.parentRef.current?.addEventListener("click", () => {
      console.log("原生事件:父元素 DOM 事件监听!");
    });
    this.childRef.current?.addEventListener("click", () => {
      console.log("原生事件:子元素 DOM 事件监听!");
    });
    document.addEventListener("click", (e) => {
      console.log("原生事件:document DOM 事件监听!");
    });
  }
  parentClickFun = () => {
    console.log("React 事件:父元素事件监听!");
  };
  childClickFun = () => {
    console.log("React 事件:子元素事件监听!");
  };
  render() {
    return (
      <div ref={this.parentRef} onClick={this.parentClickFun}>
        <div ref={this.childRef} onClick={this.childClickFun}>
          分析事件执行顺序
        </div>
      </div>
    );
  }
}
export default App;

按照冒泡模式来说,原生事件(子 -> 父) > 合成事件(子 -> 父) > document 原生事件。因为合成事件需要冒泡到根节点后才进行处理,而原生事件可以即时执行。所以执行结果是:

// V17.0.2 和 V16.14.0 一致
// 原生事件:子元素 DOM 事件监听! 
// 原生事件:父元素 DOM 事件监听! 
// React 事件:子元素事件监听! 
// React 事件:父元素事件监听! 
// 原生事件:document DOM 事件监听!

那按照捕获模式来说呢?改造一下代码:

// 忽略无关代码
this.parentRef.current?.addEventListener("click", () => {
  console.log("原生事件:父元素 DOM 事件监听!");
}, true);
this.childRef.current?.addEventListener("click", () => {
  console.log("原生事件:子元素 DOM 事件监听!");
}, true);
document.addEventListener("click", (e) => {
  console.log("原生事件:document DOM 事件监听!");
}, true);

(
  <div ref={this.parentRef} onClickCapture={this.parentClickFun}>
    <div ref={this.childRef} onClickCapture={this.childClickFun}>
      分析事件执行顺序
    </div>
  </div>
);

那结果是怎样的呢?请看:

// V17.0.2
// 原生事件:document DOM 事件监听!
// React 事件:父元素事件监听!
// React 事件:子元素事件监听! 
// 原生事件:父元素 DOM 事件监听!
// 原生事件:子元素 DOM 事件监听! 
// V16.14.0
// 原生事件:document DOM 事件监听!
// 原生事件:父元素 DOM 事件监听!
// 原生事件:子元素 DOM 事件监听!
// React 事件:父元素事件监听!
// React 事件:子元素事件监听! 

根据结果来看,当处于捕获模式时,V17.0.2 版本 document 原生事件 > 合成事件(父 -> 子) > 原生事件(父 -> 子),而 V16.14.0 版本 document 原生事件 > 原生事件(父 -> 子)> 合成事件(父 -> 子)。这是一个细微的改动:

V17 之前,合成事件和原生事件的执行顺序与冒泡/捕获模式无关,原生事件恒早于合成事件;

V17 后,合成事件和原生事件的执行顺序与冒泡/捕获模式相关,冒泡模式,原生事件早于合成事件,捕获模式,合成事件早于原生事件。

为什么呢?

且看 V17 版本之前,部分关键代码如下:

// listenTo
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) {
          // 忽略无关代码
        default: 
          const isMediaEvent = mediaEventTypes.indexOf(dependency) !== -1
          if (!isMediaEvent) {
            // onClick 和 onClickCapture 都使用 trapBubbledEvent
            trapBubbledEvent(dependency, mountAt)
          }
          break;
      }
    }
  }
}

function trapBubbledEvent(topLevelType, element) {
  // 忽略无关代码
  addEventBubbleListener(element, getRawEventName(topLevelType)
}
                         
function addEventBubbleListener(element, eventType, listener) {
  element.addEventListener(eventType, listener, false);
}

已知 onClickCaptureonClick 都使用冒泡模式进行监听,那当使用 onClickCapture 时,怎么处理才符合捕获模式时的调用结果呢?

其实是通过 traverseTwoPhase 方法遍历模拟捕获和冒泡,实质是调用了 accumulateTwoPhaseDispatchesSingle 方法 ,该方法内通过 listenerAtPhase 方法里的 getListener 来获取到对应元素上设置的对应的事件函数,然后保存在 event._dispatchListeners 里:

/**
 * Simulates the traversal of a two-phase, capture/bubble event dispatch.
 */
function traverseTwoPhase(inst, fn, arg) {
  var path = [];
  while (inst) {
    path.push(inst);
    inst = getParent(inst);
  }
  var i = void 0;
  for (i = path.length; i-- > 0;) {
    fn(path[i], 'captured', arg);
  }
  for (i = 0; i < path.length; i++) {
    fn(path[i], 'bubbled', arg);
  }
}

function accumulateTwoPhaseDispatchesSingle(event) {
  if (event && event.dispatchConfig.phasedRegistrationNames) {
    traverseTwoPhase(event._targetInst, accumulateDirectionalDispatches, event);
  }
}

function accumulateDirectionalDispatches(inst, phase, event) {
  var listener = listenerAtPhase(inst, event, phase);
  if (listener) {
    event._dispatchListeners = accumulateInto(event._dispatchListeners, listener);
    event._dispatchInstances = accumulateInto(event._dispatchInstances, inst);
  }
}

function listenerAtPhase(inst, event, propagationPhase) {
  var registrationName = event.dispatchConfig.phasedRegistrationNames[propagationPhase];
  // registrationName 就是 eventType 如:onClick / onClickCapture
  return getListener(inst, registrationName);
}

// 获取对应元素上设置的 onClick / onClickCapture 方法
function getListener(inst, registrationName) {
  var listener = void 0;
  var stateNode = inst.stateNode;
  if (!stateNode) {
    return null;
  }
  var props = getFiberCurrentPropsFromNode(stateNode);
  if (!props) {
    return null;
  }
  listener = props[registrationName];
  return listener;
}

当执行 executeDispatchesInOrder 方法时,会遍历执行 event._dispatchListeners 里保存的事件函数。

/**
 * Standard/simple iteration through an event's collected dispatches.
 */
function executeDispatchesInOrder(event) {
  var dispatchListeners = event._dispatchListeners;
  var dispatchInstances = event._dispatchInstances;
  if (Array.isArray(dispatchListeners)) {
    for (var i = 0; i < dispatchListeners.length; i++) {
      if (event.isPropagationStopped()) {
        break;
      }
      executeDispatch(event, dispatchListeners[i], dispatchInstances[i]);
    }
  } else if (dispatchListeners) {
    executeDispatch(event, dispatchListeners, dispatchInstances);
  }
}

所以,V17 版本前的捕获模式,只是模拟的,当事件冒泡到 document 时,开始处理,通过遍历该元素以及元素父节点直到根节点,模拟捕获和冒泡处理方式,获取对应模式下的事件函数,然后调用它们。

所以,在 V17 版本前,原生事件的执行时机是恒早于合成事件的执行时机的。

// V16.14.0
// 原生事件:document DOM 事件监听!
// 原生事件:父元素 DOM 事件监听!
// 原生事件:子元素 DOM 事件监听!
// React 事件:父元素事件监听!
// React 事件:子元素事件监听! 

那 V17 版本后,发生了哪些变化呢?导致结果跟 V17 版本之前的不一样了呢?

// V17.0.2
// 原生事件:document DOM 事件监听!
// React 事件:父元素事件监听!
// React 事件:子元素事件监听! 
// 原生事件:父元素 DOM 事件监听!
// 原生事件:子元素 DOM 事件监听!

其实差别就在于,V17 版本之前,捕获仅是模拟,实质还是冒泡到 document 后再进行对应事件的处理,而 V17 后,捕获事件将会启用捕获模式的监听:

function addTrappedEventListener(targetContainer, domEventName, eventSystemFlags, isCapturePhaseListener, isDeferredListenerForLegacyFBSupport) {
  // 忽略无关代码
  if (isPassiveListener !== undefined) {
      unsubscribeListener = addEventCaptureListenerWithPassiveFlag(targetContainer, domEventName, listener, isPassiveListener);
    } else {
      unsubscribeListener = addEventCaptureListener(targetContainer, domEventName, listener);
    }
}

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

function addEventCaptureListenerWithPassiveFlag(target, eventType, listener, passive) {
  target.addEventListener(eventType, listener, {
    capture: true,
    passive: passive
  });
  return listener;
}

所以,V17 版本后,合成事件和原生事件的执行顺序与冒泡/捕获模式相关,冒泡模式,原生事件早于合成事件,捕获模式,合成事件早于原生事件。