场景
Antd Table 中嵌套使用 onRow 和 Popconfirm(绑定在 body 上)。
- Table 中的固定一列,点击出现 Popconfirm 确认框
- Table点击每一行,跳转其他页面
问题
点击 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,这个函数中会做几个比较重要的事情
- 根据原生事件对象nativeEvent找到真实的dom元素。
- 根据dom元素,得到对应的fiber对象,也就是我们点击元素对应的fiber对象
- 进入legacy模式的事件处理系统
如何获取 dom 元素的 fiber 对象
在获取fiber对象时,通过函数getClosestInstanceFromNode,找到当前传入的 dom
对应的最近的元素类型的 fiber
对象。React
在初始化真实 dom
的时候,用一个随机的 key internalInstanceKey
指针指向了当前dom
对应的fiber
对象,fiber
对象用stateNode
指向了当前的dom
元素。也就是dom和fiber对象它们是相互关联起来的。
元素节点层层关联
attemptToDispatchEvent函数执行getNearestMountedFiber函数中会发现,tag=5元素节点是从目标节点向上层层关联,我在操作的时候,虽然点击的是Popconfirm的元素(挂载body上),但是在冒泡的时候还是会关联上包裹在它外层的元素。
插件事件系统的调度事件
接着往下,调用dispatchEventForLegacyPluginEventSystem,dispatchEventForLegacyPluginEventSystem函数字面理解就是插件事件系统的调度事件,其实字面理解和本质也差不多,就是事件系统的调度事件。从这个函数就开始 legacy 模式下事件处理系统与批量更新了。
dispatchEventForLegacyPluginEventSystem函数中,先在 React 事件池中取出最后一个,对属性进行赋值。
然后执行批量更新,batchedEventUpdates(v16)
为批量更新的主要函数。通过变量isBatchingEventUpdates来控制是否批量进行更新。
_事件处理的主要函数 _handleTopLevel
batchedEventUpdates
批量更新的主函数handleTopLevel为事件处理的主要函数,我们在代码开发中写的事件处理程序,实际执行是在handleTopLevel(bookKeeping)
中执行的。handleTopLevel
处理逻辑就是执行处理函数extractEvents
,比如我们Popconfirm的元素中的点击事件 onClick 最终走的就是 extractEvents
函数。原因就是 React 是采取事件合成,事件统一绑定,并且我们写在组件中的事件处理函数,也不是真正的执行函数dispatchAciton
,那么我们的事件对象 event
也是React单独合成处理的,里面单独封装了比如 stopPropagation
和preventDefault
等方法,这样的好处是,我们不需要跨浏览器单独处理兼容问题,交给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
函数。
extractEvents
会产生事件源对象,然后_从事件源开始逐渐向上,查找dom元素类型HostComponent对应的fiber,收集上面的React合成事件,_onClick / onClickCapture。- dispatchListeners收集上面的
React
合成事件。对应发生在事件捕获阶段的处理函数,逻辑是将执行函数unshift添加到队列的最前面。事件冒泡阶段,真正的事件处理函数,逻辑是将执行函数push到执行队列的最后面。 - 最后将函数执行队列,挂到事件对象event上,等待执行。从调试可以看出,最后将调度的实例挂到了_dispatchInstances上,调度的监听事件挂到了_dispatchListeners上,_dispatchListeners上包含了捕获的处理事件和冒泡的时间处理函数。
这里其实就模拟了我们原生事件上的捕获和冒泡。简单来说,其实和我们原生的事件捕获、冒泡是一样的。只是React为了可控,自己实现了事件系统。当收集模拟完事件系统之后,就是事件执行。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 中,事件机制有三个比较大的改动:
- React 将不再向
document
附加事件处理器。而会将事件处理器附加到渲染 React 树的根 DOM 容器中。在 React 16 或更早版本中,React 会对大多数事件执行document.addEventListener()
。React 17 将会在底层调用rootNode.addEventListener()
。
React 17
中终于支持了原生捕获事件的支持, 对齐了浏览器原生标准。同时onScroll
事件不再进行事件冒泡。onFocus
和onBlur
使用原生focusin
,focusout
合成。- 取消事件池
React 17
取消事件池复用。
总结
最后总结一下,通过不同的断点调测,终于找到了开文说的bug解决办法的缘由。知其然知其所以然。也间接的浅入了React 的事件系统。下面这张图是作者写在源码中的注释,简述了事件系统。
在React中,事件触发的本质是对 dispatchEvent 函数的调用。模拟原生的事件的捕获和冒泡,收集事件,顺序执行。
React 合成事件虽然承袭了事件委托的思想,但它的实现过程比传统的事件委托复杂太多。对 React 来说,事件委托主要的作用应该在于帮助 React 实现了对所有事件的中心化管控。关于 React 事件系统,就介绍到这里。