React17 事件机制

1,367 阅读8分钟

前言

接触过 React 技术栈的同学相信都有了解,React 团队在源码中实现了一套事件机制来代替原生浏览器事件,其目的是:

  1. 抹平事件对象在不同浏览器上的差异,如:在不同浏览器下阻止事件冒泡(SyntheticEvent 合成事件);
  2. 与底层架构上的任务调度「优先级机制」衔接;
  3. 基于以上两点,React 需要自己实现一套模拟「捕获」和「冒泡」的事件机制。

这套机制采用了「事件委托」方式,将冒泡和捕获事件统一绑定在 document 上进行事件回调派发(不能冒泡的事件会直接在对应的 dom 节点上直接绑定);

不过在新版 ReactV17 及以后,不再将事件委托到 document 上,而是委托在渲染 React 应用的根 DOM 容器中,即使用 rootNode.addEventListener() 监听事件动作去执行相关事件回调。

React 事件系统实现虽然庞大,但核心都放在这两个模块上:

  1. SyntheticEvent(合成事件)
  2. 模拟实现事件的传播机制(冒泡、捕获)

从源码角度看,React 事件机制整体可以概括为:

  1. 事件绑定(listenToNativeEvent)
  2. 处理合成事件(extractEvents)
  3. 收集事件响应链路(extractEvents)
  4. 触发事件回调函数(processDispatchQueue)

下面,我们将通过 debugger 来分析事件 Call Stack 调用栈去理解 React 事件机制。

一、前置知识

1.1、DOM 事件流

W3C 标准约定了一个事件的传播过程要经过 3 个阶段:事件捕获阶段目标阶段事件冒泡阶段

当事件被触发时,首先经历的是一个捕获过程:事件会从最外层的元素开始“穿梭”,逐层“穿梭”到最内层元素,这个过程会持续到事件抵达它目标的元素(也就是真正触发这个事件的元素)为止;

此时事件流就切换到了“目标阶段” —— 事件被目标元素所接收;

然后事件会被“回弹”,进入到冒泡阶段——它会沿着来时的路“逆流而上”,一层一层再走回去。

1.2、事件委托

在面对事件流机制,事件委托(也叫事件代理)则是一种重要的性能优化手段。

例如,页面有 10 个 li 元素,通过点击每个元素都能输出它的文本内容,一个比较直观的思路是让每一个 li 元素都去监听一个点击动作,10 个 li 就会被绑定 10 个监听事件,这样开销比较大。

有没有更好方式呢?借助事件冒泡机制,我们将事件处理委托到上级 ul 元素上,让 ul 来帮忙感知这个点击事件。这样我们就可以实现「委托」:

<ul id="list">
  <li>海内存知己</li>
  <li>天涯若比邻</li>
  ...
</ul>
const ul = document.getElementById('list')
ul.addEventListener('click', function(e){
  console.log(e.target.innerHTML)
})

二、debugger Demo

这里我们提供一个简单的 Demo,进行事件机制代码调试:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>React 事件机制</title>
</head>
<body>
  <script crossorigin src="https://unpkg.com/react@17.0.2/umd/react.development.js"></script>
  <script crossorigin src="https://unpkg.com/react-dom@17.0.2/umd/react-dom.development.js"></script>
  <script src="http://static.runoob.com/assets/react/browser.min.js"></script>

  <div id="root"></div>
  <script type="text/babel">
    function App() {
      return (
        <div className="App" onClick={() => {
          debugger;
          console.log('click App.');
        }}>
          Hello World
        </div>
      )
    }
    ReactDOM.render(<App />, document.getElementById('root'));
  </script>
</body>
</html>

我们打开浏览器控制台,当触发点击事件后,会进行断点,你会看到调用栈信息显示如下:

1661089180852.jpg

在这条执行链路上,包含了 React 事件机制的三个部分:

  1. 处理合成事件(dispatchEventsForPlugins)
  2. 收集事件响应链路(dispatchEventsForPlugins)
  3. 触发事件回调函数(processDispatchQueue)

除此之外,还有重要的一部分是事件初始化过程,会将事件委托到 container 容器节点,下面我们先来看初始化这一部分。

三、初始化事件

初始化事件阶段分为两部分内容进行:

  1. 注册事件插件,React 对不同类型的事件采用不同插件提供支持;
  2. 事件委托,为 contianer 挂载容器元素绑定捕获和冒泡事件监听器。

3.1、注册事件插件

浏览器环境下事件种类很多,React 将不同事件(鼠标事件、表单事件等)进行区分定义,它的位置在:react-dom/src/events/DOMPluginEventProperties.js

