一个Bug,浅入 React 合成事件

895 阅读7分钟

这是我参与8月更文挑战的第9天,活动详情查看:8月更文挑战

前言

通过一个简单的业务场景,探究 React 合成事件的底层原理。

场景

Antd Table 中嵌套使用 onRow 和 Popconfirm(绑定在 body 上)。

  • Table 中的固定一列,点击出现 Popconfirm 确认框
  • Table 点击每一行,跳转其他页面

注:React 版本 16.13.0

问题

点击 Popconfirm 确认框的任何地方也出现了跳转现象。

这里 Popconfirm 是绑定在 body 中,并没有和 table 放在一起。页面渲染视图中,Table tr td 中没有嵌套渲染 Popconfirm,但是点击 Popconfirm触发了 Table onRow 的 click 事件。 这里经测试,发现是先触发了目标元素的 click,然后触发了 onRow click。

render: (text) => (
      <div>
        <Popconfirm
          //....
          getPopupContainer={() => document.body}
        >
          <div onClick={() => {}}>
            <Tooltip title={text} theme="dark">
              <span>{text}</span>
            </Tooltip>
          </div>
        </Popconfirm>
        ,
      </div>
    )
onRow={(record) => {
  return {
    onClick: (event) => {
      window.open("www.baidu.com");
    }
  };
}}

解决方案

解决方案很简单,Popconfirm 在外层的包裹元素上,直接阻止冒泡。

onClick={(e) => e.stopPropagation()}

但是这里不仅让人遐想,两个基本没有关系的 dom ,却关联触发了,在这背后发生了什么引人猜疑,知其然知其所以然。我们抛开这个问题,看本质。 React 本身的事件系统,并不是原生的事件系统。而是采用了合成事件。我们先回顾一下 React 的合成事件。

React 合成事件

合成事件是 React 自定义的事件对象,它符合 W3C 规范,在底层抹平了不同浏览器的差异,在上层面向开发者暴露统一的、稳定的、与 DOM 原生事件相同的事件接口。开发者们由此便不必再关注烦琐的兼容性问题,可以专注于业务逻辑的开发。 React 使用合成事件的好处有以下几点:

  • React 将事件都绑定在 document 上,防止很多事件绑定在原生的 DOM 上,而造成的不可控。
  • 在底层抹平了不同浏览器的差异,在上层面向开发者暴露统一的、稳定的、与 DOM 原生事件相同的事件接口。

React 合成事件的灵感源泉来自事件委托,React 的事件系统沿袭了事件委托的思想。在 React 中,除了少数特殊的不可冒泡的事件(比如媒体类型的事件)无法被事件系统处理外,绝大部分的事件都不会被绑定在具体的元素上,而是统一被绑定在页面的 document 上。当事件在具体的 DOM 节点上被触发后,最终都会冒泡到 document 上,document 上所绑定的统一事件处理程序会将事件分发到具体的组件实例。

在分发事件之前,React 首先会对事件进行包装,把原生 DOM 事件包装成合成事件。

包装

把原生 DOM 事件包装成合成事件。

Popconfirm、button 的 Dom 上都没有绑定我们书写的事件监听器。而是 noop ,noop 就指向一个空函数。

然而在 document 却绑定了本该属于目标元素的事件。也就如上面所说,在 React 中(17 版本之前,16 版本并不是绑定在 document 上),我们在代码中所写的事件,最终都绑定在了 document 上。

事件触发一次点击事件,底层系统发生了什么?

简单理解了一下 React 的合成事件机制,我们在回过头来看看,当我们点击 Popconfirm 内任意元素时发生了什么。我们在源码中给 document 的绑定事件 dispatchDiscreteEvent 函数打上断点,来一步一步看看发生了什么。

事件触发处理函数 dispatchEvent

dispatchDiscreteEvent 函数触发之后,第一个重要的函数 dispatchEvent,React 事件注册时候,统一的监听器 dispatchEvent,也就是当我们点击按钮之后,首先执行的是 dispatchEvent 函数。

原生的 dom 元素,找到对应的 fiber

接下来执行 attemptToDispatchEvent,这个函数中会做几个比较重要的事情

  1. 根据原生事件对象 nativeEvent 找到真实的 dom 元素。
  2. 根据 dom 元素,得到对应的 fiber 对象,也就是我们点击元素对应的 fiber 对象
  3. 进入 legacy 模式的事件处理系统

