1

1、概览

React实现自己封装了一套事件系统,基本原理为将所有的事件都代理到顶层元素上(如documen元素)上进行处理,带来的好处有:

  • 抹平各平台的兼容性问题,其中不仅包括不同浏览器之间的差异,而且在RN上也能带来一致的开发体验。
  • 更好的性能。事件代理是开发中常见的优化手段,React更进一步,包括复用合成事件类、事件池、批量更新等进一步提高性能。
本文基于React 16.8.1

2、几个小问题

在详细讲解之前,先思考几个问题,可以帮助我们更好理解React的事件系统。

  • React事件系统与原生事件混用的执行顺序问题

    class App extends React.Component {
      handleWrapperCaptureClick() {
        console.log('wrapper capture click')
      }
    
      handleButtonClick() {
        console.log('button click')
      }
    
      componentDidMount() {
        const buttonEle = document.querySelector('#btn')
        buttonEle.addEventListener('click', () => {
          console.log('button native click')
        })
    
        window.addEventListener('click', () => {
          console.log('window native click')
        })
      }
    
      render() {
        <div className="wrapper" onClickCapture={this.handleWrapperCaptureClick}>
          <button id="btn" onClick={this.handleButtonClick}>
            click me
          </button>
        </div>
      }
    }
  • 异步回调中获取事件对象失败问题

    handleClick(e) {
        fetch('/a/b/c').then(() => {
            console.log(e)
        })
    }
  • React事件系统中与浏览器原生change事件有哪些差别

如果看完本文后,能清晰的回答出这几个问题,说明你对React事件系统已经有比较清楚的理解了。下面就正式进入正文了。

3、事件的绑定

事件绑定在/packages/react-dom/src/client/ReactDOMComponent.js文件中

    } else if (registrationNameModules.hasOwnProperty(propKey)) {
        if (nextProp != null) {
            ensureListeningTo(rootContainerElement, propKey);
        }
    }

如果propkey是registrationNameModules中的一个事件名,则通过ensureListeningTo方法绑定,其中registrationNameModules为包含React所有事件一个的map,在事件plugin部分中会再提到。

  function ensureListeningTo(rootContainerElement, registrationName) {
  const isDocumentOrFragment =
    rootContainerElement.nodeType === DOCUMENT_NODE ||
    rootContainerElement.nodeType === DOCUMENT_FRAGMENT_NODE;
  const doc = isDocumentOrFragment
    ? rootContainerElement
    : rootContainerElement.ownerDocument;
  listenTo(registrationName, doc);
}  

从ensureListeningTo方法中可以看出,React事件挂载在document节点或者DocumentFragment上,listenTo方法则是真正将事件注册的入口,截取部分代码如下:

        case TOP_FOCUS:
        case TOP_BLUR:
          trapCapturedEvent(TOP_FOCUS, mountAt);
          trapCapturedEvent(TOP_BLUR, mountAt);
          // We set the flag for a single dependency later in this function,
          // but this ensures we mark both as attached rather than just one.
          isListening[TOP_BLUR] = true;
          isListening[TOP_FOCUS] = true;
          break;
        case TOP_CANCEL:
        case TOP_CLOSE:
          if (isEventSupported(getRawEventName(dependency))) {
            trapCapturedEvent(dependency, mountAt);
          }
          break;
        case TOP_INVALID:
        case TOP_SUBMIT:
        case TOP_RESET:
          // We listen to them on the target DOM elements.
          // Some of them bubble so we don't want them to fire twice.
          break;
        default:
          // By default, listen on the top level to all non-media events.
          // Media events don't bubble so adding the listener wouldn't do anything.
          const isMediaEvent = mediaEventTypes.indexOf(dependency) !== -1;
          if (!isMediaEvent) {
            trapBubbledEvent(dependency, mountAt);
          }
          break;

部分特殊事件做单独处理,默认将事件通过trapBubbledEvent放到绑定,trapBubbledEvent根据字面意思可知就是绑定到冒泡事件上。其中注意的是blur等事件是通过trapCapturedEvent绑定的,这是因为blur等方法不支持冒泡事件,但是支持捕获事件,所以需要使用trapCapturedEvent绑定。

接下来我们看下trapBubbledEvent方法。

function trapBubbledEvent(
  topLevelType: DOMTopLevelEventType,
  element: Document | Element,
) {
  if (!element) {
    return null;
  }
  const dispatch = isInteractiveTopLevelEventType(topLevelType)
    ? dispatchInteractiveEvent
    : dispatchEvent;

  addEventBubbleListener(
    element,
    getRawEventName(topLevelType),
    // Check if interactive and wrap in interactiveUpdates
    dispatch.bind(null, topLevelType),
  );
}

