React17 事件机制

发表于 1年以前  | 总阅读数:369 次

由于 React16 和 React17 在事件机制在细节上有较大改动,本文仅对 React17 的事件机制做讲解,在最后对比 React17 和 React16 在事件机制上的不同点。

前置知识

事件传播机制

一般的事件触发都会经历三个阶段:

  1. 捕获阶段,事件从 window 开始,自上而下一直传播到目标元素的阶段。
  2. 目标阶段,事件真正的触发元素处理事件的阶段。
  3. 冒泡阶段,从目标元素开始,自下而上一直传播到 window 的阶段。

如果想阻止事件的传播,可以在指定节点的事件监听器通过event.stopPropagation()event.cancelBubble = true阻止事件传播。

有些事件是没有冒泡阶段的,如 scroll、blur、及各种媒体事件等。

绑定事件的方法

  • 行内 HTML 事件绑定
<div onclick="handleClick()">
  test
</div>
<script>
  let handleClick = function(){
    // 一些处理代码..
  }
  // 移除事件
  handleClick = function(){}
</script>

缺点:js 和 html 代码耦合了。

  • 事件处理器属性(DOM0)
<div id="test">
  test
</div>
<script>
  let target = document.getElementById('test')
  // 绑定事件
  target.onclick = function(){
    // 一些处理代码..
  }
  target.onclick = function(){
    // 另外一些处理代码...会覆盖上面的
  }
  // 移除事件
  target.onclick = null
</script>

缺点:作为属性使用,一次只能绑定一个事件,多次赋值会覆盖,只能处理冒泡阶段

  • addEventListener(DOM2)
<div id="test">
  test
</div>
<script>
  let target = document.getElementById('test')
  // 绑定事件
  let funcA = function(){
    // 一些处理代码..
  }
  let funcB = function(){
    // 一些处理代码..
  }
  // 添加冒泡阶段监听器
  target.addEventListener('click',funcA,false)
  // 添加捕获阶段监听器
  target.addEventListener('click',funcB,true)
  // 移除监听器
  target.removeEventListener('click', funcA)
</script>

就是为了绑定事件而生的 api,拓展性最强,现在开发者一般都用 addEventListener 绑定事件监听器。

事件委托

当节点的数量较多时,如果给每个节点都进行事件绑定的话,内存消耗大,可将事件绑定到其父节点上统一处理,减少事件绑定的数量。


<ul id="parent">
  <li>1</li>
  <li>2</li>
  <li>3</li>
  ....
  <li>999</li>
  <li>1000</li>
</ul>
<script>
  let parent = document.getElementById('parent')
  parent.addEventListener('click',(e)=>{
    // 根据e.target进行处理
  })
</script>

浏览器事件差异

由于浏览器厂商的实现差异,在事件的属性及方法在不同浏览器及版本上略有不同,开发者为兼容各浏览器及版本之间的差异,需要编写兼容代码,要么重复编写模板代码,要么将磨平浏览器差异的方法提取出来。

// 阻止事件传播
function stopPropagation(e){
  if(typeof e.stopPropagation === 'function'){
    e.stopPropagation()
  }else{
    // 兼容ie
    e.cancelBubble = true
  }
}
// 阻止默认事件
function preventDefault(e){
  if(typeof e.preventDefault === 'function'){
    e.preventDefault()
  }else{
    // 兼容ie
    e.returnValue = false
  }
}
// 获取事件触发元素
function getEventTarget(e){
  let target = e.target || e.srcElement || window;
}
// 还有事件的各种属性如e.relatedTarget等等

为什么 React 实现了自己的事件机制

  • 将事件都代理到了根节点上,减少了事件监听器的创建,节省了内存。
  • 磨平浏览器差异,开发者无需兼容多种浏览器写法。如想阻止事件传播时需要编写event.stopPropagation()event.cancelBubble = true,在 React 中只需编写event.stopPropagation()即可。
  • 对开发者友好。只需在对应的节点上编写如onClickonClickCapture等代码即可完成click事件在该节点上冒泡节点、捕获阶段的监听,统一了写法。

实现细节

事件分类

React 对在 React 中使用的事件进行了分类,具体通过各个类型的事件处理插件分别处理:

  • SimpleEventPlugin简单事件,代表事件onClick
  • BeforeInputEventPlugin输入前事件,代表事件onBeforeInput
  • ChangeEventPlugin表单修改事件,代表事件onChange
  • EnterLeaveEnventPlugin鼠标进出事件,代表事件onMouseEnter
  • SelectEventPlugin选择事件,代表事件onSelect

这里的分类是对 React 事件进行分类的,简单事件如onClickonClickCapture,它们只依赖了原生事件click。而有些事件是由 React 统一包装给用户使用的,如onChange,它依赖了['change','click','focusin','focusout','input','keydown','keyup','selectionchange'],这是 React 为了兼容不同表单的修改事件收集,如对于<input type="checkbox" /><input type="radio" />开发者原生需要使用click事件收集表单变更后的值,而在 React 中可以统一使用onChange来收集。

分类并不代表依赖的原生事件之间没有交集。 如简单事件中有onKeyDown,它依赖于原生事件keydown。输入前事件有onCompositionStart,它也依赖了原生事件keydown。表单修改事件onChange,它也依赖了原生事件keydown

