一个Bug,浅入 React 合成事件

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

网络异常,图片无法展示
|


前言


通过一个简单的业务场景,探究 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 事件系统,就介绍到这里。

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


参考


目录
相关文章
|
2月前
|
移动开发 前端开发 JavaScript
React 表单与事件
10月更文挑战第10天
50 1
|
1月前
|
前端开发 JavaScript 开发者
React 事件处理机制详解
【10月更文挑战第23天】本文介绍了 React 的事件处理机制,包括事件绑定、事件对象、常见问题及解决方案。通过基础概念和代码示例,详细讲解了如何处理 `this` 绑定、性能优化、阻止默认行为和事件委托等问题,帮助开发者编写高效、可维护的 React 应用程序。
123 4
|
2月前
|
前端开发 JavaScript IDE
React 事件处理
10月更文挑战第8天
26 1
|
2月前
|
前端开发 JavaScript
一文详解React事件中this指向,面试必备
一文详解React事件中this指向,面试必备
63 0
|
3月前
|
前端开发 JavaScript
react学习(19)事件处理
react学习(19)事件处理
|
3月前
|
前端开发 JavaScript
React的事件与原生事件的执行顺序?
React的事件与原生事件的执行顺序?
|
4月前
|
前端开发 JavaScript Java
React 中的合成事件
【8月更文挑战第30天】
63 6
|
4月前
|
前端开发 JavaScript
React 中的事件是什么?
【8月更文挑战第30天】
86 5
|
4月前
|
存储 前端开发 JavaScript
React中的事件处理(八)
【8月更文挑战第15天】React中的事件处理
38 1
|
4月前
|
前端开发 JavaScript 开发者
如何在 React 中处理事件?
【8月更文挑战第31天】
58 0