// 定义不同事件
const discreteEventPairsForSimpleEventPlugin = [
  ('cancel': DOMEventName), 'cancel',
  ('click': DOMEventName), 'click',
  ('close': DOMEventName), 'close',
  ('contextmenu': DOMEventName), 'contextMenu',
  ...
]
const otherDiscreteEvents: Array<DOMEventName> = [
  'change',
  'selectionchange',
  'textInput',
  'compositionstart',
  'compositionend',
  'compositionupdate',
];
const userBlockingPairsForSimpleEventPlugin: Array<string | DOMEventName> = [
  ...
]
onst continuousPairsForSimpleEventPlugin: Array<string | DOMEventName> = [
  ...
]

在引入加载 react-dom.js 时,会同时执行事件注册逻辑,React 根据事件类型划分为 5 大类:

// react-dom/src/events/DOMPluginEventSystem.js
SimpleEventPlugin.registerEvents();
EnterLeaveEventPlugin.registerEvents();
ChangeEventPlugin.registerEvents();
SelectEventPlugin.registerEvents();
BeforeInputEventPlugin.registerEvents();

通过执行不同插件的 registerEvents,最终将所有的事件注册并保存在 allNativeEvents 变量集合之中(下面会用到):

// react-dom/src/events/EventRegistry.js
const allNativeEvents: Set<DOMEventName> = new Set();

function registerDirectEvent(registrationName, dependencies) {
  ...
  for (var i = 0; i < dependencies.length; i++) {
    allNativeEvents.add(dependencies[i]);
  }
}

3.2、container 容器节点事件委托

将事件处理函数委托在 container 容器节点,时机是在调用 ReactDOM.render 进行初渲染时,与创建 FiberRootNodeHostRootFiber 在同一阶段:

// react-dom/src/client/ReactDOMRoot.js
function createRootImpl(container, tag, options) {
  // 创建 FiberRootNode 和 HostRootFiber
  var root = createContainer(container, tag, hydrate);
  var rootContainerElement = container.nodeType === COMMENT_NODE ? container.parentNode : container;
  // 初始化事件,对 container 容器元素绑定各类事件
  listenToAllSupportedEvents(rootContainerElement);

  return root;
}

调用 listenToNativeEventrootContainerElement 注册捕获和冒泡事件处理器:

// react-dom/src/events/DOMPluginEventSystem.js
function listenToAllSupportedEvents(rootContainerElement) {
  allNativeEvents.forEach(function (domEventName) {
    // 进行支持冒泡事件的注册,如 click
    if (!nonDelegatedEvents.has(domEventName)) {
      listenToNativeEvent(domEventName, false, rootContainerElement, null);
    }
    // 捕获事件
    listenToNativeEvent(domEventName, true, rootContainerElement, null);
  });
}

function listenToNativeEvent(domEventName, isCapturePhaseListener, rootContainerElement, targetElement) {
  var eventSystemFlags = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : 0;
  var target = rootContainerElement; 
  ...
  addTrappedEventListener(target, domEventName, eventSystemFlags, isCapturePhaseListener);
}

function addTrappedEventListener(targetContainer, domEventName, eventSystemFlags, isCapturePhaseListener, isDeferredListenerForLegacyFBSupport) {
  // 根据事件名称,创建 dispatchEvent 方法
  var listener = createEventListenerWrapperWithPriority(targetContainer, domEventName, eventSystemFlags); 
  var unsubscribeListener; // When legacyFBSupport is enabled, it's for when we
  if (isCapturePhaseListener) { // 捕获事件注册
    unsubscribeListener = addEventCaptureListener(targetContainer, domEventName, listener);
  } else { // 冒泡事件注册
    unsubscribeListener = addEventBubbleListener(targetContainer, domEventName, listener);
  }
}

function addEventBubbleListener(target, eventType, listener) {
  target.addEventListener(eventType, listener, false);
  return listener;
}

function addEventCaptureListener(target, eventType, listener) {
  target.addEventListener(eventType, listener, true);
  return listener;
}

上面值得注意的一点是创建 listener,这个函数就是绑定给 contianer 的事件处理函数,会根据用户触发事件类型优先级,使用不同的 dispatchEvent。

如 click 事件会使用 dispatchDiscreteEvent,正如在 debugger Call Stack 看到的最顶层函数。

