Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

React 事件代理与 stopImmediatePropagation #107

Open
youngwind opened this issue Jul 17, 2017 · 20 comments
Open

React 事件代理与 stopImmediatePropagation #107

youngwind opened this issue Jul 17, 2017 · 20 comments
Labels

Comments

@youngwind
Copy link
Owner

youngwind commented Jul 17, 2017

前言

我们都知道:“React 组件绑定事件本质上是代理到 document 上”,今天,我们来探索其精微之处。

stopPropagation VS stopImmediatePropagation

考虑这么一种情况:document 上绑定了 3 个事件,有什么办法能够做到 → 在触发了第 1 个事件之后,不再触发第 2、3 个事件呢?我的第一想法是调用 e.stopPropagation。但是,实际验证是不行的,如下图所示。
demo1
为什么呢?我重新翻开红宝书,第 356 页,发现除了 e.stopPropagation外,还有一个类似的方法,叫做e.stopImmediatePropagation,它们两个的区别是:

  1. stopPropagation 能够阻止事件的进一步捕获或者冒泡;
  2. 假设事件流已经被某个元素捕获(或者冒泡到某个元素),那么便会触发此元素上绑定的事件。如果绑定的事件不止一个,则依次触发。假如想中断这种依次触发,可以调用 e.stopImmediatePropagation

更多关于 stopImmediatePropagation 的定义,可自行参考 MDN

附上面例子的事件流图解
image1

React 事件代理

虽然很多资料都说 React 的事件是会被代理到 document 上,但是我翻遍了官网,也没有找到相应的说明。那么,有什么办法能够证明它吗?我想到了一个方法 → 通过 Chrome 浏览器的 Event Listeners 面板查看元素的绑定事件,具体的使用方法请参考官网文档

image2

从图中我们可以看到:

  1. #child 元素绑定了两个点击事件,一个是通过 React 绑定的,一个是通过 addEventListener 绑定的。
  2. 通过 addEventListener 绑定的事件是真的绑定到 #child 元素上。
  3. 通过 React 绑定的事件,其实是代理绑定到 document 上。

React 模拟 DOM 事件冒泡机制

观察下面这个例子:#child 和 #parent 都绑定了一个点击事件。

demo2

由图中可以看出:点击 #child 的同时,也触发了 #parent 的点击事件,看起来“很像” DOM 的事件冒泡机制。然而,实际原理并非如此,因为按照 React 的事件代理,#child 和 #parent 绑定的事件本来就是代理到 document 上的。也就是说,只有当事件流冒泡到 document 上时,才会依次触发 document 上绑定的两个事件。

到此为止,我以为我终于搞明白这块了,后来我发现我还是错了。如果说 #child 和 #parent 的事件都代理到 document 上的话,那么在 Event Listeners 面板中,我们应该能看到 2 个绑定在 document 上的事件,但实际上只有 1 个,如下图所示。

image3

因此,我们可以得出结论:并非 #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 上的事件

demo3

我们来尝试解释一下上图中的现象:

  1. 事件流首先进入到 #child ,然后触发直接绑定在 #child 上的事件;
  2. 事件流沿着 DOM 结构向上冒泡到 document,触发 React 绑定的 dispatchEvent 函数,从而调用了 #child 子元素上绑定的 clickChild 方法。
  3. 在 clickChild 方法的最后,我调用了 e.stopPropagation,成功地阻止了 React 模拟的事件冒泡,因此,成功地没有触发 #parent 上的事件。
  4. 然后,最后出现了问题,还是触发了 document 上直接绑定的事件。我想要的是:”点击 #child ,只触发 #child 上的事件,不要触发任何其他元素的事件,包括 document“,我应该怎么做呢? → 答案是:”调用e.nativeEvent.stopImmediatePropagation

上述过程用图解的方式来分析,我们能理解得清楚一些。

image4

React 合成事件对象的e.stopPropagation,只能阻止 React 模拟的事件冒泡,并不能阻止真实的 DOM 事件冒泡,更加不能阻止已经触发元素的多个事件的依次执行。在这种情况下,只有原生事件对象的 stopImmediatePropagation能做到。

你可能会说:”既然 React 在合成事件对象中封装了 stopPropagation,为什么不把
stopImmediatePropagation 也一并封装了呢?“
我的猜测是:”因为在 React 中不允许给同一个组件绑定多个相同类型的事件,如果非要重复绑定,那么后绑定的会覆盖前绑定的,这是它的设计思路。在这种设计思路下,不会存在某个组件有多个同类型的事件会依次触发,自然便不需要 stopImmediatePropagation 了。

总结

对于 React 的合成事件对象 e 来说:

  1. e.stopPropagation → 用来阻止 React 模拟的事件冒泡
  2. e.stopImmediatePropagation → 没有这个函数
  3. e.nativeEvent.stopPropagation → 原生事件对象的用于阻止 DOM 事件的进一步捕获或者冒泡
  4. e.nativeEvent.stopImmediatePropagation → 原生事件对象的用于阻止 DOM 事件的进一步捕获或者冒泡,且该元素的后续绑定的相同事件类型的事件也被一并阻止。

最后,本文对应的 demo 请参考这里:https://jsfiddle.net/youngwind/91es1dbx/5/
很久以前我也写过一篇关于此主题的博客 #9 ,不过现在看来,那时候的思考很不成熟,也一并列在这儿以作参考吧。

