一道面试题引发对React合成事件的深入思考(二)

405 阅读7分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第4天,点击查看活动详情

写在前面

这是关于React合成事件的第二篇,如果你之前对于合成事件不太了解,或是对React了解较少这里强烈建议看一下第一篇(传送门)。这篇主要是对React源码的梳理,讲解部分去掉了部分无关代码。

为什么使用合成事件

这里我们稍稍回顾一下前情,让这篇看起来完整一些。

我们之前说的只是React的触发方式,实际上对于合成事件合成并没有解释,这个也是打算是放在下一个篇章中结合源码进行讲解,我们可以把使用合成事件归结成下面两个目的

  • 抹平浏览器差异,这里主要是针对不同浏览器对于事件的api的兼容,主要是万恶的IE的兼容,好在微软官方已经放弃它了。
  • 使用事件委托,监听都在根节点进行,减少了内存开销。

插件系统

React通过一个插件系统来控制整个合成事件的过程,从注册事件、事件映射、监听事件、分发事件函数,有条不紊的进行工作,来保证事件系统的稳定。

准备阶段

我们在上个篇章中提到,React使用了事件委托,把所有的事件绑定在div#root上,由它统一去处理。那我们在监听事件之前需要做哪些准备呢?

注册事件

我们需要拿到原生DOM支持的事件,一一注册在React上面。React这里使用了一个Set,通过插件,将所有事件保存,这里以SimpleEventPlugin注册简单事件为例

// src/react-dom-bindings/src/events/EventRegistry.js
export const allNativeEvents = new Set(); // 用来保存所有的原生事件

// src/react-dom-bindings/src/events/DOMEventProperties.js
// 这里事件比较多,我们用省略号省略了一些事件,保留了一些眼熟的事件
const simpleEventPluginEvents = [
  'abort',
  // ...,
  'click',
  // ...,
  'mouseDown',
  'mouseMove',
  'mouseOut',
  'mouseOver',
  'mouseUp',
  // ...,
  'wheel',
];

function registerSimpleEvent(domEventName, reactName) {
  registerTwoPhaseEvent(reactName, [domEventName]); // 注册两个阶段的事件
}

/**
 * 注册简单事件
 */
export function registerSimpleEvents() {
  for (let i = 0; i < simpleEventPluginEvent.length; i++) {
    const eventName = simpleEventPluginEvent[i]; // click
    const domEventName = eventName.toLowerCase(); // click
    const capitalizeEvent = eventName[0].toUpperCase() + eventName.slice(1); // Click

    registerSimpleEvent(domEventName, `on${capitalizeEvent}`);
  }
}

// src/react-dom-bindings/src/events/plugins/SimpleEventPlugin.js
import { registerSimpleEvents } from '../DOMEventProperties';
export { registerSimpleEvents as registerEvents };

// src/react-dom-bindings/src/events/DOMPluginEventSystem.js
import * as SimpleEventPlugin from './plugins/SimpleEventPlugin';

// 为我们allNativeEvents赋值,注册事件
SimpleEventPlugin.registerEvents();

源码在这里写的比较麻烦,但是实际上做的事情并不是很多,这里我简单的进行一下概括:

  • 首先我们有一个包括所有原生事件的数组simpleEventPluginEvents
  • 我们创建了一个SetallNativeEvents来保存原生事件。
  • 通过SimpleEventPlugin插件调用registerEvents方法为allNativeEvents赋值。

这样我们allNativeEvents就包括所有的事件了。

事件映射

事实上我们在使用React绑定事件的时候都是使用比如onClick的方式,但是我们如果想让DOM实现监听的话,那么我们就必须使用它能“听”懂的click,这里就需要我们做一个映射了,把onClickclick关联起来。

React使用的方式是维护了名叫topLevelEventsToReactNamesMap,在注册事件的事件,把ReactName和nativeName做一个关联:

// src/react-dom-bindings/src/events/DOMEventProperties.js
export const topLevelEventsToReactNames = new Map();