如何获取 dom 元素的 fiber 对象

在获取 fiber 对象时,通过函数 getClosestInstanceFromNode ,找到当前传入的 dom 对应的最近的元素类型的 fiber 对象。React 在初始化真实 dom 的时候,用一个随机的 key internalInstanceKey 指针指向了当前 dom 对应的 fiber 对象,fiber 对象用 stateNode 指向了当前的 dom 元素。也就是 dom 和 fiber 对象它们是相互关联起来的。

元素节点层层关联

attemptToDispatchEvent 函数执行 getNearestMountedFiber 函数中会发现,tag=5 元素节点是从目标节点向上层层关联,我在操作的时候,虽然点击的是 Popconfirm 的元素(挂载 body 上),但是在冒泡的时候还是会关联上包裹在它外层的元素。

插件事件系统的调度事件

接着往下,调用 dispatchEventForLegacyPluginEventSystemdispatchEventForLegacyPluginEventSystem 函数字面理解就是插件事件系统的调度事件,其实字面理解和本质也差不多,就是事件系统的调度事件。从这个函数就开始 legacy 模式下事件处理系统与批量更新了。

dispatchEventForLegacyPluginEventSystem 函数中,先在 React 事件池中取出最后一个,对属性进行赋值。

然后执行批量更新,batchedEventUpdates(v16)为批量更新的主要函数。通过变量 isBatchingEventUpdates 来控制是否批量进行更新。

事件处理的主要函数 handleTopLevel

batchedEventUpdates 批量更新的主函数 handleTopLevel 为事件处理的主要函数,我们在代码开发中写的事件处理程序,实际执行是在 handleTopLevel(bookKeeping) 中执行的。 handleTopLevel 处理逻辑就是执行处理函数 extractEvents,比如我们 Popconfirm 的元素中的点击事件 onClick 最终走的就是 extractEvents 函数。原因就是 React 是采取事件合成,事件统一绑定,并且我们写在组件中的事件处理函数,也不是真正的执行函数 dispatchAciton,那么我们的事件对象 event 也是 React 单独合成处理的,里面单独封装了比如 stopPropagationpreventDefault 等方法,这样的好处是,我们不需要跨浏览器单独处理兼容问题,交给 React 底层统一处理。

// 主函数
function handleTopLevel(bookKeeping) {
	// ...
  for (var i = 0; i < bookKeeping.ancestors.length; i++) {
    // ...
    runExtractedPluginEventsInBatch(topLevelType, targetInst, nativeEvent, eventTarget, eventSystemFlags);
  }
}

function runExtractedPluginEventsInBatch(topLevelType, targetInst, nativeEvent, nativeEventTarget, eventSystemFlags) {
  var events = extractPluginEvents(topLevelType, targetInst, nativeEvent, nativeEventTarget, eventSystemFlags);
  runEventsInBatch(events);
}
// 找到对应的事件插件,形成对应的合成event,形成事件执行队列
function extractPluginEvents(topLevelType, targetInst, nativeEvent, nativeEventTarget, eventSystemFlags) {
  var events = null;
  for (var i = 0; i < plugins.length; i++) {
    var possiblePlugin = plugins[i];
    if (possiblePlugin) {
      /* 找到对应的事件插件,形成对应的合成event,形成事件执行队列  */
      var extractedEvents = possiblePlugin.extractEvents(topLevelType, targetInst, nativeEvent, nativeEventTarget, eventSystemFlags);
      if (extractedEvents) {
        events = accumulateInto(events, extractedEvents);
      }
    }
  }
  return events;
}
// 执行事件处理函数
function runEventsInBatch(events) {
  // ...
}

extractEvents-重点、重点、重点

