React中阻止事件冒泡的問題詳析
【React中阻止事件冒泡的問題詳析】前言
最近在研究react、redux等,網上找了很久都沒有完整的答案,索性自己整理下,這篇文章就來給大家介紹了關于React阻止事件冒泡的相關內容,下面話不多說了,來一起看看詳細的介紹吧
在正式開始前,先來看看 JS 中事件的觸發與事件處理器的執行 。
JS 中事件的監聽與處理
事件捕獲與冒泡
DOM 事件會先后經歷 捕獲 與 冒泡 兩個階段 。捕獲即事件沿著 DOM 樹由上往下傳遞,到達觸發事件的元素后,開始由下往上冒泡 。

事件處理器
默認情況下,事件處理器是在事件的冒泡階段執行 , 無論是直接設置元素的 onclick 屬性還是通過 EventTarget.addEventListener() 來綁定,后者在沒有設置 useCapture 參數為 true 的情況下 。
考察下面的示例:

輸出:

阻止事件的冒泡
通過調用事件身上的 stopPropagation() 可阻止事件冒泡,這樣可實現只我們想要的元素處理該事件,而其他元素接收不到 。

輸出:

一個阻止冒泡的應用場景
常見的彈窗組件中,點擊彈窗區域之外關閉彈窗的功能 , 可通過阻止事件冒泡來方便地實現,而不用這種方式的話,會引入復雜的判斷當前點擊坐標是否在彈窗之外的復雜邏輯 。

但如果你嘗試在 React 中實現上面的邏輯,一開始的嘗試會讓你懷疑人生 。
React 下事件執行的問題
了解了 JS 中事件的基礎,一切都沒什么難的 。在引入 React 后 , ,事情開始起變化 。將上面阻止冒泡的邏輯在 React 里實現一下,代碼大概像這樣:

輸出:

document 上的事件處理器正常執行了 , 并沒有因為我們在按鈕里面調用 event.stopPropagation() 而阻止 。
那么問題出在哪?
React 中事件處理的原理
考慮下面的示例代碼并思考點擊按鈕后的輸出 。

現在對代碼做一些變動,在 body 的事件處理器中把冒泡阻止,再思考其輸出 。

下面是劇透環節 , 如果你懶得自己實驗的話 。
點擊按鈕后的輸出:

bdoy 上阻止冒泡后,你可能會覺得,既然 body 是按鈕及按鈕容器的父級,那么按鈕及容器的事件會正常執行,事件到達 body 后, body 的事件處理器執行,然后就結束了 。document 上的事件處理器一個也不執行 。
事實上,按鈕及按鈕容器上的事件處理器也沒執行,只有 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:before react mount 。
然后是 React 的事件處理器 。此時,流程才真正進入 React,走進我們的組件 。組件里面就好理解了 , 從 button 冒泡到 container,依次輸出 。
最后 ReactDOM.render 之后的那個處理器先執行,輸出 document:after react mount 。
事件完成了在 document 上的冒泡,往上到了 window , 執行相應的處理器并輸出 window 。
理解 React 是通過監聽 document 冒泡階段來代理組件中的事件,這點很重要 。同時,區分原生 DOM 與 React 組件,也很重要 。并且,React 組件上的事件處理器接收到的 event 對象也有別于原生的事件對象,不是同一個東西 。但這個對象上有個 nativeEvent 屬性,可獲取到原生的事件對象,后面會用到和討論它 。
緊接著的代碼的改動中,我們在 body 上阻止了事件冒泡,這樣事件在 body 就結束了 , 沒有到達 document,那么 React 的事件就不會被觸發,所以 React 組件樹中,按鈕及容器就沒什么反應 。如果沒理解到這點,光看表象還以為是 bug 。
進而可以理解 , 如果在 ReactDOM.render() 之前的的 document 事件處理器上將冒泡結束掉,同樣會影響 React 的執行 。只不過這里需要調用的不是 event.stopPropagation(),而是 event.stopImmediatePropagation()。

輸出:

stopImmediatePropagation 會產生這樣的效果 , 即,如果同一元素上同一類型的事件(這里是 click)綁定了多個事件處理器,本來這些處理器會按綁定的先后來執行,但如果其中一個調用了 stopImmediatePropagation,不但會阻止事件冒泡,還會阻止這個元素后續其他事件處理器的執行 。
所以,雖然都是監聽 document 上的點擊事件,但 ReactDOM.render() 之前的這個處理器要先于 React,所以 React 對 document 的監聽不會觸發 。
解答前面按鈕未能阻止冒泡的問題
如果你已經忘了,這是相應的代碼及輸出 。
到這里 , 已經可以解答為什么 React 組件中 button 的事件處理器中調用 event.stopPropagation() 沒有阻止 document 的點擊事件執行的問題了 。因為 button 事件處理器的執行前提是事件達到 document 被 React 接收到,然后 React 將事件派發到 button 組件 。既然在按鈕的事件處理器執行之前,事件已經達到 document 了,那當然就無法在按鈕的事件處理器進行阻止了 。
問題的解決
要解決這個問題,這里有不止一種方法 。
用 window 替換 document
來自 React issue 回答中提供的這個方法是最快速有效的 。使用 window 替換掉 document 后,前面的代碼可按期望的方式執行 。

這里 button 事件處理器上接到到的 event 來自 React 系統 , 也就是 document 上代理過來的,所以通過它阻止冒泡后 , 事件到 document 就結束了,而不會往上到 window 。
Event.stopImmediatePropagation()
組件中事件處理器接收到的 event 事件對象是 React 包裝后的 SyntheticEvent 事件對象 。但可通過它的 nativeEvent 屬性獲取到原生的 DOM 事件對象 。通過調用這個原生的事件對象上的 stopImmediatePropagation() 方法可達到阻止冒泡的目的 。

至于原理,其實前面已經有展示過 。React 在 render 時監聽了 document 冒泡階段的事件,當我們的 App 組件執行時 , 準確地說是渲染完成后(useEffect 渲染完成后執行),又在 document 上注冊了 click 的監聽 。此時 document 上有兩個事件處理器了,并且組件中的這個順序在 React 后面 。
當調用 event.nativeEvent.stopImmediatePropagation() 后,阻止了 document 上同類型后續事件處理器的執行,達到了想要的效果 。
但這種方式有個缺點很明顯,那就是要求需要被阻止的事件是在 React render 之后綁定,如果在之前綁定,是達不到效果的 。
通過元素自身來綁定事件處理器
當繞開 React 直接通過調用元素自己身上的方法來綁定事件時,此時走的是原生 DOM 的流程,都沒在 React 的流程里面 。

很明顯這樣是能解決問題 , 但你根本不會想要這樣做 。代碼丑陋,不直觀也不易理解 。
結論
注意區分 React 組件的事件及原生 DOM 事件,一般情況下,盡量使用 React 的事件而不要混用 。如果必需要混用比如監聽 document,window 上的事件,處理 mousemove,resize 等這些場景 , 那么就需要注意本文提到的順序問題,不然容易出 bug 。