事件收集

由于 React 需要对所有的事件做代理委托,所以需要事先知道浏览器支持的所有事件,这些事件都是硬编码在 React 源码的各个事件插件中的。

而对于所有需要代理的原生事件,都会以原生事件名字符串的形式存储在一个名为allNativeEvents的集合中,并且在registrationNameDependencies中存储 React 事件名到其依赖的原生事件名数组的映射。

而事件的收集是通过各个事件处理插件各自收集注册的,在页面加载时,会执行各个插件的registerEvents,将所有依赖的原生事件都注册到allNativeEvents中去,并且在registrationNameDependencies中存储映射关系。

对于原生事件不支持冒泡阶段的事件,硬编码的形式存储在了nonDelegatedEvents集合中,原生不支持冒泡阶段的事件在后续的事件代理环节有不一样的处理方式。

后面的描述中,对于 nonDelegatedEvents,称为非代理事件。其他的事件称为代理事件。他们的区别在于原生事件是否支持冒泡。

// React代码加载时就会执行以下js代码
SimpleEventPlugin.registerEvents();
EnterLeaveEventPlugin.registerEvents();
ChangeEventPlugin.registerEvents();
SelectEventPlugin.registerEvents();
BeforeInputEventPlugin.registerEvents();

// 上述代码执行完后allNativeEvents集合中就会有cancel、click等80种事件
allNativeEvents = ['cancel','click', ...]

// nonDelegatedEvents有cancel、close等29种事件
nonDelegatedEvents = ['cancel','close',...]

// registrationNameDependencies保存react事件和其依赖的事件的映射
registrationNameDependencies = {
  onClick: ['click'],
  onClickCapture: ['click'],
  onChange: ['change','click','focusin','focusout','input','keydown','keyup','selectionchange'],
  ...
}

事件代理

可代理事件

将事件委托代理到根的操作发生在ReactDOM.render(element, container)时。

ReactDOM.render的实现中,在创建了fiberRoot后,在开始构造fiber树前,会调用listenToAllSupportedEvents进行事件的绑定委托。

const listeningMarker =
  '_reactListening' +
  Math.random()
    .toString(36)
    .slice(2);
export function listenToAllSupportedEvents(rootContainerElement: EventTarget) {
  if (enableEagerRootListeners) {
    if ((rootContainerElement: any)[listeningMarker]) {
      // 避免重复初始化
      return;
    }
    // 将该根元素标记为已初始化事件监听
    (rootContainerElement: any)[listeningMarker] = true;
    allNativeEvents.forEach(domEventName => {
      if (!nonDelegatedEvents.has(domEventName)) {
        listenToNativeEvent(
          domEventName,
          false,
          ((rootContainerElement: any): Element),
          null,
        );
      }
      listenToNativeEvent(
        domEventName,
        true,
        ((rootContainerElement: any): Element),
        null,
      );
    });
  }
}

可以看到,首先会判断根上的事件监听器相关的字段是否已标记完成过监听,如果没有完成,则将根标记为已监听过,并遍历allNativeEvents进行事件的委托绑定。是否完成监听的判断是避免多次调用ReactDOM.render(element, container)是对同一个container重复委托事件。

listenToNativeEvent即对元素进行事件绑定的方法,第二个参数的含义是是否将监听器绑定在捕获阶段。 由此我们可以看到,对于不存在冒泡阶段的事件,React 只委托了捕获阶段的监听器,而对于其他的事件,则对于捕获阶段和冒泡阶段都委托了监听器。

listenToNativeEvent的内部会将绑定了入参的dispatchEvent使用addEventListener绑定到根元素上。

export function dispatchEvent(
  domEventName: DOMEventName, // 原生事件名
  eventSystemFlags: EventSystemFlags, // 事件标记,如是否捕获阶段
  targetContainer: EventTarget, // 绑定事件的根
  nativeEvent: AnyNativeEvent, // 实际触发时传入的真实事件对象
): void {
    //... 前三个参数在绑定到根上时已传入
}
// 提前绑定入参
const listener = dispatchEvent.bind(
  null,
  targetContainer,
  domEventName,
  eventSystemFlags,
)
if(isCapturePhaseListener){
    addEventCaptureListener(targetContainer,domEventName,listener)
}else{
    addEventBubbleListener(targetContainer,domEventName,listener)
}

// 添加冒泡事件监听器
export function addEventBubbleListener(
  target: EventTarget,
  eventType: string,
  listener: Function,
): Function {
  target.addEventListener(eventType, listener, false);
  return listener;
}
// 添加捕获事件监听器
export function addEventCaptureListener(
  target: EventTarget,
  eventType: string,
  listener: Function,
): Function {
  target.addEventListener(eventType, listener, true);
  return listener;
}

图示:代理事件在根元素上绑定了捕获和冒泡阶段的回调

图示:非代理事件在根元素上只绑定了捕获阶段的回调

非代理事件

对于非代理事件nonDelegatedEvents,由于这些事件不存在冒泡阶段,所以我们在根部代理他们的冒泡阶段监听器也不会触发,所以需要特殊处理。