handleTopLevel 的执行中,会找到找到对应的事件插件,形成对应的合成 event,形成事件执行队列,extractEvents 算是整个事件系统核心函数,当我们点击 Popconfirm 的元素时,最终走的就是 extractEvents 函数。

  1. extractEvents 会产生事件源对象,然后从事件源开始逐渐向上,查找 dom 元素类型 HostComponent 对应的 fiber,收集上面的 React 合成事件,onClick / onClickCapture
  2. dispatchListeners 收集上面的 React 合成事件。对应发生在事件捕获阶段的处理函数,逻辑是将执行函数 unshift 添加到队列的最前面。事件冒泡阶段,真正的事件处理函数,逻辑是将执行函数 push 到执行队列的最后面。
  3. 最后将函数执行队列,挂到事件对象 event 上,等待执行。从调试可以看出,最后将调度的实例挂到了 _dispatchInstances 上,调度的监听事件挂到了 _dispatchListeners 上,_dispatchListeners 上包含了捕获的处理事件和冒泡的时间处理函数。

这里其实就模拟了我们原生事件上的捕获和冒泡。简单来说,其实和我们原生的事件捕获、冒泡是一样的。只是为了可控,自己实现了事件系统。当收集模拟完事件系统之后,就是。

extractEvents 会产生事件源对象 SyntheticEvent,下图就可以看到事件源的真面目。

在事件正式执行之前,React 就将事件队列和事件源形成,并且在事件源对象上处理了对事件默认行为、事件冒泡的处理。这里为我之前的 bug 问题解决埋下了伏笔。

事件执行

当一切都准备完成,就开始进行事件的执行,事件的执行都是在函数 runEventsInBatch 中操作。

runEventsInBatch 执行链路比较长,我们简化一下最终、最重要的执行,定位到函数 executeDispatchesInOrder,这函数的功能就是将事件收集的分派进行标准/简单迭代,


function executeDispatchesInOrder(event) {
  var dispatchListeners = event._dispatchListeners;
  var dispatchInstances = event._dispatchInstances;

  {
    validateEventDispatches(event);
  }

  if (Array.isArray(dispatchListeners)) {
    for (var i = 0; i < dispatchListeners.length; i++) {
      if (event.isPropagationStopped()) {
        break;
      }
      // 执行我们的事件处理函数
      executeDispatch(event, dispatchListeners[i], dispatchInstances[i]);
    }
  } else if (dispatchListeners) {
    executeDispatch(event, dispatchListeners, dispatchInstances);
  }

  event._dispatchListeners = null;
  event._dispatchInstances = null;
}

dispatchListeners[i] 就是执行我们的事件处理函数,例如我们在开发书写的点击事件的监听处理函数。这里在处理的时候,会判断 event.isPropagationStopped(),是否已经阻止事件冒泡。如果已经组织,就不会继续触发。 React 对于阻止冒泡,就是通过 isPropagationStopped,判断是否已经阻止事件冒泡。如果我们在事件函数执行队列中,某一会函数中,调用 e.stopPropagation(),就会赋值给 isPropagationStopped=()=>true,当再执行 e.isPropagationStopped() 就会返回 true ,接下来事件处理函数,就不会执行了。 这里就明白了为什么我在 Popconfirm 在外层的包裹元素上,直接阻止冒泡 e.stopPropagation()。就不会触发 table 了 onRow click。

React17 事件机制

这里随带也提一下 React 17 的事件机制,在 React 17 中,事件机制有三个比较大的改动:

  1. React 将不再向 document 附加事件处理器。而会将事件处理器附加到渲染 React 树的根 DOM 容器中。在 React 16 或更早版本中,React 会对大多数事件执行 document.addEventListener()。React 17 将会在底层调用 rootNode.addEventListener()。

  1. React 17 中终于支持了原生捕获事件的支持, 对齐了浏览器原生标准。同时 onScroll 事件不再进行事件冒泡。onFocus 和 onBlur 使用原生 focusin, focusout 合成。
  2. 取消事件池 React 17 取消事件池复用。

总结

最后总结一下,通过不同的断点调测,终于找到了开文说的 bug 解决办法的缘由。知其然知其所以然。也间接的浅入了 React 的事件系统。下面这张图是作者写在源码中的注释,简述了事件系统。

在 React 中,事件触发的本质是对 dispatchEvent 函数的调用。模拟原生的事件的捕获和冒泡,收集事件,顺序执行。

React 合成事件虽然承袭了事件委托的思想,但它的实现过程比传统的事件委托复杂太多。对 React 来说,事件委托主要的作用应该在于帮助 React 实现了对所有事件的中心化管控。关于 React 事件系统,就介绍到这里。

如果你觉得写的不错,帮忙点个赞吧。

参考