场景

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

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

注:React 版本 16.13.0

问题

点击 Popconfirm 确认框的任何地方也出现了跳转现象。
1-跳转.gif
这里 Popconfirm 是绑定在 body 中,并没有和 table 放在一起。页面渲染视图中,Table tr td中没有嵌套渲染Popconfirm,但是点击Popconfirm触发了Table onRow 的click 事件。
image.png
image.png
这里经测试,发现是先触发了目标元素的click,然后触发了onRow click。

  1. render: (text) => (
  2. <div>
  3. <Popconfirm
  4. //....
  5. getPopupContainer={() => document.body}
  6. >
  7. <div onClick={() => {}}>
  8. <Tooltip title={text} theme="dark">
  9. <span>{text}</span>
  10. </Tooltip>
  11. </div>
  12. </Popconfirm>
  13. ,
  14. </div>
  15. )
  1. onRow={(record) => {
  2. return {
  3. onClick: (event) => {
  4. window.open("www.baidu.com");
  5. }
  6. };
  7. }}

解决方案

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

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

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

React 合成事件

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

React 使用合成事件的好处有以下几点:

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

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

包装

把原生 DOM 事件包装成合成事件。
image.png
image.png
Popconfirm、<button> 的Dom上都没有绑定我们书写的事件监听器。而是noopnoop就指向一个空函数。
image.png
然而在document却绑定了本该属于目标元素的事件。也就如上面所说,在 React 中(17版本之前,16版本并不是绑定在document上),我们在代码中所写的事件,最终都绑定在了 document 上。
image.png

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

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

事件触发处理函数 dispatchEvent

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

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

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

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

image.png

如何获取 dom 元素的 fiber 对象

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

元素节点层层关联

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

插件事件系统的调度事件

接着往下,调用dispatchEventForLegacyPluginEventSystem,dispatchEventForLegacyPluginEventSystem函数字面理解就是插件事件系统的调度事件,其实字面理解和本质也差不多,就是事件系统的调度事件。从这个函数就开始 legacy 模式下事件处理系统与批量更新了。
image.png
dispatchEventForLegacyPluginEventSystem函数中,先在 React 事件池中取出最后一个,对属性进行赋值。
image.png
然后执行批量更新,batchedEventUpdates(v16)为批量更新的主要函数。通过变量isBatchingEventUpdates来控制是否批量进行更新。
image.png

_事件处理的主要函数 _handleTopLevel

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

  1. // 主函数
  2. function handleTopLevel(bookKeeping) {
  3. // ...
  4. for (var i = 0; i < bookKeeping.ancestors.length; i++) {
  5. // ...
  6. runExtractedPluginEventsInBatch(topLevelType, targetInst, nativeEvent, eventTarget, eventSystemFlags);
  7. }
  8. }
  9. function runExtractedPluginEventsInBatch(topLevelType, targetInst, nativeEvent, nativeEventTarget, eventSystemFlags) {
  10. var events = extractPluginEvents(topLevelType, targetInst, nativeEvent, nativeEventTarget, eventSystemFlags);
  11. runEventsInBatch(events);
  12. }
  13. // 找到对应的事件插件,形成对应的合成event,形成事件执行队列
  14. function extractPluginEvents(topLevelType, targetInst, nativeEvent, nativeEventTarget, eventSystemFlags) {
  15. var events = null;
  16. for (var i = 0; i < plugins.length; i++) {
  17. var possiblePlugin = plugins[i];
  18. if (possiblePlugin) {
  19. /* 找到对应的事件插件,形成对应的合成event,形成事件执行队列 */
  20. var extractedEvents = possiblePlugin.extractEvents(topLevelType, targetInst, nativeEvent, nativeEventTarget, eventSystemFlags);
  21. if (extractedEvents) {
  22. events = accumulateInto(events, extractedEvents);
  23. }
  24. }
  25. }
  26. return events;
  27. }
  28. // 执行事件处理函数
  29. function runEventsInBatch(events) {
  30. // ...
  31. }

extractEvents-重点、重点、重点

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

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

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

extractEvents 会产生事件源对象SyntheticEvent,下图就可以看到事件源的真面目。
image.png
在事件正式执行之前,React就将事件队列和事件源形成,并且在事件源对象上处理了对事件默认行为、事件冒泡的处理。这里为我之前的 bug 问题解决埋下了伏笔。
image.png
image.png

事件执行

当一切都准备完成,就开始进行事件的执行,事件的执行都是在函数runEventsInBatch中操作。
image.png
runEventsInBatch执行链路比较长,我们简化一下最终、最重要的执行,定位到函数executeDispatchesInOrder,这函数的功能就是将事件收集的分派进行标准/简单迭代,

  1. function executeDispatchesInOrder(event) {
  2. var dispatchListeners = event._dispatchListeners;
  3. var dispatchInstances = event._dispatchInstances;
  4. {
  5. validateEventDispatches(event);
  6. }
  7. if (Array.isArray(dispatchListeners)) {
  8. for (var i = 0; i < dispatchListeners.length; i++) {
  9. if (event.isPropagationStopped()) {
  10. break;
  11. }
  12. // 执行我们的事件处理函数
  13. executeDispatch(event, dispatchListeners[i], dispatchInstances[i]);
  14. }
  15. } else if (dispatchListeners) {
  16. executeDispatch(event, dispatchListeners, dispatchInstances);
  17. }
  18. event._dispatchListeners = null;
  19. event._dispatchInstances = null;
  20. }

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()

image.png

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

    总结

    最后总结一下,通过不同的断点调测,终于找到了开文说的bug解决办法的缘由。知其然知其所以然。也间接的浅入了React 的事件系统。下面这张图是作者写在源码中的注释,简述了事件系统。
    image.png
    在React中,事件触发的本质是对 dispatchEvent 函数的调用。模拟原生的事件的捕获和冒泡,收集事件,顺序执行。
    React 合成事件虽然承袭了事件委托的思想,但它的实现过程比传统的事件委托复杂太多。对 React 来说,事件委托主要的作用应该在于帮助 React 实现了对所有事件的中心化管控。关于 React 事件系统,就介绍到这里。

参考