实际上这些事件的代理发生在 DOM 实例的创建阶段,也就是render阶段的completeWork阶段。通过调用finalizeInitialChildren为 DOM 实例设置属性时,判断 DOM 节点类型来添加响应的冒泡阶段监听器。 如为<img /><link />标签对应的 DOM 实例添加errorload的监听器。

export function setInitialProperties(
  domElement: Element,
  tag: string,
  rawProps: Object,
  rootContainerElement: Element | Document,
):void {
  // ...
  switch (tag) {
    // ...
    case 'img':
    case 'image':
    case 'link':
        listenToNonDelegatedEvent('error', domElement);
        listenToNonDelegatedEvent('load', domElement);
        break;
    // ...
  }
  // ...
}
// 非代理事件监听器绑定
export function listenToNonDelegatedEvent(
  domEventName: DOMEventName,
  targetElement: Element,
): void {
  // 绑定在目标/冒泡阶段
  const isCapturePhaseListener = false;
  const listenerSet = getEventListenerSet(targetElement);
  const listenerSetKey = getListenerSetKey(
    domEventName,
    isCapturePhaseListener,
  );
  if (!listenerSet.has(listenerSetKey)) {
    addTrappedEventListener(
      targetElement,
      domEventName,
      IS_NON_DELEGATED,// 非代理事件
      isCapturePhaseListener,// 目标/冒泡阶段
    );
    listenerSet.add(listenerSetKey);
  }
}

图示:img元素上绑定了非代理事件errorload冒泡阶段回调

实际上 React 对这些不可冒泡的事件都进行了冒泡模拟。

但在 React17 中去掉了 scroll 事件的冒泡模拟。

合成事件

合成事件SyntheticEvent是 React 事件系统对于原生事件跨浏览器包装器。它除了兼容所有浏览器外,它还拥有和浏览器原生事件相同的接口,包括 stopPropagation()preventDefault()

如果因为某些原因,当你需要使用浏览器的底层事件时,只需要使用 nativeEvent 属性来获取即可。

合成事件的使用

  • React 事件的命名采用小驼峰式(camelCase),而不是纯小写。以 click 事件为例,冒泡阶段用onClick,捕获阶段用onClickCapture
  • 使用 JSX 语法时你需要传入一个函数作为事件处理函数,而不是一个字符串。
// 传统html绑定事件
<button onclick="activateLasers()">
  test
</button>

// 在React中绑定事件
<button onClick={activateLasers}>
  test
</button>

在 React 事件中不同通过返回 false 阻止默认行为,必须显示调用event.preventDefault()

由于 React 事件执行回调时的上下文并不在组件内部,所以还需要注意 this 的指向问题

磨平浏览器差异

React 通过事件normalize以让他们在不同浏览器中拥有一致的属性。

React 声明了各种事件的接口,以此来磨平浏览器中的差异:

  • 如果接口中的字段值为 0,则直接使用原生事件的值。
  • 如果接口中字段的值为函数,则会以原生事件作为入参,调用该函数来返回磨平了浏览器差异的值。
// 基础事件接口,timeStamp需要磨平差异
const EventInterface = {
  eventPhase: 0,
  bubbles: 0,
  cancelable: 0,
  timeStamp: function(event) {
    return event.timeStamp || Date.now();
  },
  defaultPrevented: 0,
  isTrusted: 0,
};
// UI事件接口,继承基础事件接口
const UIEventInterface: EventInterfaceType = {
  ...EventInterface,
  view: 0,
  detail: 0,
};
// 鼠标事件接口,继承UI事件接口,getModifierState,relatedTarget、movementX、movementY等字段需要磨平差异
const MouseEventInterface: EventInterfaceType = {
  ...UIEventInterface,
  screenX: 0,
  screenY: 0,
  clientX: 0,
  clientY: 0,
  pageX: 0,
  pageY: 0,
  ctrlKey: 0,
  shiftKey: 0,
  altKey: 0,
  metaKey: 0,
  getModifierState: getEventModifierState,
  button: 0,
  buttons: 0,
  relatedTarget: function(event) {
    if (event.relatedTarget === undefined)
      return event.fromElement === event.srcElement
        ? event.toElement
        : event.fromElement;

    return event.relatedTarget;
  },
  movementX: function(event) {
    if ('movementX' in event) {
      return event.movementX;
    }
    updateMouseMovementPolyfillState(event);
    return lastMovementX;
  },
  movementY: function(event) {
    if ('movementY' in event) {
      return event.movementY;
    }
    // Don't need to call updateMouseMovementPolyfillState() here
    // because it's guaranteed to have already run when movementX
    // was copied.
    return lastMovementY;
  },
};
// 指针类型,继承鼠标事件接口。还有很多其他事件类型接口。。。。。。
const PointerEventInterface = {
  ...MouseEventInterface,
  pointerId: 0,
  width: 0,
  height: 0,
  pressure: 0,
  tangentialPressure: 0,
  tiltX: 0,
  tiltY: 0,
  twist: 0,
  pointerType: 0,
  isPrimary: 0,
};

由于不同的类型的事件其字段有所不同,所以 React 实现了针对事件接口的合成事件构造函数的工厂函数。 通过传入不一样的事件接口返回对应事件的合成事件构造函数,然后在事件触发回调时根据触发的事件类型判断使用哪种类型的合成事件构造函数来实例化合成事件。