// react-dom/src/events/ReactDOMEventListener.js
function createEventListenerWrapperWithPriority(targetContainer, domEventName, eventSystemFlags) {
  var eventPriority = getEventPriorityForPluginSystem(domEventName);
  var listenerWrapper;

  switch (eventPriority) {
    case DiscreteEvent:
      listenerWrapper = dispatchDiscreteEvent; // click 事件的处理
      break;

    case UserBlockingEvent:
      listenerWrapper = dispatchUserBlockingUpdate;
      break;

    case ContinuousEvent:
    default:
      listenerWrapper = dispatchEvent;
      break;
  }

  return listenerWrapper.bind(null, domEventName, eventSystemFlags, targetContainer);
}

其实对于本例,上述事件的初始化可以简单概括为:

container.addEventListener('click', dispatchDiscreteEvent, false);

四、debugger Call Stack 分析

虽然从图中我们看到 Call Stack 函数调用链路很长,不过多数是和并发及优先级调度相关,抛开这些,我们提炼几个重要的函数来看看。

dispatchDiscreteEvent 是注册在 container 容器节点上的事件监听函数,核心是调用 dispatchEvent

function dispatchDiscreteEvent(domEventName, eventSystemFlags, container, nativeEvent) {
  // 核心是执行 dispatchEvent
  dispatchEvent(dispatchEvent, domEventName, eventSystemFlags, container, nativeEvent);
}

function dispatchEvent(domEventName, eventSystemFlags, targetContainer, nativeEvent) {
  // 根据事件获取 DOM 元素
  const nativeEventTarget = getEventTarget(nativeEvent); // nativeEvent.target
  // 根据 DOM 元素获取 Fiber 节点
  const targetInst = getClosestInstanceFromNode(nativeEventTarget);
  // 核心是执行 dispatchEventsForPlugins
  dispatchEventsForPlugins(domEventName, eventSystemFlags, nativeEvent, targetInst);
}

React 需要模拟事件冒泡和捕获机制,就需要收集 DOM 树链路上的所有元素事件回调;

事件回调被存储在 FiberNode 节点上,这里需要先通过 nativeEvent.target 拿到当前 FiberNode,后续将通过它向上查找事件链路。

下面是根据 DOM 获取 FiberNode 逻辑:

const randomKey = Math.random().toString(36).slice(2);
const internalInstanceKey = '__reactFiber$' + randomKey;

function getClosestInstanceFromNode(targetNode) {
  // 每个 DOM 节点上会在 internalInstanceKey 属性上记录对应的 Fiber 节点。
  let targetInst = targetNode[internalInstanceKey];
  if (targetInst) {
    return targetInst;
  }
  ...
}

另外扩展一下,DOM 进行 Fiber 属性标记的时机在 render/completeWork「render 归阶段」创建 DOM 实例时:

function createInstance(type, props, rootContainerInstance, hostContext, internalInstanceHandle) {
  var domElement = createElement(type, props, rootContainerInstance, parentNamespace);
  precacheFiberNode(internalInstanceHandle, domElement); // internalInstanceHandle --> FiberNode
  ...
}
function precacheFiberNode(hostInst, node) {
  node[internalInstanceKey] = hostInst;
}

有了 FiberNode,将进入 dispatchEventsForPlugins 处理:合成事件、收集事件传播,以及事件回调的执行,所以后面内容的分析都集中在这里:

// targetInst 是目标元素的 Fiber 节点,后续查找事件链路会使用到 FiberNode
function dispatchEventsForPlugins(domEventName, eventSystemFlags, nativeEvent, targetInst, targetContainer) {
  const nativeEventTarget = getEventTarget(nativeEvent);
  const dispatchQueue: DispatchQueue = [];
  // 事件对象的合成,收集事件到执行路径上
  extractEvents(
    dispatchQueue,
    domEventName, // click
    targetInst, // FiberNode
    nativeEvent, // Event
    nativeEventTarget, // Target
    eventSystemFlags,
    targetContainer,
  );
  // 执行收集到的组件中真正的事件
  processDispatchQueue(dispatchQueue, eventSystemFlags);
}

五、合成事件

SyntheticEvent 合成事件类似于原生 event 对象,是对浏览器原生事件对象的一层封装。

它抹平了不同浏览器在事件对象间的差异,同时拥有和浏览器原生事件相同的API,如 stopPropagation() 和 preventDefault()。

extractEvents 中包含了创建「合成事件」以及收集「事件链路」,我们这里重点看下合成事件的处理。