export function registerSimpleEvents() {
  for (let i = 0; i < simpleEventPluginEvent.length; i++) {
    const eventName = simpleEventPluginEvent[i];
    const domEventName = eventName.toLowerCase();
    const capitalizeEvent = eventName[0].toUpperCase() + eventName.slice(1);

    registerSimpleEvent(domEventName, `on${capitalizeEvent}`); // 这里完成了reactName的拼接
  }
}

function registerSimpleEvent(domEventName, reactName) {
  topLevelEventsToReactNames.set(domEventName, reactName); // 保存domEventName和reactName的映射
  registerTwoPhaseEvent(reactName, [domEventName]);
}

小结

到这里我们准备阶段的工作就完成了,最后的结果就是拿到了一个了包括所有事件的SetallNativeEvents和一个关于React事件和原生事件的映射topLevelEventsToReactNames。下面我们将进行真正的监听。

事件监听

注册捕获和冒泡事件

当我们完成准备条件之后,就可以在我们div#root进行事件的监听:

// src/react-dom-bindings/src/events/DOMPluginEventSystem.js
// 随机的唯一标识,我们只进行一次事件监听,避免重复监听
const listeningMarker = `_reactListening` + Math.random().toString(36).slice(2);

/**
 * 监听根容器,即div#root
 * @param {*} rootContainerElement div#root
 */
export function listenToAllSupportedEvents(rootContainerElement) {
  // 监听根容器,为其添加listeningMarker属性,以达到只监听一次的目的
  if (!rootContainerElement[listeningMarker]) {
    rootContainerElement[listeningMarker] = true;
  }

  // 遍历所有原生事件比如click,进行监听
  allNativeEvents.forEach((domEventName) => {
    listenToNativeEvent(domEventName, true, rootContainerElement); // 捕获事件
    listenToNativeEvent(domEventName, false, rootContainerElement); // 冒泡事件
  });
}
/**
 * 注册原生事件
 * @param {*} domEventName 原生事件,click
 * @param {*} isCapurePhaseListener 是否是捕获阶段
 * @param {*} target 目标DOM节点,div#root 容器节点
 */
export function listenToNativeEvent(
  domEventName,
  isCapurePhaseListener,
  target,
) {
  let eventSystemFlags = 0; // 默认是0 指的是冒泡  4是捕获
  if (isCapurePhaseListener) {
    eventSystemFlags |= IS_CAPTURE_PHASE;
  }

  // 将要进行的原生监听
  addTrappedEventListener(
    target,
    domEventName,
    eventSystemFlags,
    isCapurePhaseListener,
  );
}

// src/react-dom-bindings/src/events/EventSystemFlags.js
export const IS_CAPTURE_PHASE = 1 << 2; // 4

这个阶段完成的工作主要包括:

  • 遍历所有的事件,为每个事件都注册了捕获冒泡两个阶段的监听。这里需要注意的是,我们只进行一个的监听,方法是通过产生一个随机listeningMarker标记div#root,来证明他已经被监听过,避免在之后rerender阶段重复监听。
  • 添加标志捕获的flagseventSystemFlags,其中0表示冒泡,4表示捕获

获取监听事件

存事件函数

这里是我们这个篇章的重点,完成的也是比较复杂的,下面我们借助示例来理清React在这里的处理思想(下面的示例均以点击事件为例)。

<div onClick={() => console.log('click')}>click me</div>

我们通常会采用上面的写法来进行事件绑定,绑定的函数是写在虚拟DOM上面的,页面渲染时,React会把虚拟DOM转换为Fiber,然后通过Fiber协调,生成真实DOM,虚拟DOM、Fiber、真实DOM都是一一对应的。

我们的点击函数会随着虚拟DOM被添加在Fiber的属性中,但是我们在真实DOM中,无法观察到函数的存在,那React又是怎么获取这个点击函数的呢?

我们在创建真实DOM的时候就把Fiber里面属性绑定在DOM上了