// 辅助函数,永远返回true
function functionThatReturnsTrue() {
  return true;
}
// 辅助函数,永远返回false
function functionThatReturnsFalse() {
  return false;
}
// 合成事件构造函数的工厂函数,根据传入的事件接口返回对应的合成事件构造函数
function createSyntheticEvent(Interface: EventInterfaceType) {

  // 合成事件构造函数
  function SyntheticBaseEvent(
    reactName: string | null,
    reactEventType: string,
    targetInst: Fiber,
    nativeEvent: {[propName: string]: mixed},
    nativeEventTarget: null | EventTarget,
  ) {
    // react事件名
    this._reactName = reactName;
    // 当前执行事件回调时的fiber
    this._targetInst = targetInst;
    // 真实事件名
    this.type = reactEventType;
    // 原生事件对象
    this.nativeEvent = nativeEvent;
    // 原生触发事件的DOM target
    this.target = nativeEventTarget;
    // 当前执行回调的DOM
    this.currentTarget = null;

    // 下面是磨平字段在浏览器间的差异
    for (const propName in Interface) {
      if (!Interface.hasOwnProperty(propName)) {
        // 该接口没有这个字段,不拷贝
        continue;
      }
      // 拿到事件接口对应的值
      const normalize = Interface[propName];
      // 如果接口对应字段函数,进入if分支,执行函数拿到值
      if (normalize) {
        // 获取磨平了浏览器差异后的值
        this[propName] = normalize(nativeEvent);
      } else {
        // 如果接口对应值是0,则直接取原生事件对应字段值
        this[propName] = nativeEvent[propName];
      }
    }
    // 磨平defaultPrevented的浏览器差异,即磨平e.defaultPrevented和e.returnValue的表现
    const defaultPrevented =
      nativeEvent.defaultPrevented != null
        ? nativeEvent.defaultPrevented
        : nativeEvent.returnValue === false;
    if (defaultPrevented) {
      // 如果在处理事件时已经被阻止默认操作了,则调用isDefaultPrevented一直返回true
      this.isDefaultPrevented = functionThatReturnsTrue;
    } else {
      // 如果在处理事件时没有被阻止过默认操作,则先用返回false的函数
      this.isDefaultPrevented = functionThatReturnsFalse;
    }
    // 默认执行时间时,还没有被阻止继续传播,所以调用isPropagationStopped返回false
    this.isPropagationStopped = functionThatReturnsFalse;
    return this;
  }
  // 合成事件重要方法的包装
  Object.assign(SyntheticBaseEvent.prototype, {
    preventDefault: function() {
      // 调用后设置defaultPrevented
      this.defaultPrevented = true;
      const event = this.nativeEvent;
      if (!event) {
        return;
      }
      // 下面是磨平e.preventDefault()和e.returnValue=false的浏览器差异,并在原生事件上执行
      if (event.preventDefault) {
        event.preventDefault();
        // $FlowFixMe - flow is not aware of `unknown` in IE
      } else if (typeof event.returnValue !== 'unknown') {
        event.returnValue = false;
      }
      // 然后后续回调判断时都会返回true
      this.isDefaultPrevented = functionThatReturnsTrue;
    },

    stopPropagation: function() {
      const event = this.nativeEvent;
      if (!event) {
        return;
      }
      // 磨平e.stopPropagation()和e.calcelBubble = true的差异,并在原生事件上执行
      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;
      }
      // 然后后续判断时都会返回true,已停止传播
      this.isPropagationStopped = functionThatReturnsTrue;
    },
    /**
     * We release all dispatched `SyntheticEvent`s after each event loop, adding
     * them back into the pool. This allows a way to hold onto a reference that
     * won't be added back into the pool.
     */
    // react16的保留原生事件的方法,react17里已无效
    persist: function() {
      // Modern event system doesn't use pooling.
    },

    /**
     * Checks if this event should be released back into the pool.
     *
     * @return {boolean} True if this should not be released, false otherwise.
     */
    isPersistent: functionThatReturnsTrue,
  });
  // 返回根据接口类型包装的合成事件构造器
  return SyntheticBaseEvent;
}
// 使用通过给工厂函数传入鼠标事件接口获取鼠标事件合成事件构造函数
export const SyntheticMouseEvent = createSyntheticEvent(MouseEventInterface);

可以看到,合成事件的实例,其实就是根据事件类型对原生事件的属性做浏览器的磨平,以及关键方法的包装。

事件触发

当页面上触发了特定的事件时,如点击事件 click,就会触发绑定在根元素上的事件回调函数,也就是之前绑定了参数的dispatchEvent,而dispatchEvent在内部最终会调用dispatchEventsForPlugins,看一下dispatchEventsForPlugins具体做了哪些事情。

function dispatchEventsForPlugins(
  domEventName: DOMEventName, // dispatchEvent中绑定的事件名
  eventSystemFlags: EventSystemFlags, // dispatchEvent绑定的事件标记
  nativeEvent: AnyNativeEvent, // 事件触发时回调传入的原生事件对象
  targetInst: null | Fiber, // 事件触发目标元素对应的fiber
  targetContainer: EventTarget, // 绑定事件的根元素
): void {
  // 磨平浏览器差异,拿到真正的target
  const nativeEventTarget = getEventTarget(nativeEvent);
  // 要处理事件回调的队列
  const dispatchQueue: DispatchQueue = [];
  // 将fiber树上的回调收集
  extractEvents(
    dispatchQueue,
    domEventName,
    targetInst,
    nativeEvent,
    nativeEventTarget,
    eventSystemFlags,
    targetContainer,
  );
  // 根据收集到的回调及事件标记处理事件
  processDispatchQueue(dispatchQueue, eventSystemFlags);
}

