React 事件代理与 stopImmediatePropagation

@youngwind 2017-07-17 06:05:25发表于 youngwind/blog React

前言

我们都知道:“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 ,不过现在看来,那时候的思考很不成熟,也一并列在这儿以作参考吧。