// src/react-dom-bindings/src/client/ReactDOMHostConfig.js
/**
 * 创建实例节点
 * @param {*} type 节点类型
 * @param {*} props 属性
 * @param {*} internalInstanceHandle fiber
 * @returns dom实例
 */
export function createInstance(type, props, internalInstanceHandle) {
  const domElement = document.createElement(type);
  updateFiberNode(domElement, props);

  return domElement;
}

// src/react-dom-bindings/src/client/ReactDOMComponentTree.js
const randomKey = Math.random().toString(36).slice(2);
const internalPropsKey = '__reactProps$' + randomKey;

export function updateFiberNode(node, props) {
  node[internalPropsKey] = props;
}

我们在创建DOM实例的时候,会产生一个随机的internalPropsKey做为真实DOM的一个属性,这样我们就可以通过真实DOM拿到我们事件函数了。

取事件函数

通过上面的方式,我们在真实DOM中可以通过属性访问的方式拿到我们的事件函数。

// src/react-dom-bindings/src/events/getListener.js
export default function getListener(inst, registrationName) {
  const { stateNode } = inst;

  if (stateNode === null) {
    return null;
  }

  const props = getFiberCurrentPropsFromNode(stateNode);
  if (props === null) {
    return null;
  }

  const listener = props[registrationName];
  return listener;
}

// src/react-dom-bindings/src/client/ReactDOMComponentTree.js
export function getFiberCurrentPropsFromNode(node) {
  return node[internalPropsKey] || null;
}

但是这里依旧的问题是,我们是通过事件委托的方式,由根节点统一监听。当点击事件发生时,我们不仅要当前目标绑定的事件,还需要冒泡到根节点,获取其中所有绑定的点击函数,根据不同阶段依次触发。

计算单阶段所有事件

不论是冒泡阶段还是捕获阶段,都需要我们获取当前事件源到根节点的所有事件,而这种不断遍历的事情,我们通过真实DOM又很难做到,最好的方式就是通过Fiber,遍历Fiber获取所有的事件。

React在这也是通过和真实DOM绑定的方式,通过事件源的真实DOM获取到其对应的Fiber,然后遍历Fiber获取所有的事件。

// src/react-dom-bindings/src/client/ReactDOMHostConfig.js
/**
 * 创建实例节点
 * @param {*} type 节点类型
 * @param {*} props 属性
 * @param {*} internalInstanceHandle fiber
 * @returns dom实例
 */
export function createInstance(type, props, internalInstanceHandle) {
  const domElement = document.createElement(type);
  precacheFiberNode(internalInstanceHandle, domElement);
  updateFiberNode(domElement, props);

  return domElement;
}

// src/react-dom-bindings/src/client/ReactDOMComponentTree.js
/**
 * 提前在dom上面缓存对应的fiber
 * @param {*} hostInst
 * @param {*} node
 */
const randomKey = Math.random().toString(36).slice(2);
const internalInstanceKey = '__reactFiber$' + randomKey;
export function precacheFiberNode(hostInst, node) {
  node[internalInstanceKey] = hostInst;
}

这里也是和存事件函数使用一样的方法,在创建真实DOM的时候,为其绑定随机的internalInstanceKey属性,这个属性指向真实DOM对应的Fiber,这样我们就可以通过真实DOM获取到其对应的Fiber。

/**
 * 计算单阶段的事件
 * @param {*} targetFiber
 * @param {*} reactName
 * @param {*} nativeEventType
 * @param {*} isCapturePhase
 */
export function accumulateSinglePhaseListeners(
  targetFiber,
  reactName,
  nativeEventType,
  isCapturePhase,
) {
  const captureName = reactName + 'Capture';
  const reactEventName = isCapturePhase ? captureName : reactName;
  const listeners = []; // 事件数组

  let instance = targetFiber; // 要处理的Fiber
  while (instance !== null) {
    const { stateNode, tag } = instance;
    if (tag === HostComponent && stateNode !== null) {
      const listener = getListener(instance, reactEventName); // 获取该fiber上面的事件函数

      if (listener) {
        listeners.push(listener);
      }
    }

    instance = instance.return; // 进行下一次迭代
  }

  return listeners;
}

