乐趣区

React事件源码浅析

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

退出移动版