React 事件代理与 stopImmediatePropagation
youngwind opened this issue · 20 comments
前言
我们都知道:“React 组件绑定事件本质上是代理到 document 上”,今天,我们来探索其精微之处。
stopPropagation VS stopImmediatePropagation
考虑这么一种情况:document 上绑定了 3 个事件,有什么办法能够做到 → 在触发了第 1 个事件之后,不再触发第 2、3 个事件呢?我的第一想法是调用 e.stopPropagation
。但是,实际验证是不行的,如下图所示。
为什么呢?我重新翻开红宝书,第 356 页,发现除了 e.stopPropagation
外,还有一个类似的方法,叫做e.stopImmediatePropagation
,它们两个的区别是:
- stopPropagation 能够阻止事件的进一步捕获或者冒泡;
- 假设事件流已经被某个元素捕获(或者冒泡到某个元素),那么便会触发此元素上绑定的事件。如果绑定的事件不止一个,则依次触发。假如想中断这种依次触发,可以调用
e.stopImmediatePropagation
。
更多关于 stopImmediatePropagation 的定义,可自行参考 MDN。
React 事件代理
虽然很多资料都说 React 的事件是会被代理到 document 上,但是我翻遍了官网,也没有找到相应的说明。那么,有什么办法能够证明它吗?我想到了一个方法 → 通过 Chrome 浏览器的 Event Listeners 面板查看元素的绑定事件,具体的使用方法请参考官网文档。
从图中我们可以看到:
- #child 元素绑定了两个点击事件,一个是通过 React 绑定的,一个是通过 addEventListener 绑定的。
- 通过 addEventListener 绑定的事件是真的绑定到 #child 元素上。
- 通过 React 绑定的事件,其实是代理绑定到 document 上。
React 模拟 DOM 事件冒泡机制
观察下面这个例子:#child 和 #parent 都绑定了一个点击事件。
由图中可以看出:点击 #child 的同时,也触发了 #parent 的点击事件,看起来“很像” DOM 的事件冒泡机制。然而,实际原理并非如此,因为按照 React 的事件代理,#child 和 #parent 绑定的事件本来就是代理到 document 上的。也就是说,只有当事件流冒泡到 document 上时,才会依次触发 document 上绑定的两个事件。
到此为止,我以为我终于搞明白这块了,后来我发现我还是错了。如果说 #child 和 #parent 的事件都代理到 document 上的话,那么在 Event Listeners 面板中,我们应该能看到 2 个绑定在 document 上的事件,但实际上只有 1 个,如下图所示。
因此,我们可以得出结论:并非 #child 和 #parent 的事件分别代理到 document 上,而是 React 在 document 上绑定了一个 dispatchEvent 函数(事件),在执行 dispatchEvent 的过程中,其内部会依次执行 #child 和 #parent 上绑定的事件。请注意,虽然 dispatchEvent 和代理到 document 上这两种方式的表现结果一样,但是其本质是有很大差别的,后边我们结合到 stopImmediatePropagation 的时候便会讲到。
那么这个 dispatchEvent 函数又是如何做到依次触发 #child 和 #parent 的事件的呢?我无力研究 React 这部分的源码,只好自己猜想了一下,其伪代码可能是这样子:
function dispatchEvent(event) {
let target = event.target;
target.click && target.click(); // 触发点击元素的事件
while (target.parentNode) { // 沿 DOM 向上回溯,遍历父节点,触发其 click 事件
target.parentNode.click && target.parentNode.click();
target = target.parentNode;
}
}
这应该便是 React 模拟 DOM 事件冒泡的大致原理。
React 禁止事件冒泡
既然有“事件冒泡”,就得有相应的禁止它的方法,这一点 React 的官网中便有提到:通过 React 绑定的事件,其回调函数中的 event 对象,是经过 React 合成的 SyntheticEvent,与原生的 DOM 事件的 event 不是一回事。准确地说,在 React 中,e.nativeEvent 才是原生 DOM 事件的那个 event,虽然 React 的合成事件对象也同样实现了 stopPropagation 接口。
因此,在 React 中,想要阻止“事件冒泡”(再强调一次,React 只是模拟事件冒泡,并非真正的 DOM 事件冒泡),只需要在回调函数中调用 e.stopPropagation
。请注意,这时候的 e.stopPropagation
非原生事件对象的 stopPropagation。
以上这些都是官网中已经有的,那本文又有什么新意呢?请看下面的例子:#child、#parent 和 document 上都绑定了事件,如何做到只触发 #child 上的事件?
我们来尝试解释一下上图中的现象:
- 事件流首先进入到 #child ,然后触发直接绑定在 #child 上的事件;
- 事件流沿着 DOM 结构向上冒泡到 document,触发 React 绑定的 dispatchEvent 函数,从而调用了 #child 子元素上绑定的 clickChild 方法。
- 在 clickChild 方法的最后,我调用了
e.stopPropagation
,成功地阻止了 React 模拟的事件冒泡,因此,成功地没有触发 #parent 上的事件。 - 然后,最后出现了问题,还是触发了 document 上直接绑定的事件。我想要的是:”点击 #child ,只触发 #child 上的事件,不要触发任何其他元素的事件,包括 document“,我应该怎么做呢? → 答案是:”调用
e.nativeEvent.stopImmediatePropagation
“
上述过程用图解的方式来分析,我们能理解得清楚一些。
React 合成事件对象的e.stopPropagation
,只能阻止 React 模拟的事件冒泡,并不能阻止真实的 DOM 事件冒泡,更加不能阻止已经触发元素的多个事件的依次执行。在这种情况下,只有原生事件对象的 stopImmediatePropagation
能做到。
你可能会说:”既然 React 在合成事件对象中封装了 stopPropagation,为什么不把
stopImmediatePropagation 也一并封装了呢?“
我的猜测是:”因为在 React 中不允许给同一个组件绑定多个相同类型的事件,如果非要重复绑定,那么后绑定的会覆盖前绑定的,这是它的设计思路。在这种设计思路下,不会存在某个组件有多个同类型的事件会依次触发,自然便不需要 stopImmediatePropagation 了。
总结
对于 React 的合成事件对象 e 来说:
- e.stopPropagation → 用来阻止 React 模拟的事件冒泡
- e.stopImmediatePropagation → 没有这个函数
- e.nativeEvent.stopPropagation → 原生事件对象的用于阻止 DOM 事件的进一步捕获或者冒泡
- e.nativeEvent.stopImmediatePropagation → 原生事件对象的用于阻止 DOM 事件的进一步捕获或者冒泡,且该元素的后续绑定的相同事件类型的事件也被一并阻止。
最后,本文对应的 demo 请参考这里:https://jsfiddle.net/youngwind/91es1dbx/5/
很久以前我也写过一篇关于此主题的博客 #9 ,不过现在看来,那时候的思考很不成熟,也一并列在这儿以作参考吧。
谢谢作者~
谢谢作者~
写的太好了!!
@youngwind 或者 在父元素上用target判断一下,可行不优雅
@youngwind 作者你好,我又认真的读了你的文章,做了测试,发现是有问题的:
- 其实原生事件的执行时间总是先于合成事件,无论合成事件是不是在捕获阶段绑定
- 所以上述用例的执行结果: 直接绑定在Child上的事件-》直接绑定在document上的事件-》点击子元素-》点击父元素
- 所以你上面图的描述其实是错误的
- 结果是对的,但是 e.nativeEvent.stopImmediatePropagation()其实只能在document 上才有用,换成
document.getElementById('parent')都是不可以的
大赞!!!
结论:
1、e.stopPropagation 可以用来禁用 React 模拟事件;
2、e.nativeEvent.stopImmediatePropagation 只能用来禁用直接绑定在 document 上的事件;
3、对 2 的补充,试想一下,child 元素通过 React 直接绑定事件会被模拟到 document 上,parent 元素通过原生 addEventListener 绑定,通过冒泡原来应该 parent 先被触发,所以无法在 child 元素内禁用 parent 元素绑定的事件(当然,实际开发中,应该不会去 child 通过 React 绑定事件,而 parent 却不使用 React 绑定事件)。
感谢作者,同类分析里写的简单易懂的一个
写得不错
测试发现document原生绑定事件在合成的事件执行之后执行,原生事件(document元素事件)-》点击子元素-》点击父元素-》直接绑定在document上的事件。链接地址
https://codesandbox.io/s/q33v4vw866
补充一下在react页面里面打印了一下绑定在document上的click事件,发现第一个就是react绑定的事件,然后才是我在Didmount上绑定的事件
@youngwind 作者你好,我又认真的读了你的文章,做了测试,发现是有问题的:
- 其实原生事件的执行时间总是先于合成事件,无论合成事件是不是在捕获阶段绑定
- 所以上述用例的执行结果: 直接绑定在Child上的事件-》直接绑定在document上的事件-》点击子元素-》点击父元素
- 所以你上面图的描述其实是错误的
- 结果是对的,但是 e.nativeEvent.stopImmediatePropagation()其实只能在document 上才有用,换成
document.getElementById('parent')都是不可以的
根据我的测试结果,和你的结论有点出入,一起讨论下:
- 原生事件执行总是先于合成事件?
你说的这种情况只在给 非document,window元素 ` 添加原生事件时成立,当给document添加原生事件时,还取决于添加原生事件的时机:
在render之前添加原生事件,那么原生事件先于合成事件绑定,触发时先执行原生事件。
在render之后比如didMount
中,那么合成事件先于原生事件绑定,触发时先执行合成事件。
- 作者的结论是对的。
因为在作者在 didMount
中添加的事件,先执行原生的直接绑定在Child事件
,然后根据在 didMount
中添加事件,所以合成事件先于原生事件执行,执行 点击子元素
, 阻止冒泡所以 点击父元素
不执行。最后会执行原生添加的 直接绑定在Document事件
。
- e.nativeEvent.stopImmediatePropagation()其实只能在document 上才有用?
我测试的结果是,这个api对其它元素也有效。可能我理解错你的意思了?
非常非常非常有用,感谢!!!
写的很清楚。但是有个地方有问题:
function dispatchEvent(event) { let target = event.target; target.click && target.click(); // 触发点击元素的事件 while (target.parentNode) { // 沿 DOM 向上回溯,遍历父节点,触发其 click 事件 target.parentNode.click && target.parentNode.click(); target = target.parentNode; } }
节点上并没有绑定事件,不存在click函数。具体好像是react会在每个节点上生成一个唯一的id,把id与click事件的函数作为map的数据结构的形式存在一个全局对象中。调用 dispatchEvent的时候,会找到当前节点的id找到click函数。
太赞了!!!