我们可以看看上面的处理手法,从事件源的fiber开始,如果是原生组件(div、sapn)这些对应真实DOM的fiber,取到其对应的事件函数,保存到数组中,如果该Fiber存在父Fiber则继续遍历,直至没有父Fiber。这样我们数组就可以保存到所有“途径”的事件函数。

小结

这一小节我们进行了事件监听,在div#root上面,对allNativeEvents里面所有的事件都进行了冒泡和捕获阶段的监听;着重介绍了通过为真实DOM添加新属性获取事件函数的方法;获取事件源到根结点所有事件函数的方法。

合成事件

当我们点击事件发生时,浏览器会将当前事件的事件源传递给addEventListener的监听函数中,React在原生事件的基础上进行了一些属性添加和覆盖,来进行对浏览器的兼容,新的事件对象被称为是合成事件。React把这个合成事件作为事件对象传递给触发函数。

// src/react-dom-bindings/src/events/SyntheticEvent.js
function functionThatReturnTrue() {
  return true;
}
function functionThatReturnFalse() {
  return false;
}

// 鼠标事件,这里其实有很多,这里做了一些删减
const MouseEventInterface = {
  clientX: 0,
  clientY: 0,
  // ...
};

function createSyntheticEvent(inter) {
  /**
   * 合成事件的基类
   * @param {*} reactName react事件名 onClick
   * @param {*} reactEventType click
   * @param {*} targetInst 事件源对应的fiber实例
   * @param {*} nativeEvent 原生事件对象
   * @param {*} nativeEventTarget 原生事件源,span 事件源对用的那个真实DOM
   */
  function SyntheticBaseEvent(
    reactName,
    reactEventType,
    targetInst,
    nativeEvent,
    nativeEventTarget,
  ) {
    debugger;
    this._reactName = reactName;
    this.type = reactEventType;
    this._targetInst = targetInst;
    this.nativeEvent = nativeEvent;
    this.target = nativeEventTarget;
    // 把接口上面对应属性从原生事件上拷贝到合成事件实例上
    for (const propName in inter) {
      if (!inter.hasOwnProperty(propName)) {
        continue;
      }
      this[propName] = nativeEvent[propName];
    }
    // 是否已经阻止默认事件
    this.isDefaultPrevented = functionThatReturnFalse;
    // 是否已经阻止继续传播
    this.isPropagationStopped = functionThatReturnFalse;

    return this;
  }

  // 重写preventDefault和stopPropagation,主要是兼容浏览器
  Object.assign(SyntheticBaseEvent.prototype, {
    preventDefault() {
      const event = this.nativeEvent;
      if (event.preventDefault) {
        event.preventDefault();
      } else {
        event.returnValue = false;
      }
      this.isDefaultPrevented = functionThatReturnTrue;
    },
    stopPropagation() {
      const event = this.nativeEvent;
      if (event.stopPropagation) {
        event.stopPropagation();
      } else {
        event.cancelBubble = true;
      }
      this.isPropagationStopped = functionThatReturnTrue;
    },
  });

  return SyntheticBaseEvent;
}

export const SyntheticMouseEvent = createSyntheticEvent(MouseEventInterface);

这里主要是看看我们对preventDefaultstopPropagation的兼容。React对两个方法方法进行了兼容和覆盖,这里其实主要是对IE浏览器的兼容,抹平浏览器之间的差异。

最后

本篇我们从源码上分析React是如何实现合成事件,着重讲解了事件的获取和绑定关系的确立;如何进行事件绑定,监听两个阶段的事件;如何创建合成事件,重写事件对象的属性,实现浏览器的兼容。

这个是我们React合成事件的第二篇,第一篇中主要是对为何要要使用合成函数的思考,而本篇中则是侧重于在源码上实现各处细节。