重点在extractEventsprocessDispatchQueue两个方法,分别进行了事件对应回调的收集及处理回调。

收集回调

function extractEvents(
  dispatchQueue: DispatchQueue,
  domEventName: DOMEventName,
  targetInst: null | Fiber,
  nativeEvent: AnyNativeEvent,
  nativeEventTarget: null | EventTarget,
  eventSystemFlags: EventSystemFlags,
  targetContainer: EventTarget,
) {
  // 抽出简单事件
  SimpleEventPlugin.extractEvents(
    dispatchQueue,
    domEventName,
    targetInst,
    nativeEvent,
    nativeEventTarget,
    eventSystemFlags,
    targetContainer,
  );
  const shouldProcessPolyfillPlugins =
    (eventSystemFlags & SHOULD_NOT_PROCESS_POLYFILL_EVENT_PLUGINS) === 0;
  if (shouldProcessPolyfillPlugins) {
    EnterLeaveEventPlugin.extractEvents(
      dispatchQueue,
      domEventName,
      targetInst,
      nativeEvent,
      nativeEventTarget,
      eventSystemFlags,
      targetContainer,
    );
    ChangeEventPlugin.extractEvents(
      dispatchQueue,
      domEventName,
      targetInst,
      nativeEvent,
      nativeEventTarget,
      eventSystemFlags,
      targetContainer,
    );
    SelectEventPlugin.extractEvents(
      dispatchQueue,
      domEventName,
      targetInst,
      nativeEvent,
      nativeEventTarget,
      eventSystemFlags,
      targetContainer,
    );
    BeforeInputEventPlugin.extractEvents(
      dispatchQueue,
      domEventName,
      targetInst,
      nativeEvent,
      nativeEventTarget,
      eventSystemFlags,
      targetContainer,
    );
  }
}

我们可以发现回调的收集也是根据事件的类型分别处理的,将extractEvents的入参分别给各个事件处理插件的extractEvents进行分别处理。

SimpleEventPlugin.extractEvents为例看看如何进行收集:

// SimpleEventPlugin.js
function extractEvents(
  dispatchQueue: DispatchQueue,
  domEventName: DOMEventName,
  targetInst: null | Fiber,
  nativeEvent: AnyNativeEvent,
  nativeEventTarget: null | EventTarget,
  eventSystemFlags: EventSystemFlags,
  targetContainer: EventTarget,
): void {
  // 根据原生事件名拿到React事件名
  const reactName = topLevelEventsToReactNames.get(domEventName);
  if (reactName === undefined) {
    // 如果是没对应的React事件就不处理
    return;
  }
  // 默认的合成事件构造函数,下面根据事件名重新赋值对应的合成事件构造函数
  let SyntheticEventCtor = SyntheticEvent;
  let reactEventType: string = domEventName;
  // 根据事件名获取对应的合成事件构造函数
  switch (domEventName) {
    case 'keypress':
    case 'keydown':
    case 'keyup':
      SyntheticEventCtor = SyntheticKeyboardEvent;
      break;
    case 'focusin':
      reactEventType = 'focus';
      SyntheticEventCtor = SyntheticFocusEvent;
      break;
    case 'focusout':
      reactEventType = 'blur';
      SyntheticEventCtor = SyntheticFocusEvent;
      break;
    case 'beforeblur':
    case 'afterblur':
      SyntheticEventCtor = SyntheticFocusEvent;
      break;
    case 'click':
      // Firefox creates a click event on right mouse clicks. This removes the
      // unwanted click events.
      if (nativeEvent.button === 2) {
        return;
      }
    /* falls through */
    case 'auxclick':
    case 'dblclick':
    case 'mousedown':
    case 'mousemove':
    case 'mouseup':
    // TODO: Disabled elements should not respond to mouse events
    /* falls through */
    case 'mouseout':
    case 'mouseover':
    case 'contextmenu':
      SyntheticEventCtor = SyntheticMouseEvent;
      break;
    // ...这里省略了很多case
    default:
      // Unknown event. This is used by createEventHandle.
      break;
  }
  // 判断是捕获阶段还是冒泡阶段
  const inCapturePhase = (eventSystemFlags & IS_CAPTURE_PHASE) !== 0;
  if (
    enableCreateEventHandleAPI &&
    eventSystemFlags & IS_EVENT_HANDLE_NON_MANAGED_NODE
  ) {
    // 这个分支不看
  } else {
    // Some events don't bubble in the browser.
    // In the past, React has always bubbled them, but this can be surprising.
    // We're going to try aligning closer to the browser behavior by not bubbling
    // them in React either. We'll start by not bubbling onScroll, and then expand.
    // 如果不是捕获阶段且事件名为scroll,则只处理触发事件的节点
    const accumulateTargetOnly =
      !inCapturePhase &&
      // TODO: ideally, we'd eventually add all events from
      // nonDelegatedEvents list in DOMPluginEventSystem.
      // Then we can remove this special list.
      // This is a breaking change that can wait until React 18.
      domEventName === 'scroll';
    // 在fiber树上收集事件名对应的props
    const listeners = accumulateSinglePhaseListeners(
      targetInst,
      reactName,
      nativeEvent.type,
      inCapturePhase,
      accumulateTargetOnly,
    );
    // 如果存在监听该事件props回调函数
    if (listeners.length > 0) {
      // Intentionally create event lazily.
      // 则构建一个react合成事件
      const event = new SyntheticEventCtor(
        reactName,
        reactEventType,
        null,
        nativeEvent,
        nativeEventTarget,
      );
      // 并收集到队列中
      dispatchQueue.push({event, listeners});
    }
  }
}
// 遍历fiber树的收集函数
export function accumulateSinglePhaseListeners(
  targetFiber: Fiber | null,
  reactName: string | null,
  nativeEventType: string,
  inCapturePhase: boolean,
  accumulateTargetOnly: boolean,
): Array<DispatchListener> {
  const captureName = reactName !== null ? reactName + 'Capture' : null;
  const reactEventName = inCapturePhase ? captureName : reactName;
  const listeners: Array<DispatchListener> = [];

  let instance = targetFiber;
  let lastHostComponent = null;

  // Accumulate all instances and listeners via the target -> root path.
  while (instance !== null) {
    const {stateNode, tag} = instance;
    // Handle listeners that are on HostComponents (i.e. <div>)
    if (tag === HostComponent && stateNode !== null) {
      lastHostComponent = stateNode;
      // Standard React on* listeners, i.e. onClick or onClickCapture
      if (reactEventName !== null) {
        // 拿到DOM节点类型上对应事件名的props
        const listener = getListener(instance, reactEventName);
        if (listener != null) {
          // 如果这个同名props存在,则收集起来
          listeners.push(
            createDispatchListener(instance, listener, lastHostComponent),
          );
        }
      }
    }
    // If we are only accumulating events for the target, then we don't
    // continue to propagate through the React fiber tree to find other
    // listeners.
    // 对于只收集当前节点的事件,收集完当前节点就退出了
    if (accumulateTargetOnly) {
      break;
    }
    // 向上遍历
    instance = instance.return;
  }
  // 返回该事件名对应收集的监听器
  return listeners;
}