trapBubbledEvent就是将事件通过addEventBubbleListener绑定到document上的。dispatch则是事件的回调函数。dispatchInteractiveEvent和dispatchEvent的区别为,dispatchInteractiveEvent在执行前会确保之前所有的任务都已执行,具体见/packages/react-reconciler/src/ReactFiberScheduler.js中的interactiveUpdates方法,该模块不是本文讨论的重点,感兴趣可以自己看看。

事件的绑定已经介绍完毕,下面介绍事件的合成及触发,该部分为React事件系统的核心。

4、事件的合成

事件在dispatch方法中将事件的相关信息保存到bookKeeping中,其中bookKeeping也有个bookKeeping池,从而避免了反复创建销毁变量导致浏览器频繁GC。
创建完bookkeeping后就传入handleTopLevel处理了,handleTopLevel主要是缓存祖先元素,避免事件触发后找不到祖先元素报错。接下来就进入runExtractedEventsInBatch方法了。

function runExtractedEventsInBatch(
  topLevelType: TopLevelType,
  targetInst: null | Fiber,
  nativeEvent: AnyNativeEvent,
  nativeEventTarget: EventTarget,
) {
  const events = extractEvents(
    topLevelType,
    targetInst,
    nativeEvent,
    nativeEventTarget,
  );
  runEventsInBatch(events);
}

runExtractedEventsInBatch代码很短,但是非常重要,其中extractEvents通过不同插件合成事件,runEventsInBatch则是完成事件的触发,事件触发放到下一小节中再讲,接下来先讲事件的合成。

function extractEvents(
  topLevelType: TopLevelType,
  targetInst: null | Fiber,
  nativeEvent: AnyNativeEvent,
  nativeEventTarget: EventTarget,
): Array<ReactSyntheticEvent> | ReactSyntheticEvent | null {
  let events = null;

  for (let i = 0; i < plugins.length; i++) {
    // Not every plugin in the ordering may be loaded at runtime.
    const possiblePlugin: PluginModule<AnyNativeEvent> = plugins[i];
    if (possiblePlugin) {
      const extractedEvents = possiblePlugin.extractEvents(
        topLevelType,
        targetInst,
        nativeEvent,
        nativeEventTarget,
      );
      if (extractedEvents) {
        events = accumulateInto(events, extractedEvents);
      }
    }
  }
  return events;
}

可以看到extractEvents通过遍历所有插件的extractEvents方法合成事件,如果一个插件适用该事件,则返回一个events,否则返回为null,意味着最后产生的events有可能是个数组。每个插件至少有两部分组成:eventTypes和extractEvents,eventTypes会在初始化的时候生成前文提到的registrationNameModules,extractEvents用于合成事件。下面介绍SimpleEventPlugin和ChangeEventPlugin两个插件。

插件是在初始化的时候通过EventPluginHubInjection插入的,并对其进行排序等初始化工作,不同的平台会注入不同的插件。

SimpleEventPlugin

