likes
comments
collection
share

React17事件机制

作者站长头像
站长
· 阅读数 35

我们来自字节跳动飞书商业应用研发部(Lark Business Applications),目前我们在北京、深圳、上海、武汉、杭州、成都、广州、三亚都设立了办公区域。我们关注的产品领域主要在企业经验管理软件上,包括飞书 OKR、飞书绩效、飞书招聘、飞书人事等 HCM 领域系统,也包括飞书审批、OA、法务、财务、采购、差旅与报销等系统。欢迎各位加入我们。

本文作者:LBA 赖达强

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

前置知识

事件传播机制

React17事件机制

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

  1. 捕获阶段,事件从window开始,自上而下一直传播到目标元素的阶段
  1. 目标阶段,事件真正的触发元素处理事件的阶段
  1. 冒泡阶段,从目标元素开始,自下而上一直传播到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;
}

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

React17事件机制

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

React17事件机制

非代理事件

对于非代理事件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冒泡阶段回调

React17事件机制

实际上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;
}

图示

React17事件机制React17事件机制

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

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

React17事件机制

这一改动的出发点是如果页面中存在多个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,可以通过切换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

scroll事件不再冒泡

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

加入我们

扫码发现职位&投递简历

React17事件机制

官网投递:job.toutiao.com/s/FyL7DRg

欢迎大家关注飞书技术,每周定期更新飞书技术团队技术干货内容,想看什么内容,欢迎大家评论区留言~