图示:

可以看到SimpleEventPlugin.extractEvents的主要处理逻辑:

  1. 根据原生事件名,得到对应的 React 事件名。
  2. 根据原生事件名,判断需要使用的合成事件构造函数。
  3. 根据绑定的事件标记得出事件是否捕获阶段。
  4. 判断事件名是否为 scoll 且不是捕获阶段,如果是则只收集事件触发节点。
  5. 从触发事件的 DOM 实例对应的 fiber 节点开始,向上遍历 fiber 树,判断遍历到的 fiber 是否宿主类型 fiber 节点,是的话判断在其 props 上是否存在 React 事件名同名属性,如果存在,则 push 到数组中,遍历结束即可收集由叶子节点到根节点的回调函数。
  6. 如果收集的回调数组不为空,则实例化对应的合成事件,并与收集的回调函数一同收集到dispatchQueue中。

处理回调

// 分别处理事件队列
export function processDispatchQueue(
  dispatchQueue: DispatchQueue,
  eventSystemFlags: EventSystemFlags,
): void {
  const inCapturePhase = (eventSystemFlags & IS_CAPTURE_PHASE) !== 0;
  for (let i = 0; i < dispatchQueue.length; i++) {
    const {event, listeners} = dispatchQueue[i];
    processDispatchQueueItemsInOrder(event, listeners, inCapturePhase);
  }
}
// 根据事件是捕获阶段还是冒泡阶段,来决定是顺序执行还是倒序执行
// 并且如果事件被调用过event.stopPropagation则退出执行
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;
    }
  }
}

// 执行事件回调
function executeDispatch(
  event: ReactSyntheticEvent,
  listener: Function,
  currentTarget: EventTarget,
): void {
  const type = event.type || 'unknown-event';
  // 设置合成事件执行到当前DOM实例时的指向
  event.currentTarget = currentTarget;
  invokeGuardedCallbackAndCatchFirstError(type, listener, undefined, event);
  // 不在事件的回调中时拿不到currentTarget
  event.currentTarget = null;
}

可以看到对于回调的处理,就是简单地根据收集到的回调数组,判断事件的触发是处于捕获阶段还是冒泡阶段来决定是顺序执行还是倒序执行回调数组。并且通过event.isPropagationStopped()来判断事件是否执行过event.stopPropagation()以决定是否继续执行。

React17 与 React16 事件系统的差别

绑定位置

事件委托的节点从 React16 的 document 更改为 React17 的 React 树的根 DOM 容器。

这一改动的出发点是如果页面中存在多个 React 应用,由于他们都会在顶层document注册事件处理器,如果你在一个 React 子应用的 React 事件中调用了e.stopPropagation(),无法阻止事件冒泡到外部树,因为真实的事件早已传播到document

而将事件委托在 React 应用的根 DOM 容器则可以避免这样的问题,减少了多个 React 应用并存可能产生的问题,并且事件系统的运行也更贴近现在浏览器的表现。