@LiuYashion
Copy link

谢谢作者~

1 similar comment
@0x457
Copy link

0x457 commented Mar 28, 2018

谢谢作者~

@cloudsere
Copy link

写的太好了!!

@Lx15
Copy link

Lx15 commented Aug 6, 2018

作者你好,很感谢你的文章,解开了我的疑惑,但是如果是下面的这种情景该怎样解决呢?
image

我想在点击子元素时,只是点击子元素,而不点击父元素,该怎样阻止呢?

@youngwind
Copy link
Owner Author

这种情况,除非你全部使用 React 绑定或者原生绑定,否则我想不到可阻止的方法。 @Lx15

@Lx15
Copy link

Lx15 commented Aug 6, 2018

@youngwind 或者 在父元素上用target判断一下,可行不优雅

@youngwind
Copy link
Owner Author

哈哈哈,角度刁钻,但也确实可行。 @Lx15

@Lx15
Copy link

Lx15 commented Aug 6, 2018

@youngwind 作者你好,我又认真的读了你的文章,做了测试,发现是有问题的:

  1. 其实原生事件的执行时间总是先于合成事件,无论合成事件是不是在捕获阶段绑定
  2. 所以上述用例的执行结果: 直接绑定在Child上的事件-》直接绑定在document上的事件-》点击子元素-》点击父元素
  3. 所以你上面图的描述其实是错误的
  4. 结果是对的,但是 e.nativeEvent.stopImmediatePropagation()其实只能在document 上才有用,换成
    document.getElementById('parent')都是不可以的

@amnsss
Copy link

amnsss commented Aug 16, 2018

大赞!!!

@acrens
Copy link

acrens commented Sep 7, 2018

结论:
1、e.stopPropagation 可以用来禁用 React 模拟事件;
2、e.nativeEvent.stopImmediatePropagation 只能用来禁用直接绑定在 document 上的事件;
3、对 2 的补充,试想一下,child 元素通过 React 直接绑定事件会被模拟到 document 上,parent 元素通过原生 addEventListener 绑定,通过冒泡原来应该 parent 先被触发,所以无法在 child 元素内禁用 parent 元素绑定的事件(当然,实际开发中,应该不会去 child 通过 React 绑定事件,而 parent 却不使用 React 绑定事件)。

@vincentdd
Copy link

感谢作者,同类分析里写的简单易懂的一个

@Nick930826
Copy link

写得不错

@someOneJYB
Copy link

测试发现document原生绑定事件在合成的事件执行之后执行,原生事件(document元素事件)-》点击子元素-》点击父元素-》直接绑定在document上的事件。链接地址
https://codesandbox.io/s/q33v4vw866

@someOneJYB
Copy link

补充一下在react页面里面打印了一下绑定在document上的click事件,发现第一个就是react绑定的事件,然后才是我在Didmount上绑定的事件

@yes1am
Copy link

yes1am commented Apr 25, 2019

@youngwind 作者你好,我又认真的读了你的文章,做了测试,发现是有问题的:

  1. 其实原生事件的执行时间总是先于合成事件,无论合成事件是不是在捕获阶段绑定
  2. 所以上述用例的执行结果: 直接绑定在Child上的事件-》直接绑定在document上的事件-》点击子元素-》点击父元素
  3. 所以你上面图的描述其实是错误的
  4. 结果是对的,但是 e.nativeEvent.stopImmediatePropagation()其实只能在document 上才有用,换成
    document.getElementById('parent')都是不可以的

@Lx15

根据我的测试结果,和你的结论有点出入,一起讨论下:

  1. 原生事件执行总是先于合成事件?

你说的这种情况只在给 非document,window元素 ` 添加原生事件时成立,当给document添加原生事件时,还取决于添加原生事件的时机:

在render之前添加原生事件,那么原生事件先于合成事件绑定,触发时先执行原生事件。
在render之后比如didMount 中,那么合成事件先于原生事件绑定,触发时先执行合成事件。

  1. 作者的结论是对的。

因为在作者在 didMount 中添加的事件,先执行原生的直接绑定在Child事件,然后根据在 didMount 中添加事件,所以合成事件先于原生事件执行,执行 点击子元素, 阻止冒泡所以 点击父元素不执行。最后会执行原生添加的 直接绑定在Document事件

  1. e.nativeEvent.stopImmediatePropagation()其实只能在document 上才有用?

我测试的结果是,这个api对其它元素也有效。可能我理解错你的意思了?

@wujiedong
Copy link

作者你好,很感谢你的文章,解开了我的疑惑,但是如果是下面的这种情景该怎样解决呢?
image

我想在点击子元素时,只是点击子元素,而不点击父元素,该怎样阻止呢?

parent的那个事件使用react中的合成事件,不要写原生事件,然后再子dom的事件中直接e.stopPropagation(),这样事件就不会冒泡到父那边
我试过子是合成事件,父是原生事件,这种方式我没有成功阻止冒泡

@sanfengliao
Copy link

sanfengliao commented Jun 19, 2019 via email

@jachan-lr
Copy link

非常非常非常有用,感谢!!!

@BigKongfuPanda
Copy link

BigKongfuPanda commented Oct 24, 2020

写的很清楚。但是有个地方有问题:
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函数。

@yang1666204
Copy link

太赞了!!!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests