1. 程式人生 > >React 中阻止事件冒泡的問題

React 中阻止事件冒泡的問題

react 監聽 lse children blog 開始 代理 tco per

在正式開始前,先來看看 JS 中事件的觸發與事件處理器的執行。

JS 中事件的監聽與處理

事件捕獲與冒泡

DOM 事件會先後經歷 捕獲 與 冒泡 兩個階段。捕獲即事件沿著 DOM 樹由上往下傳遞,到達觸發事件的元素後,開始由下往上冒泡。

IE9 及之前的版本只支持冒泡

                  |  A
 -----------------|--|-----------------
 | Parent         |  |                |
 |   -------------|--|-----------     |
 |   |Children    V  |          |     |
 |   ----------------------------     |
 |                                    |
 --------------------------------------

事件處理器

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

考察下面的示例:

<button onclick="btnClickHandler(event)">CLICK ME</button>
<script>
  document.addEventListener("click", function(event) {
    console.log("document clicked");
  });

  function btnClickHandler(event) {
    console.log("btn clicked");
  }
</script>

輸出:

btn clicked
document clicked

阻止事件的冒泡

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

<button onclick="btnClickHandler(event)">CLICK ME</button>
<script>
  document.addEventListener(
    "click",
    function(event) {
      console.log("document clicked");
    },
    false
  );

  function btnClickHandler(event) {
    event.stopPropagation();
    console.log("btn clicked");
  }
</script>

輸出:

btn clicked

一個阻止冒泡的應用場景

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

document.addEventListener("click", () => {
  // close dialog
});

dialogElement.addEventListener("click", event => {
  event.stopPropagation();
});

但如果你嘗試在 React 中實現上面的邏輯,一開始的嘗試會讓你懷疑人生。

React 下事件執行的問題

了解了 JS 中事件的基礎,一切都沒什麽難的。在引入 React 後,,事情開始起變化。將上面阻止冒泡的邏輯在 React 裏實現一下,代碼大概像這樣:

function App() {
  useEffect(() => {
    document.addEventListener("click", documentClickHandler);
    return () => {
      document.removeEventListener("click", documentClickHandler);
    };
  }, []);

  function documentClickHandler() {
    console.log("document clicked");
  }

  function btnClickHandler(event) {
    event.stopPropagation();
    console.log("btn clicked");
  }

  return <button onClick={btnClickHandler}>CLICK ME</button>;
}

輸出:

btn clicked
document clicked

document 上的事件處理器正常執行了,並沒有因為我們在按鈕裏面調用 event.stopPropagation() 而阻止。

那麽問題出在哪?

React 中事件處理的原理

考慮下面的示例代碼並思考點擊按鈕後的輸出。

import React, { useEffect } from "react";
import ReactDOM from "react-dom";

window.addEventListener("click", event => {
  console.log("window");
});

document.addEventListener("click", event => {
  console.log("document:bedore react mount");
});

document.body.addEventListener("click", event => {
  console.log("body");
});

function App() {
  function documentHandler() {
    console.log("document within react");
  }

  useEffect(() => {
    document.addEventListener("click", documentHandler);
    return () => {
      document.removeEventListener("click", documentHandler);
    };
  }, []);

  return (
    <div
      onClick={() => {
        console.log("raect:container");
      }}
    >
      <button
        onClick={event => {
          console.log("react:button");
        }}
      >
        CLICK ME
      </button>
    </div>
  );
}

ReactDOM.render(<App />, document.getElementById("root"));

document.addEventListener("click", event => {
  console.log("document:after react mount");
});

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

document.body.addEventListener("click", event => {
+  event.stopPropagation();
  console.log("body");
});

下面是劇透環節,如果你懶得自己實驗的話。

點擊按鈕後的輸出:

body
document:bedore react mount
react:button
raect:container
document:after react mount
document within react
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.addEventListenerReactDOM.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()

document.addEventListener("click", event => {
+  event.stopImmediatePropagation();
  console.log("document:bedore react mount");
});

輸出:

body
document:bedore react mount

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 後,前面的代碼可按期望的方式執行。

function App() {
  useEffect(() => {
+    window.addEventListener("click", documentClickHandler);
    return () => {
+      window.removeEventListener("click", documentClickHandler);
    };
  }, []);

  function documentClickHandler() {
    console.log("document clicked");
  }

  function btnClickHandler(event) {
    event.stopPropagation();
    console.log("btn clicked");
  }

  return <button onClick={btnClickHandler}>CLICK ME</button>;
}

這裏 button 事件處理器上接到到的 event 來自 React 系統,也就是 document 上代理過來的,所以通過它阻止冒泡後,事件到 document 就結束了,而不會往上到 window。

Event.stopImmediatePropagation()

組件中事件處理器接收到的 event 事件對象是 React 包裝後的 SyntheticEvent 事件對象。但可通過它的 nativeEvent 屬性獲取到原生的 DOM 事件對象。通過調用這個原生的事件對象上的 stopImmediatePropagation()方法可達到阻止冒泡的目的。

function btnClickHandler(event) {
+  event.nativeEvent.stopImmediatePropagation();
  console.log("btn clicked");
}

至於原理,其實前面已經有展示過。React 在 render 時監聽了 document 冒泡階段的事件,當我們的 App 組件執行時,準確地說是渲染完成後(useEffect 渲染完成後執行),又在 document 上註冊了 click 的監聽。此時 document 上有兩個事件處理器了,並且組件中的這個順序在 React 後面。

當調用 event.nativeEvent.stopImmediatePropagation() 後,阻止了 document 上同類型後續事件處理器的執行,達到了想要的效果。

但這種方式有個缺點很明顯,那就是要求需要被阻止的事件是在 React render 之後綁定,如果在之前綁定,是達不到效果的。

通過元素自身來綁定事件處理器

當繞開 React 直接通過調用元素自己身上的方法來綁定事件時,此時走的是原生 DOM 的流程,都沒在 React 的流程裏面。

function App() {
  const btnElement = 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);
      }
    };
  }, []);

  function documentClickHandler() {
    console.log("document clicked");
  }

  function btnClickHandler(event) {
    event.stopPropagation();
    console.log("btn clicked");
  }

  return <button ref={btnElement}>CLICK ME</button>;
}

很明顯這樣是能解決問題,但你根本不會想要這樣做。代碼醜陋,不直觀也不易理解。

結論

註意區分 React 組件的事件及原生 DOM 事件,一般情況下,盡量使用 React 的事件而不要混用。如果必需要混用比如監聽 document,window 上的事件,處理 mousemoveresize 等這些場景,那麽就需要註意本文提到的順序問題,不然容易出 bug。

相關資源

  • e.stopPropagation() seems to not be working as expect. #4335
  • ReactJS SyntheticEvent stopPropagation() only works with React events?
  • Event.stopImmediatePropagation()
  • SyntheticEvent

React 中阻止事件冒泡的問題