React中阻止事件冒泡的问题详析
前言
最近在研究react、redux等,网上找了很久都没有完整的答案,索性自己整理下,这篇文章就来给大家介绍了关于React阻止事件冒泡的相关内容,下面话不多说了,来一起看看详细的介绍吧
在正式开始前,先来看看JS中事件的触发与事件处理器的执行。
JS中事件的监听与处理
事件捕获与冒泡
DOM事件会先后经历捕获与冒泡两个阶段。捕获即事件沿着DOM树由上往下传递,到达触发事件的元素后,开始由下往上冒泡。
IE9及之前的版本只支持冒泡
| A
-----------------|--|-----------------
|Parent | | |
| -------------|--|----------- |
| |Children V | | |
| ---------------------------- |
| |
--------------------------------------
事件处理器
默认情况下,事件处理器是在事件的冒泡阶段执行,无论是直接设置元素的onclick属性还是通过EventTarget.addEventListener()来绑定,后者在没有设置useCapture参数为true的情况下。
考察下面的示例:
CLICKME
输出:
btnclicked
documentclicked
阻止事件的冒泡
通过调用事件身上的stopPropagation()可阻止事件冒泡,这样可实现只我们想要的元素处理该事件,而其他元素接收不到。
CLICKME
输出:
btnclicked
一个阻止冒泡的应用场景
常见的弹窗组件中,点击弹窗区域之外关闭弹窗的功能,可通过阻止事件冒泡来方便地实现,而不用这种方式的话,会引入复杂的判断当前点击坐标是否在弹窗之外的复杂逻辑。
document.addEventListener("click",()=>{ //closedialog }); dialogElement.addEventListener("click",event=>{ event.stopPropagation(); });
但如果你尝试在React中实现上面的逻辑,一开始的尝试会让你怀疑人生。
React下事件执行的问题
了解了JS中事件的基础,一切都没什么难的。在引入React后,,事情开始起变化。将上面阻止冒泡的逻辑在React里实现一下,代码大概像这样:
functionApp(){ useEffect(()=>{ document.addEventListener("click",documentClickHandler); return()=>{ document.removeEventListener("click",documentClickHandler); }; },[]); functiondocumentClickHandler(){ console.log("documentclicked"); } functionbtnClickHandler(event){ event.stopPropagation(); console.log("btnclicked"); } returnCLICKME; }
输出:
btnclicked
documentclicked
document上的事件处理器正常执行了,并没有因为我们在按钮里面调用event.stopPropagation()而阻止。
那么问题出在哪?
React中事件处理的原理
考虑下面的示例代码并思考点击按钮后的输出。
importReact,{useEffect}from"react"; importReactDOMfrom"react-dom"; window.addEventListener("click",event=>{ console.log("window"); }); document.addEventListener("click",event=>{ console.log("document:bedorereactmount"); }); document.body.addEventListener("click",event=>{ console.log("body"); }); functionApp(){ functiondocumentHandler(){ console.log("documentwithinreact"); } useEffect(()=>{ document.addEventListener("click",documentHandler); return()=>{ document.removeEventListener("click",documentHandler); }; },[]); return({ console.log("raect:container"); }} >); } ReactDOM.render(,document.getElementById("root")); document.addEventListener("click",event=>{ console.log("document:afterreactmount"); });
现在对代码做一些变动,在body的事件处理器中把冒泡阻止,再思考其输出。
document.body.addEventListener("click",event=>{ +event.stopPropagation(); console.log("body"); });
下面是剧透环节,如果你懒得自己实验的话。
点击按钮后的输出:
body
document:bedorereactmount
react:button
raect:container
document:afterreactmount
documentwithinreact
window
bdoy上阻止冒泡后,你可能会觉得,既然body是按钮及按钮容器的父级,那么按钮及容器的事件会正常执行,事件到达body后,body的事件处理器执行,然后就结束了。document上的事件处理器一个也不执行。
事实上,按钮及按钮容器上的事件处理器也没执行,只有body执行了。
输出:
body
通过下面的分析,你能够完全理解上面的结果。
SyntheticEvent
React有自身的一套事件系统,叫作SyntheticEvent。叫什么不重要,实现上,其实就是通过在document上注册事件代理了组件树中所有的事件(facebook/react#4335),并且它监听的是document冒泡阶段。你完全可以忽略掉SyntheticEvent这个名词,如果觉得它有点让事情变得高大上或者增加了一些神秘的话。
除了事件系统,它有自身的一套,另外还需要理解的是,界面上展示的DOM与我们代码中的DOM组件,也是两样东西,需要在概念上区分开来。
所以,当你在页面上点击按钮,事件开始在原生DOM上走捕获冒泡流程。React监听的是document上的冒泡阶段。事件冒泡到document后,React将事件再派发到组件树中,然后事件开始在组件树DOM中走捕获冒泡流程。
现在来尝试理解一下输出结果:
- 事件最开始从原生DOM按钮一路冒泡到body,body的事件处理器执行,输出body。注意此时流程还没进入React。为什么?因为React监听的是document上的事件。
- 继续往上事件冒泡到document。
- 事件到达document之后,发现document上面一共绑定了三个事件处理器,分别是代码中通过document.addEventListener在ReactDOM.render前后调用的,以及一个隐藏的事件处理器,是ReactDOM绑定的,也就是前面提到的React用来代理事件的那个处理器。
- 同一元素上如果对同一类型的事件绑定了多个处理器,会按照绑定的顺序来执行。
- 所以ReactDOM.render之前的那个处理器先执行,输出document:beforereactmount。
- 然后是React的事件处理器。此时,流程才真正进入React,走进我们的组件。组件里面就好理解了,从button冒泡到container,依次输出。
- 最后ReactDOM.render之后的那个处理器先执行,输出document:afterreactmount。
- 事件完成了在document上的冒泡,往上到了window,执行相应的处理器并输出window。
理解React是通过监听document冒泡阶段来代理组件中的事件,这点很重要。同时,区分原生DOM与React组件,也很重要。并且,React组件上的事件处理器接收到的event对象也有别于原生的事件对象,不是同一个东西。但这个对象上有个nativeEvent属性,可获取到原生的事件对象,后面会用到和讨论它。
紧接着的代码的改动中,我们在body上阻止了事件冒泡,这样事件在body就结束了,没有到达document,那么React的事件就不会被触发,所以React组件树中,按钮及容器就没什么反应。如果没理解到这点,光看表象还以为是bug。
进而可以理解,如果在ReactDOM.render()之前的的document事件处理器上将冒泡结束掉,同样会影响React的执行。只不过这里需要调用的不是event.stopPropagation(),而是event.stopImmediatePropagation()。
document.addEventListener("click",event=>{ +event.stopImmediatePropagation(); console.log("document:bedorereactmount"); });
输出:
body
document:bedorereactmount
stopImmediatePropagation会产生这样的效果,即,如果同一元素上同一类型的事件(这里是click)绑定了多个事件处理器,本来这些处理器会按绑定的先后来执行,但如果其中一个调用了stopImmediatePropagation,不但会阻止事件冒泡,还会阻止这个元素后续其他事件处理器的执行。
所以,虽然都是监听document上的点击事件,但ReactDOM.render()之前的这个处理器要先于React,所以React对document的监听不会触发。
解答前面按钮未能阻止冒泡的问题
如果你已经忘了,这是相应的代码及输出。
到这里,已经可以解答为什么React组件中button的事件处理器中调用event.stopPropagation()没有阻止document的点击事件执行的问题了。因为button事件处理器的执行前提是事件达到document被React接收到,然后React将事件派发到button组件。既然在按钮的事件处理器执行之前,事件已经达到document了,那当然就无法在按钮的事件处理器进行阻止了。
问题的解决
要解决这个问题,这里有不止一种方法。
用window替换document
来自Reactissue回答中提供的这个方法是最快速有效的。使用window替换掉document后,前面的代码可按期望的方式执行。
functionApp(){ useEffect(()=>{ +window.addEventListener("click",documentClickHandler); return()=>{ +window.removeEventListener("click",documentClickHandler); }; },[]); functiondocumentClickHandler(){ console.log("documentclicked"); } functionbtnClickHandler(event){ event.stopPropagation(); console.log("btnclicked"); } returnCLICKME; }
这里button事件处理器上接到到的event来自React系统,也就是document上代理过来的,所以通过它阻止冒泡后,事件到document就结束了,而不会往上到window。
Event.stopImmediatePropagation()
组件中事件处理器接收到的event事件对象是React包装后的SyntheticEvent事件对象。但可通过它的nativeEvent属性获取到原生的DOM事件对象。通过调用这个原生的事件对象上的stopImmediatePropagation()方法可达到阻止冒泡的目的。
functionbtnClickHandler(event){ +event.nativeEvent.stopImmediatePropagation(); console.log("btnclicked"); }
至于原理,其实前面已经有展示过。React在render时监听了document冒泡阶段的事件,当我们的App组件执行时,准确地说是渲染完成后(useEffect渲染完成后执行),又在document上注册了click的监听。此时document上有两个事件处理器了,并且组件中的这个顺序在React后面。
当调用event.nativeEvent.stopImmediatePropagation()后,阻止了document上同类型后续事件处理器的执行,达到了想要的效果。
但这种方式有个缺点很明显,那就是要求需要被阻止的事件是在Reactrender之后绑定,如果在之前绑定,是达不到效果的。
通过元素自身来绑定事件处理器
当绕开React直接通过调用元素自己身上的方法来绑定事件时,此时走的是原生DOM的流程,都没在React的流程里面。
functionApp(){ constbtnElement=useRef(null); useEffect(()=>{ document.addEventListener("click",documentClickHandler); if(btnElement.current){ btnElement.current.addEventListener("click",btnClickHandler); } return()=>{ document.removeEventListener("click",documentClickHandler); if(btnElement.current){ btnElement.current.removeEventListener("click",btnClickHandler); } }; },[]); functiondocumentClickHandler(){ console.log("documentclicked"); } functionbtnClickHandler(event){ event.stopPropagation(); console.log("btnclicked"); } returnCLICKME; }
很明显这样是能解决问题,但你根本不会想要这样做。代码丑陋,不直观也不易理解。
结论
注意区分React组件的事件及原生DOM事件,一般情况下,尽量使用React的事件而不要混用。如果必需要混用比如监听document,window上的事件,处理mousemove,resize等这些场景,那么就需要注意本文提到的顺序问题,不然容易出bug。
相关资源
- e.stopPropagation()seemstonotbeworkingasexpect.#4335
- ReactJSSyntheticEventstopPropagation()onlyworkswithReactevents?
- Event.stopImmediatePropagation()
- SyntheticEvent
总结
以上就是这篇文章的全部内容了,希望本文的内容对大家的学习或者工作具有一定的参考学习价值,谢谢大家对毛票票的支持。