const SimpleEventPlugin: PluginModule<MouseEvent> & {
  isInteractiveTopLevelEventType: (topLevelType: TopLevelType) => boolean,
} = {
  eventTypes: eventTypes,

  isInteractiveTopLevelEventType(topLevelType: TopLevelType): boolean {
    const config = topLevelEventsToDispatchConfig[topLevelType];
    return config !== undefined && config.isInteractive === true;
  },

  extractEvents: function(
    topLevelType: TopLevelType,
    targetInst: null | Fiber,
    nativeEvent: MouseEvent,
    nativeEventTarget: EventTarget,
  ): null | ReactSyntheticEvent {
    const dispatchConfig = topLevelEventsToDispatchConfig[topLevelType];
    if (!dispatchConfig) {
      return null;
    }
    let EventConstructor;
    switch (topLevelType) {
      case DOMTopLevelEventTypes.TOP_KEY_PRESS:
        // Firefox creates a keypress event for function keys too. This removes
        // the unwanted keypress events. Enter is however both printable and
        // non-printable. One would expect Tab to be as well (but it isn't).
        if (getEventCharCode(nativeEvent) === 0) {
          return null;
        }
      /* falls through */
      case DOMTopLevelEventTypes.TOP_KEY_DOWN:
      case DOMTopLevelEventTypes.TOP_KEY_UP:
        EventConstructor = SyntheticKeyboardEvent;
        break;
      case DOMTopLevelEventTypes.TOP_BLUR:
      case DOMTopLevelEventTypes.TOP_FOCUS:
        EventConstructor = SyntheticFocusEvent;
        break;
      case DOMTopLevelEventTypes.TOP_CLICK:
        // Firefox creates a click event on right mouse clicks. This removes the
        // unwanted click events.
        if (nativeEvent.button === 2) {
          return null;
        }
      /* falls through */
      case DOMTopLevelEventTypes.TOP_AUX_CLICK:
      case DOMTopLevelEventTypes.TOP_DOUBLE_CLICK:
      case DOMTopLevelEventTypes.TOP_MOUSE_DOWN:
      case DOMTopLevelEventTypes.TOP_MOUSE_MOVE:
      case DOMTopLevelEventTypes.TOP_MOUSE_UP:
      /* falls through */
      case DOMTopLevelEventTypes.TOP_MOUSE_OUT:
      case DOMTopLevelEventTypes.TOP_MOUSE_OVER:
      case DOMTopLevelEventTypes.TOP_CONTEXT_MENU:
        EventConstructor = SyntheticMouseEvent;
        break;
      case DOMTopLevelEventTypes.TOP_DRAG:
      case DOMTopLevelEventTypes.TOP_DRAG_END:
      case DOMTopLevelEventTypes.TOP_DRAG_ENTER:
      case DOMTopLevelEventTypes.TOP_DRAG_EXIT:
      case DOMTopLevelEventTypes.TOP_DRAG_LEAVE:
      case DOMTopLevelEventTypes.TOP_DRAG_OVER:
      case DOMTopLevelEventTypes.TOP_DRAG_START:
      case DOMTopLevelEventTypes.TOP_DROP:
        EventConstructor = SyntheticDragEvent;
        break;
      case DOMTopLevelEventTypes.TOP_TOUCH_CANCEL:
      case DOMTopLevelEventTypes.TOP_TOUCH_END:
      case DOMTopLevelEventTypes.TOP_TOUCH_MOVE:
      case DOMTopLevelEventTypes.TOP_TOUCH_START:
        EventConstructor = SyntheticTouchEvent;
        break;
      case DOMTopLevelEventTypes.TOP_ANIMATION_END:
      case DOMTopLevelEventTypes.TOP_ANIMATION_ITERATION:
      case DOMTopLevelEventTypes.TOP_ANIMATION_START:
        EventConstructor = SyntheticAnimationEvent;
        break;
      case DOMTopLevelEventTypes.TOP_TRANSITION_END:
        EventConstructor = SyntheticTransitionEvent;
        break;
      case DOMTopLevelEventTypes.TOP_SCROLL:
        EventConstructor = SyntheticUIEvent;
        break;
      case DOMTopLevelEventTypes.TOP_WHEEL:
        EventConstructor = SyntheticWheelEvent;
        break;
      case DOMTopLevelEventTypes.TOP_COPY:
      case DOMTopLevelEventTypes.TOP_CUT:
      case DOMTopLevelEventTypes.TOP_PASTE:
        EventConstructor = SyntheticClipboardEvent;
        break;
      case DOMTopLevelEventTypes.TOP_GOT_POINTER_CAPTURE:
      case DOMTopLevelEventTypes.TOP_LOST_POINTER_CAPTURE:
      case DOMTopLevelEventTypes.TOP_POINTER_CANCEL:
      case DOMTopLevelEventTypes.TOP_POINTER_DOWN:
      case DOMTopLevelEventTypes.TOP_POINTER_MOVE:
      case DOMTopLevelEventTypes.TOP_POINTER_OUT:
      case DOMTopLevelEventTypes.TOP_POINTER_OVER:
      case DOMTopLevelEventTypes.TOP_POINTER_UP:
        EventConstructor = SyntheticPointerEvent;
        break;
      default:
        // HTML Events
        // @see http://www.w3.org/TR/html5/index.html#events-0
        EventConstructor = SyntheticEvent;
        break;
    }
    const event = EventConstructor.getPooled(
      dispatchConfig,
      targetInst,
      nativeEvent,
      nativeEventTarget,
    );
    accumulateTwoPhaseDispatches(event);
    return event;
  },
};

可以看到不同的事件类型会有不同的合成事件基类,然后再通过EventConstructor.getPooled生成事件。在default中的SyntheticEvent我们可以看到熟悉的preventDefault、stopPropagation、persist等方法,其中有个persist需要说明下,由上文可知事件对象会循环使用,所以一个事件完成后事件就会被回收,因此在异步回调中是拿不到事件的,而调用persist方法后会保持事件的引用不被回收。preventDefault则调用原生事件的preventDefault方法,并标记isDefaultPrevented,该属性下一节会再继续讲。

合成事件之后,会通过accumulateTwoPhaseDispatches收集父级事件监听并储存到_dispatchListeners中,这里是React事件系统模拟冒泡的关键。