// react-dom/src/events/plugins/SimpleEventPlugin.js
function extractEvents(dispatchQueue, domEventName, targetInst, nativeEvent, nativeEventTarget, eventSystemFlags, targetContainer) {
  const reactName = topLevelEventsToReactNames.get(domEventName); // 获取完整事件名称
  // 根据事件名称,获取对应的合成事件构造实例
  let SyntheticEventCtor = SyntheticEvent;
  switch (domEventName) {
    case 'click':
    case 'dblclick':
      SyntheticEventCtor = SyntheticMouseEvent;
      break;
    ...
  }
  const inCapturePhase = (eventSystemFlags & IS_CAPTURE_PHASE) !== 0;
  const accumulateTargetOnly = !inCapturePhase && domEventName === 'scroll';

  // 1、收集 事件传播
  const listeners = accumulateSinglePhaseListeners(
    targetInst,
    reactName,
    nativeEvent.type,
    inCapturePhase,
    accumulateTargetOnly,
  );
  // 2、创建合成事件
  if (listeners.length > 0) {
    const event = new SyntheticEventCtor(
      reactName,
      reactEventType,
      null,
      nativeEvent,
      nativeEventTarget,
    );
    // 3、加入队列
    dispatchQueue.push({ event, listeners });
  }
}

在这里会根据事件类型,使用不同的合成事件实例,不论是哪一类合成事件,都具备 SyntheticEvent 基础结构,这也是合成事件的核心:

function functionThatReturnsTrue() {
  return true;
}
function functionThatReturnsFalse() {
  return false;
}

// react-dom/src/events/SyntheticEvent.js
function createSyntheticEvent(Interface: EventInterfaceType) {
  function SyntheticBaseEvent(
    reactName: string | null,
    reactEventType: string,
    targetInst: Fiber,
    nativeEvent: {[propName: string]: mixed},
    nativeEventTarget: null | EventTarget,
  ) {
    this._reactName = reactName;
    this._targetInst = targetInst;
    this.type = reactEventType;
    this.nativeEvent = nativeEvent;
    this.target = nativeEventTarget;
    this.currentTarget = null;

    for (const propName in Interface) {
      if (!Interface.hasOwnProperty(propName)) {
        continue;
      }
      const normalize = Interface[propName];
      if (normalize) {
        this[propName] = normalize(nativeEvent);
      } else {
        this[propName] = nativeEvent[propName];
      }
    }

    const defaultPrevented =
      nativeEvent.defaultPrevented != null
        ? nativeEvent.defaultPrevented
        : nativeEvent.returnValue === false;
    if (defaultPrevented) {
      this.isDefaultPrevented = functionThatReturnsTrue;
    } else {
      this.isDefaultPrevented = functionThatReturnsFalse;
    }
    this.isPropagationStopped = functionThatReturnsFalse;
    return this;
  }

  Object.assign(SyntheticBaseEvent.prototype, {
    preventDefault: function() {
      this.defaultPrevented = true;
      const event = this.nativeEvent;
      if (!event) returnif (event.preventDefault) {
        event.preventDefault();
      } else if (typeof event.returnValue !== 'unknown') {
        event.returnValue = false;
      }
      this.isDefaultPrevented = functionThatReturnsTrue;
    },

    stopPropagation: function() {
      const event = this.nativeEvent;
      if (!event) returnif (event.stopPropagation) {
        event.stopPropagation();
      } else if (typeof event.cancelBubble !== 'unknown') {
        event.cancelBubble = true;
      }
      this.isPropagationStopped = functionThatReturnsTrue; // return true
    },

    persist: function() {},

    isPersistent: functionThatReturnsTrue,
  });
  return SyntheticBaseEvent;
}

在合成事件对象中实现了 preventDefaultstopPropagation 两个方法,其内部抹平了不同浏览器环境下的兼容问题。

六、收集事件链路

我们回顾一下浏览器事件冒泡机制:从当前元素开始,依次向上执行每个上级元素对应事件的回调。

React 为了实现这一机制,就需要去收集事件回调,基于底层 Fiber 架构,元素的事件回调都保存在了 FiberNode 中,React 会从当前 FiberNode 到根 FiberNode 收集所有注册该事件的回调函数。

所以在 extractEvents 的另一个工作是收集 FiberNode 上的事件链路回调,将其存储在 dispatchQueue 之中,以此来实现事件传播机制。

// react-dom/src/events/DOMPluginEventSystem.js
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;

  while (instance !== null) {
    const { stateNode, tag } = instance;
    // 在 DOM Fiber 上收集 onClick 事件
    if (tag === HostComponent && stateNode !== null) {
      lastHostComponent = stateNode;

      if (reactEventName !== null) {
        const listener = getListener(instance, reactEventName);
        if (listener != null) {
          listeners.push(
            createDispatchListener(instance, listener, lastHostComponent),
          );
        }
      }
    }

    if (accumulateTargetOnly) break;
    instance = instance.return;
  }
  return listeners;
}