事件代理阶段

在 React16 中,对 document 的事件委托都委托在冒泡阶段,当事件冒泡到 document 之后触发绑定的回调函数,在回调函数中重新模拟一次 捕获-冒泡 的行为,所以 React 事件中的e.stopPropagation()无法阻止原生事件的捕获和冒泡,因为原生事件的捕获和冒泡已经执行完了。

在 React17 中,对 React 应用根 DOM 容器的事件委托分别在捕获阶段和冒泡阶段。即:

  • 当根容器接收到捕获事件时,先触发一次 React 事件的捕获阶段,然后再执行原生事件的捕获传播。所以 React 事件的捕获阶段调用e.stopPropagation()阻止原生事件的传播。
  • 当根容器接受到冒泡事件时,会触发一次 React 事件的冒泡阶段,此时原生事件的冒泡传播已经传播到根了,所以 React 事件的冒泡阶段调用e.stopPropagation()不能阻止原生事件向根容器的传播,但是能阻止根容器到页面顶层的传播。

可以根据下面的 demo 感受 React16 和 React17 事件在时序细节上的不同:codesandbox demo(https://codesandbox.io/s/react17shi-jian-chuan-bo-mc2wdp?file=/src/index.tsx),可以通过切换 Dependencies 中 react 和 react-dom 的版本。

import { useEffect } from "react";
import ReactDOM from "react-dom";

// 应用挂载前的原生事件绑定
document.addEventListener("click", () => {
  console.log("原生document冒泡挂载前");
});
document.addEventListener(
  "click",
  () => {
    console.log("原生document捕获挂载前");
  },
  true
);
document.querySelector("#root")!.addEventListener("click", () => {
  console.log("原生root冒泡挂载前");
});
document.querySelector("#root")!.addEventListener(
  "click",
  () => {
    console.log("原生root捕获挂载前");
  },
  true
);

function App() {
  // 应用挂载后的原生事件绑定
  useEffect(() => {
    const root = document.querySelector("#root")!;
    const parent = document.querySelector("#parent")!;
    const child = document.querySelector("#child")!;
    document.addEventListener("click", () => {
      console.log("原生document冒泡挂载后");
    });
    document.addEventListener(
      "click",
      () => {
        console.log("原生document捕获挂载后");
      },
      true
    );
    root.addEventListener("click", () => {
      console.log("原生root冒泡挂载后");
    });
    root.addEventListener(
      "click",
      () => {
        console.log("原生root捕获挂载后");
      },
      true
    );
    parent.addEventListener("click", () => {
      console.log("原生parent冒泡");
    });
    parent.addEventListener(
      "click",
      (e) => {
        console.log("原生parent捕获");
        // 注释1
        // e.stopPropagation();
      },
      true
    );
    child.addEventListener("click", () => {
      console.log("原生child冒泡");
    });
    child.addEventListener(
      "click",
      () => {
        console.log("原生child捕获");
      },
      true
    );
  });
  return (
    <div
      id="parent"
      onClick={() => {
        console.log("react parent冒泡");
      }}
      onClickCapture={(e) => {
        console.log("react parent捕获");
        // 注释2
        // e.stopPropagation()
      }}
    >
      <h1
        id="child"
        onClick={(e) => {
          console.log("react child冒泡");
          // 注释3
          // e.stopPropagation()
        }}
        onClickCapture={() => {
          console.log("react child捕获");
        }}
      >
        React event propagation
      </h1>
    </div>
  );
}

ReactDOM.render(<App />, document.getElementById("root"));

// 当点击id为child的div时
// ------------下面是react:17.0.2,react-dom:17.0.2的表现------------------
// 当所有e.stopPropagation()注释都不打开时
// 控制台打印如下:
// 原生document捕获挂载前
// 原生document捕获挂载后
// 原生root捕获挂载前
// react parent捕获
// react child捕获
// 原生root捕获挂载后
// 原生parent捕获
// 原生child捕获
// 原生child冒泡
// 原生parent冒泡
// 原生root冒泡挂载前
// react child冒泡
// react parent冒泡
// 原生root冒泡挂载后
// 原生document冒泡挂载前
// 原生document冒泡挂载后

// 当只打开注释1的e.stopPropagation()
// 控制台打印如下:
// 原生document捕获挂载前
// 原生document捕获挂载后
// 原生root捕获挂载前
// react parent捕获
// react child捕获
// 原生root捕获挂载后
// 原生parent捕获

// 当只打开注释2的e.stopPropagation()
// 控制台打印如下:
// 原生document捕获挂载前
// 原生document捕获挂载后
// 原生root捕获挂载前
// react parent捕获
// 原生root捕获挂载后

// 当只打开注释3的e.stopPropagation()
// 控制台打印如下:
// 原生document捕获挂载前
// 原生document捕获挂载后
// 原生root捕获挂载前
// react parent捕获
// react child捕获
// 原生root捕获挂载后
// 原生parent捕获
// 原生child捕获
// 原生child冒泡
// 原生parent冒泡
// 原生root冒泡挂载前
// react child冒泡
// 原生root冒泡挂载后

// ------------下面是react:16.14.0,react-dom:16.14.0的表现------------------
// 当所有e.stopPropagation()注释都不打开时
// 控制台打印如下:
// 原生document捕获挂载前
// 原生document捕获挂载后
// 原生root捕获挂载前
// 原生root捕获挂载后
// 原生parent捕获
// 原生child捕获
// 原生child冒泡
// 原生parent冒泡
// 原生root冒泡挂载前
// 原生root冒泡挂载后
// 原生document冒泡挂载前
// react parent捕获
// react child捕获
// react child冒泡
// react parent冒泡
// 原生document冒泡挂载后

// 当只打开注释1的e.stopPropagation()
// 控制台打印如下:
// 原生document捕获挂载前
// 原生document捕获挂载后
// 原生root捕获挂载前
// 原生root捕获挂载后
// 原生parent捕获

// 当只打开注释2的e.stopPropagation()
// 控制台打印如下:
// 原生document捕获挂载前
// 原生document捕获挂载后
// 原生root捕获挂载前
// 原生root捕获挂载后
// 原生parent捕获
// 原生child捕获
// 原生child冒泡
// 原生parent冒泡
// 原生root冒泡挂载前
// 原生root冒泡挂载后
// 原生document冒泡挂载前
// react parent捕获
// 原生document冒泡挂载后

// 当只打开注释3的e.stopPropagation()
// 控制台打印如下:
// 原生document捕获挂载前
// 原生document捕获挂载后
// 原生root捕获挂载前
// 原生root捕获挂载后
// 原生parent捕获
// 原生child捕获
// 原生child冒泡
// 原生parent冒泡
// 原生root冒泡挂载前
// 原生root冒泡挂载后
// 原生document冒泡挂载前
// react parent捕获
// react child捕获
// react child冒泡
// 原生document冒泡挂载后

去除事件池

事件池 – React(https://zh-hans.reactjs.org/docs/legacy-event-pooling.html)

scroll 事件不再冒泡

在原生 scroll 里,scroll 是不存在冒泡阶段的,但是 React16 中模拟了 scroll 的冒泡阶段,React17 中将此特性去除,避免了当一个嵌套且可滚动的元素在其父元素触发事件时造成混乱。

本文由哈喽比特于1年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/lCHeWE5DrbxEYIdG60aIsw

 相关推荐

刘强东夫妇:“移民美国”传言被驳斥

京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。

发布于:7月以前  |  808次阅读  |  详细内容 »

博主曝三大运营商,将集体采购百万台华为Mate60系列

日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。

发布于:7月以前  |  770次阅读  |  详细内容 »

ASML CEO警告:出口管制不是可行做法,不要“逼迫中国大陆创新”

据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。

发布于:7月以前  |  756次阅读  |  详细内容 »

抖音中长视频App青桃更名抖音精选,字节再发力对抗B站

今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。

发布于:7月以前  |  648次阅读  |  详细内容 »

威马CDO:中国每百户家庭仅17户有车

日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。

发布于:7月以前  |  589次阅读  |  详细内容 »

研究发现维生素 C 等抗氧化剂会刺激癌症生长和转移

近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。

发布于:7月以前  |  449次阅读  |  详细内容 »

苹果据称正引入3D打印技术,用以生产智能手表的钢质底盘

据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。

发布于:7月以前  |  446次阅读  |  详细内容 »

千万级抖音网红秀才账号被封禁

9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...

发布于:7月以前  |  445次阅读  |  详细内容 »

亚马逊股东起诉公司和贝索斯,称其在购买卫星发射服务时忽视了 SpaceX

9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。

发布于:7月以前  |  444次阅读  |  详细内容 »

苹果上线AppsbyApple网站,以推广自家应用程序

据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。

发布于:7月以前  |  442次阅读  |  详细内容 »

特斯拉美国降价引发投资者不满:“这是短期麻醉剂”

特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。

发布于:7月以前  |  441次阅读  |  详细内容 »

光刻机巨头阿斯麦:拿到许可,继续对华出口

据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。

发布于:7月以前  |  437次阅读  |  详细内容 »

马斯克与库克首次隔空合作:为苹果提供卫星服务

近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。

发布于:7月以前  |  430次阅读  |  详细内容 »

𝕏(推特)调整隐私政策,可拿用户发布的信息训练 AI 模型

据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。

发布于:7月以前  |  428次阅读  |  详细内容 »

荣耀CEO谈华为手机回归:替老同事们高兴,对行业也是好事

9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。

发布于:7月以前  |  423次阅读  |  详细内容 »

AI操控无人机能力超越人类冠军

《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。

发布于:7月以前  |  423次阅读  |  详细内容 »

AI生成的蘑菇科普书存在可致命错误

近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。

发布于:7月以前  |  420次阅读  |  详细内容 »

社交媒体平台𝕏计划收集用户生物识别数据与工作教育经历

社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”

发布于:7月以前  |  411次阅读  |  详细内容 »

国产扫地机器人热销欧洲,国产割草机器人抢占欧洲草坪

2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。

发布于:7月以前  |  406次阅读  |  详细内容 »

罗永浩吐槽iPhone15和14不会有区别,除了序列号变了

罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。

发布于:7月以前  |  398次阅读  |  详细内容 »
 相关文章
Android插件化方案 5年以前  |  236862次阅读
vscode超好用的代码书签插件Bookmarks 1年以前  |  6847次阅读
 目录