export function traverseTwoPhase(inst, fn, arg) {
  const path = [];
  // 遍历父级元素
  while (inst) {
    path.push(inst);
    inst = getParent(inst);
  }
  let i;
  // 分别放入捕获和冒泡队列中
  // fn为accumulateDirectionalDispatches方法
  for (i = path.length; i-- > 0; ) {
    fn(path[i], 'captured', arg);
  }
  for (i = 0; i < path.length; i++) {
    fn(path[i], 'bubbled', arg);
  }
}
function accumulateDirectionalDispatches(inst, phase, event) {
  // 提取绑定的监听事件
  const listener = listenerAtPhase(inst, event, phase);
  if (listener) {
    // 将提取到的绑定添加到_dispatchListeners中
    event._dispatchListeners = accumulateInto(
      event._dispatchListeners,
      listener,
    );
    event._dispatchInstances = accumulateInto(event._dispatchInstances, inst);
  }
}

ChangeEventPlugin

const ChangeEventPlugin = {
  eventTypes: eventTypes,

  _isInputEventSupported: isInputEventSupported,

  extractEvents: function(
    topLevelType,
    targetInst,
    nativeEvent,
    nativeEventTarget,
  ) {
    const targetNode = targetInst ? getNodeFromInstance(targetInst) : window;

    let getTargetInstFunc, handleEventFunc;
    if (shouldUseChangeEvent(targetNode)) {
      getTargetInstFunc = getTargetInstForChangeEvent;
    } else if (isTextInputElement(targetNode)) {
      if (isInputEventSupported) {
        getTargetInstFunc = getTargetInstForInputOrChangeEvent;
      } else {
        getTargetInstFunc = getTargetInstForInputEventPolyfill;
        handleEventFunc = handleEventsForInputEventPolyfill;
      }
    } else if (shouldUseClickEvent(targetNode)) {
      getTargetInstFunc = getTargetInstForClickEvent;
    }

    if (getTargetInstFunc) {
      const inst = getTargetInstFunc(topLevelType, targetInst);
      if (inst) {
        const event = createAndAccumulateChangeEvent(
          inst,
          nativeEvent,
          nativeEventTarget,
        );
        return event;
      }
    }

    if (handleEventFunc) {
      handleEventFunc(topLevelType, targetNode, targetInst);
    }

    // When blurring, set the value attribute for number inputs
    if (topLevelType === TOP_BLUR) {
      handleControlledInputBlur(targetNode);
    }
  },
};

MDN中对change事件有以下描述:

事件触发取决于表单元素的类型(type)和用户对标签的操作:

  • 当元素被:checked时(通过点击或者使用键盘):<input type="radio"> 和 <input type="checkbox">;
  • 当用户完成提交动作时(例如:点击了 <select>中的一个选项,从 <input type="date">标签选择了一个日期,通过<input type="file">标签上传了一个文件,等);
  • 当标签的值被修改并且失焦后,但并未进行提交(例如:对<textarea> 或者<input type="text">的值进行编辑后。)。

ChangeEventPlugin中shouldUseChangeEvent对应的<input type="date">与<input type="file">元素,监听change事件;isTextInputElement对应普通input元素,监听input事件;shouldUseClickEvent对应<input type="radio">与<input type="checkbox">元素,监听click事件。

所以普通input元素中当时区焦点后才会触发change事件,而React的change事件在每次输入的时候都会触发,因为监听的是input事件。

5、事件的触发

截止到目前已经完成了事件的绑定与合成,接下来就是最后一步事件的触发了。事件触发的入口为前文提到的runEventsInBatch方法,该方法中会遍历触发合成的事件。

function executeDispatchesInOrder(event) {
  const dispatchListeners = event._dispatchListeners;
  const dispatchInstances = event._dispatchInstances;
  // 遍历触发dispatchListeners中收集的事件
  if (Array.isArray(dispatchListeners)) {
    for (let i = 0; i < dispatchListeners.length; i++) {
      if (event.isPropagationStopped()) {
        break;
      }
      // Listeners and Instances are two parallel arrays that are always in sync.
      executeDispatch(event, dispatchListeners[i], dispatchInstances[i]);
    }
  } else if (dispatchListeners) {
    executeDispatch(event, dispatchListeners, dispatchInstances);
  }
  event._dispatchListeners = null;
  event._dispatchInstances = null;
}

其中event.isPropagationStopped()为判断是否需要阻止冒泡,需要注意的是因为是代理到document上的,原生事件早已冒泡到了document上,所以stopPropagation是无法阻止原生事件的冒泡,只能阻止React事件的冒泡。
executeDispatch就是最终触发回调事件的地方,并捕获错误。至此React事件的绑定、合成与触发都已经结束了。

6、结束

React事件系统初看比较复杂,其实理解后也并没有那么难。在解决跨平台和兼容性的问题时,保持了高性能,有很多值得学习的地方。
在看源代码的时候,一开始也没有头绪,多打断点,一点点调试,也就慢慢理解。
文中如有不正确的地方,还望不吝指正。


kjnt
275 声望5 粉丝

悟已往之不谏,知来者之可追。