// react-dom/src/events/getListener.js
export default function getListener(inst: Fiber, registrationName: string): Function | null {
  const stateNode = inst.stateNode;
  // props 保存在了 DOM 节点上
  const props = getFiberCurrentPropsFromNode(stateNode);
  const listener = props[registrationName];
  return listener;
}

var randomKey = Math.random().toString(36).slice(2);
var internalPropsKey = '__reactProps$' + randomKey;
function getFiberCurrentPropsFromNode(node) {
  return node[internalPropsKey] || null;
}

function createDispatchListener(instance, listener, currentTarget) {
  return {
    instance: instance,
    listener: listener,
    currentTarget: currentTarget
  };
}

七、事件执行

通过 extractEvents,会将收集到的事件回调 list 存储在 dispatchQueue 之中,接下里会根据区分「捕获」和「冒泡」采用不同方式执行回调:

在本例中使用的 onClick 是冒泡阶段被触发的事件。React 提供了类似于 addEventListener 第三参数,可支持「冒泡」和「捕获」两种方式。

如需注册捕获阶段的事件处理函数,则应为事件名添加 Capture。例如,处理捕获阶段的点击事件请使用 onClickCapture,而不是 onClick

// 执行事件(包含捕获和冒泡阶段处理)
function processDispatchQueue(dispatchQueue, eventSystemFlags) {
  const inCapturePhase = (eventSystemFlags & IS_CAPTURE_PHASE) !== 0;
  for (let i = 0; i < dispatchQueue.length; i++) {
    const { event, listeners } = dispatchQueue[i];
    processDispatchQueueItemsInOrder(event, listeners, inCapturePhase);
  }
}

function processDispatchQueueItemsInOrder(event, dispatchListeners, inCapturePhase) {
  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, listener, currentTarget) {
  var type = event.type || 'unknown-event';
  event.currentTarget = currentTarget;
  listener(event); // listener 是传给 onClick 的真正事件回调,传入合成事件对象,执行回调
  event.currentTarget = null;
}

若我们的 Demo 改造成如下结构,便于你理解上面的逻辑:

function App() {
  return (
    <div className="App" onClickCapture={() => console.log('div capture')} onClick={() => console.log('div bubble')}>
      <span onClickCapture={() => console.log('span capture')} onClick={() => console.log('span bubble')}>点击</span>
    </div>
  )
}

回到页面触发点击事件,控制台输出如下:

div capture
span capture
span bubble
div bubble

八、总结事件传播机制

整个事件传播机制可以概括为:

  1. 在根节点绑定事件类型对应的事件回调,所有子孙节点触发该类事件最终都会委托给「根节点的事件回调」处理;
  2. 寻找触发事件的 DOM 节点,找到其对应的 FiberNode(节点上保存了事件回调);
  3. 收集从当前 FiberNode 到根 FiberNode 之间所有注册的「该事件对应回调」;
  4. 对于捕获事件(事件名中存在 Capture),反向遍历并执行一遍所有收集的回调(模拟捕获阶段的实现);
  5. 对于冒泡事件,正向遍历并执行一遍所有收集的回调(模拟冒泡阶段的实现)。

另外,这里有一篇卡颂的 60行代码实现React的事件系统,文章简明通俗易懂,感兴趣的同学可以阅读查看。

小技巧分享

假如我们现在遇到这样一个场景:

线上环境某个 Button 按钮点击触发事件后没有任何反应,或者没有按照预期逻辑去执行。这时候我们会想到,通过浏览器控制台查看该元素所绑定的 onClick 事件回调,找到相关源码并在事件回调中加入 debugger。

但生产环境下代码都是压缩混淆过的,再加上 React 事件机制的链路很长,很难通过浏览器控制台 - Element - Event Listeners 找到元素绑定的真正事件回调。

那么生产环境下如何找到视图上 DOM 元素真正的事件回调?

其实在上面的分析中我们会发现,真正的事件回调都是由 executeDispatch 触发的,而在这个函数中会有一个默认 type unknown-event

function executeDispatch(event, listener, currentTarget) {
  var type = event.type || 'unknown-event';
  event.currentTarget = currentTarget;
  listener(event); // listener 是传给 onClick 的真正事件回调,传入合成事件对象,执行回调
  event.currentTarget = null;
}

由于字符串值不会被打包压缩,因此我们只需在 Sources 面板中找到 react-dom 资源打包所在的文件,查找关键字 unknown-event 来定位到 executeDispatch 函数的位置,通过在这里打断点,很方便得便能找到元素真实绑定的事件回调。

最后

阅读完本文,相信读者对 React 事件机制有了更进一步的了解。

借鉴:
卡颂 - 60行代码实现React的事件系统
浅谈 React17 事件机制