JavaScript 事件深入解析

preventDefaultstopPropagation:使用時機和各個方法的具體功能。

JavaScript 事件處理通常很簡單。處理簡單 (相對平面) HTML 結構時,這一點尤其重要。不過,如果事件是透過元素階層傳遞 (或傳播),情況就會複雜一些。通常,開發人員會尋求 stopPropagation() 和/或 preventDefault() 來解決遇到的問題。如果您曾經想過:「我來試試 preventDefault(),如果沒效,就試試 stopPropagation(),如果還是沒效,就試試兩者都試試看」,那麼這篇文章就是為您而寫的!我將詳細說明每種方法的作用、使用時機,並提供各種可供您探索的實際範例。我的目標是徹底解決你的疑惑。

不過,在深入探討之前,我們必須先簡單介紹 JavaScript 中可能會出現的兩種事件處理方式 (在所有現代瀏覽器中,也就是 Internet Explorer 9 之前的版本完全不支援事件擷取)。

活動樣式 (拍攝和泡泡)

所有新式瀏覽器都支援事件擷取功能,但開發人員很少使用這項功能。有趣的是,這是 Netscape 最初支援的唯一事件形式。Netscape 最大的競爭對手 Microsoft Internet Explorer 完全不支援擷取事件,但僅支援另一種形式稱為事件開始。在 W3C 成立時,他們發現兩種事件樣式都有優點,並宣告瀏覽器應透過 addEventListener 方法的第三個參數同時支援這兩種事件。這個參數原本只是一個布林值,但所有新式瀏覽器都支援 options 物件做為第三個參數,您可以使用該參數指定是否要使用事件擷取功能 (以及其他功能):

someElement.addEventListener('click', myClickHandler, { capture: true | false });

請注意,options 物件和其 capture 屬性皆為選用項目。如果省略其中一個標記,capture 的預設值為 false,表示會使用事件冒泡。

事件擷取

如果事件處理常式「在擷取階段進行監聽」,代表什麼意思?為了瞭解這個情況 我們需要瞭解事件的來源和方式下列說明適用於所有事件,即使您 (開發人員) 不使用、不關心或不考慮事件也一樣。

所有事件都會從回溯期開始,然後先經過擷取階段。也就是說,當事件調度時,它會啟動視窗,並「向下」前往目標元素。就算你只是在擴散階段聆聽也是如此。請參考下列標記和 JavaScript 範例:

<html>
  <body>
    <div id="A">
      <div id="B">
        <div id="C"></div>
      </div>
    </div>
  </body>
</html>
document.getElementById('C').addEventListener(
  'click',
  function (e) {
    console.log('#C was clicked');
  },
  true,
);

當使用者點選元素 #C 時,系統會分派源自 window 的事件。此事件會透過其子項傳播,如下所示:

window => document => <html> => <body> =>,以此類推,直到達成目標為止。

無論 windowdocument<html> 元素或 <body> 元素 (或前往目標的任何其他元素) 是否有監聽點擊事件,都無所謂。事件仍會從 window 開始,並按照剛才所述的路徑開始傳送。

在本範例中,點擊事件會傳播 (這是個重要的字詞,因為它會直接連結至 stopPropagation() 方法的運作方式,並會在本文稍後說明),從 window 到其目標元素 (在本例中為 #C),經由 window#C 之間的每個元素。

這表示點擊事件會從 window 開始,瀏覽器會詢問下列問題:

「在擷取階段,是否有任何項目會監聽 window 的點擊事件?」如果是的話,系統會觸發適當的事件處理常式。在本範例中,沒有任何事件,因此不會觸發任何處理常式。

接下來,事件會「傳播」document,瀏覽器會詢問:「是否有任何在擷取階段中在 document 上監聽點擊事件?」如果是,系統會觸發適當的事件處理常式。

接著,事件會傳播<html> 元素,瀏覽器會詢問:「在擷取階段中,是否有任何元素會監聽 <html> 元素的點擊動作?」如果是的話,系統就會觸發適當的事件處理常式。

接著,事件會傳播<body> 元素,瀏覽器會詢問:「在擷取階段中,是否有任何元素會監聽 <body> 元素上的點擊事件?」如果是這樣,系統會觸發適當的事件處理常式。

接下來,事件會傳播#A 元素。同樣地,瀏覽器會詢問:「在擷取階段中,是否有任何項目會監聽 #A 上的點擊事件?如果有的話,系統會觸發適當的事件處理常式。」

接下來,事件會傳播#B 元素 (系統會詢問同樣問題)。

最後,事件將達到其目標,瀏覽器會詢問:「是否有任何在擷取階段中 #C 元素上監聽點擊事件的行為?」這次答案是「要!」事件「到達」目標的這段短暫時間稱為「目標階段」。此時,事件處理常式會觸發,瀏覽器會執行 console.log "#C was clicked",然後我們就完成了,對吧?錯了!我們還沒全部完成。程序會繼續進行,但現在會改為冒泡階段。

活動泡泡

瀏覽器會詢問:

「在冒泡階段,是否有任何事件監聽 #C 的點擊事件?」請仔細留意這點。您完全可以同時在擷取和冒泡階段監聽點擊 (或任何事件類型)。如果您在兩個階段都設定了事件處理常式 (例如呼叫 .addEventListener() 兩次,一次使用 capture = true,另一次使用 capture = false),那麼兩個事件處理常式都會針對同一個元素觸發。但也請您留意,在不同階段 (處於擷取階段,一個處於懸浮階段) 啟動。

接著,事件會傳播 (更常見的說法是「冒泡」,因為事件似乎會沿著 DOM 樹「向上」傳播) 到其父項元素 #B,瀏覽器會詢問:「在冒泡階段,是否有任何項目會監聽 #B 上的點擊事件?」在本範例中,沒有任何項目不會觸發任何處理常式。

接著,事件會冒泡至 #A,瀏覽器會詢問:「在冒泡階段,是否有任何事件監聽 #A 的點擊事件?」

接下來,事件會對話至 <body>:「是否有任何在懸浮階段中 <body> 元素上監聽點擊事件的行為?」

接著是 <html> 元素:「在冒泡階段,是否有任何元素監聽 <html> 元素的點擊事件?

接下來,document:「是否有任何在活躍階段中監聽 document 上的點擊事件?」

最後,window:「是否有任何項目在正在啟動階段的視窗中監聽點擊事件?」

大功告成!這是很長的旅程,我們的活動現在或許已經很累人,但無論如何,每個事件都經歷了!開發人員通常只對某個事件階段感興趣,因此大多不會注意到這個問題 (通常是冒泡階段)。

建議您花點時間試試事件擷取和事件冒泡,並在處理常式觸發時將一些備註記錄到控制台。查看事件的路徑非常有助於瞭解事件的路徑。以下範例會監聽兩個階段中的所有元素。

<html>
  <body>
    <div id="A">
      <div id="B">
        <div id="C"></div>
      </div>
    </div>
  </body>
</html>
document.addEventListener(
  'click',
  function (e) {
    console.log('click on document in capturing phase');
  },
  true,
);
// document.documentElement == <html>
document.documentElement.addEventListener(
  'click',
  function (e) {
    console.log('click on <html> in capturing phase');
  },
  true,
);
document.body.addEventListener(
  'click',
  function (e) {
    console.log('click on <body> in capturing phase');
  },
  true,
);
document.getElementById('A').addEventListener(
  'click',
  function (e) {
    console.log('click on #A in capturing phase');
  },
  true,
);
document.getElementById('B').addEventListener(
  'click',
  function (e) {
    console.log('click on #B in capturing phase');
  },
  true,
);
document.getElementById('C').addEventListener(
  'click',
  function (e) {
    console.log('click on #C in capturing phase');
  },
  true,
);

document.addEventListener(
  'click',
  function (e) {
    console.log('click on document in bubbling phase');
  },
  false,
);
// document.documentElement == <html>
document.documentElement.addEventListener(
  'click',
  function (e) {
    console.log('click on <html> in bubbling phase');
  },
  false,
);
document.body.addEventListener(
  'click',
  function (e) {
    console.log('click on <body> in bubbling phase');
  },
  false,
);
document.getElementById('A').addEventListener(
  'click',
  function (e) {
    console.log('click on #A in bubbling phase');
  },
  false,
);
document.getElementById('B').addEventListener(
  'click',
  function (e) {
    console.log('click on #B in bubbling phase');
  },
  false,
);
document.getElementById('C').addEventListener(
  'click',
  function (e) {
    console.log('click on #C in bubbling phase');
  },
  false,
);

控制台輸出內容取決於您點選的元素。如果您按一下 DOM 樹狀結構中的「最深層」元素 (#C 元素),就會看到這些事件處理常式中的每個事件處理常式都會觸發。透過 CSS 樣式,您可以更清楚分辨哪個元素是哪個元素,以下是控制台輸出的 #C 元素 (以及螢幕截圖):

"click on document in capturing phase"
"click on <html> in capturing phase"
"click on <body> in capturing phase"
"click on #A in capturing phase"
"click on #B in capturing phase"
"click on #C in capturing phase"
"click on #C in bubbling phase"
"click on #B in bubbling phase"
"click on #A in bubbling phase"
"click on <body> in bubbling phase"
"click on <html> in bubbling phase"
"click on document in bubbling phase"

您可以在下方的直播示範中,與此互動式遊戲互動。按一下 #C 元素,並觀察控制台輸出內容。

event.stopPropagation()

在瞭解事件的來源,以及事件在擷取階段和冒泡階段中如何透過 DOM 傳遞 (即傳播) 後,我們現在可以將注意力轉移到 event.stopPropagation()

您可以在 (大多數) 原生 DOM 事件上呼叫 stopPropagation() 方法。我說「大多數」是因為在少數情況下,呼叫此方法不會執行任何動作 (因為事件不會傳播至開始)。focusblurloadscroll 和其他幾個事件都屬於這個類別。您可以呼叫 stopPropagation(),但不會發生任何有趣的事情,因為這些事件不會傳播。

那麼 stopPropagation 有什麼功能?

基本上,純粹就是這樣。當您呼叫時,事件會從該點開始停止傳播至其他元素。這適用於兩種方向 (擷取和冒泡)。因此,如果您在擷取階段的任何位置呼叫 stopPropagation(),事件就永遠不會進入目標階段或啟動階段。如果您在冒泡階段呼叫,則該事件已完成擷取階段,但會從您呼叫的時間點開始停止「冒泡」。

回到同一個範例標記,如果我們在 #B 元素的擷取階段呼叫 stopPropagation(),您認為會發生什麼情況?

輸出結果如下:

"click on document in capturing phase"
"click on <html> in capturing phase"
"click on <body> in capturing phase"
"click on #A in capturing phase"
"click on #B in capturing phase"

您可以在下方的直播示範中,與此互動式遊戲互動。按一下即時示範中的 #C 元素,並觀察控制台輸出內容。

在冒泡階段停止在 #A 處傳播如何?這會產生以下輸出內容:

"click on document in capturing phase"
"click on <html> in capturing phase"
"click on <body> in capturing phase"
"click on #A in capturing phase"
"click on #B in capturing phase"
"click on #C in capturing phase"
"click on #C in bubbling phase"
"click on #B in bubbling phase"
"click on #A in bubbling phase"

您可以在下方的直播示範中,與此互動式遊戲互動。按一下即時示範中的 #C 元素,並觀察控制台輸出內容。

再來一張,只是為了好玩。如果我們在 #C目標階段中呼叫 stopPropagation(),會發生什麼情況?提醒您,「目標階段」是指事件「位於」目標時所指定的時間長度。這會產生以下輸出內容:

"click on document in capturing phase"
"click on <html> in capturing phase"
"click on <body> in capturing phase"
"click on #A in capturing phase"
"click on #B in capturing phase"
"click on #C in capturing phase"

請注意,#C 的事件處理常式會記錄「在擷取階段點選 #C」仍會執行,但會記錄「在冒泡階段點選 #C」的事件處理常式不會執行。這應該是合理的做法。我們會從前者呼叫 stopPropagation(),因此事件的傳播會在該點停止。

您可以在下方的直播示範中,與此互動式遊戲互動。按一下即時示範中的 #C 元素,然後觀察控制台輸出內容。

在任何實況示範中,我都鼓勵您多多嘗試。請嘗試只按一下 #A 元素或 body 元素。試著預測會發生什麼事,然後觀察結果是否正確。此時,您應該可以做出相當準確的預測。

event.stopImmediatePropagation()

這個奇怪且不常使用的做法是什麼?這個方法與 stopPropagation 類似,但不會停止事件傳送至子項 (擷取) 或祖系 (冒泡),而是只在您將多個事件處理常式連接至單一元素時才會套用。由於 addEventListener() 支援多播式事件處理,因此可以將事件處理常式連接至單一元素多次。發生這種情況時,在大多數瀏覽器中,事件處理常式會依照連接順序執行。呼叫 stopImmediatePropagation() 可防止任何後續處理常式觸發。請參考以下範例:

<html>
  <body>
    <div id="A">I am the #A element</div>
  </body>
</html>
document.getElementById('A').addEventListener(
  'click',
  function (e) {
    console.log('When #A is clicked, I shall run first!');
  },
  false,
);

document.getElementById('A').addEventListener(
  'click',
  function (e) {
    console.log('When #A is clicked, I shall run second!');
    e.stopImmediatePropagation();
  },
  false,
);

document.getElementById('A').addEventListener(
  'click',
  function (e) {
    console.log('When #A is clicked, I would have run third, if not for stopImmediatePropagation');
  },
  false,
);

上述範例會產生以下主控台輸出內容:

"When #A is clicked, I shall run first!"
"When #A is clicked, I shall run second!"

請注意,由於第二個事件處理常式會呼叫 e.stopImmediatePropagation(),因此第三個事件處理常式永遠不會執行。如果我們改為呼叫 e.stopPropagation(),第三個處理常式仍會執行。

event.preventDefault()

如果 stopPropagation() 阻止事件「向下」傳遞 (擷取) 或「向上」傳遞 (冒泡),那麼 preventDefault() 會做什麼?這似乎是類似的情況。是嗎?

算不上是。雖然這兩者經常讓人混淆,但其實彼此並無太大關聯。當您看到 preventDefault() 時,請在頭部處加入「action」一詞。請想想如何「防止預設動作」。

請問您會要求預設動作嗎?可惜的是,這個答案不夠明確,因為這與問題的元素 + 事件組合極為相關。更令人困惑的是,有時根本沒有預設動作!

讓我們先從一個簡單的例子開始,您預期點選網頁上的連結後會發生什麼事?您會預期瀏覽器會瀏覽至該網址指定的網址。 在本例中,元素是錨點標記,事件則是點擊事件。這個組合 (<a> + click) 的「預設動作」是前往連結的 href。如果想要防止瀏覽器執行該預設動作,該怎麼做?也就是說,假設您想防止瀏覽器前往 <a> 元素 href 屬性指定的網址,preventDefault() 會為您處理這項程序。以這段程式碼為例:

<a id="avett" href="https://www.theavettbrothers.com/welcome">The Avett Brothers</a>
document.getElementById('avett').addEventListener(
  'click',
  function (e) {
    e.preventDefault();
    console.log('Maybe we should just play some of their music right here instead?');
  },
  false,
);

您可以在下方的直播示範中,與此互動式遊戲互動。按一下 Avett Brothers 連結,觀察主控台輸出內容 (而且您不會被重新導向至 Avett Brothers 網站)。

一般來說,點選標示為 The Avett Brothers 的連結,會導向 www.theavettbrothers.com。不過,在本例中,我們已將點擊事件處理常式連接至 <a> 元素,並指定應防止預設動作。因此,當使用者點選這個連結時,系統不會導向任何位置,而是在控制台記錄「也許我們應該直接播放他們的音樂?」

還有哪些元素/事件組合可防止預設動作?我無法一一列出這些因素,有時你必須透過實驗才能瞭解。以下簡要列舉幾項:

  • <form> 元素 + 「submit」事件:preventDefault() 這個組合會防止表單提交。如果您想執行驗證,且發生錯誤時,可以有條件地呼叫 preventDefault,停止表單提交作業。

  • <a> 元素 +「點擊」事件:preventDefault()這個組合會防止瀏覽器前往 <a> 元素 href 屬性中指定的網址。

  • document +「滑鼠滾輪」事件:加入 preventDefault() 這個組合時,使用者將無法使用滑鼠滾輪捲動頁面 (但依然可以使用鍵盤捲動)。
    ↜ 這需要使用 { passive: false } 呼叫 addEventListener()

  • document +「keydown」事件:preventDefault()這個組合會導致致命錯誤。這會讓網頁幾乎無法使用,並且會阻止鍵盤捲動、Tab 鍵和鍵盤醒目顯示功能。

  • document + 「mousedown」事件:preventDefault() 與此組合搭配使用時,系統會禁止使用滑鼠選取文字,以及任何在按下滑鼠時觸發的「預設」動作。

  • <input> 元素 +「keypress」事件:preventDefault() 此組合可防止使用者輸入的字元觸及輸入元素 (但切勿執行此動作,因此在極少數的情況下,原因很少)。

  • document +「內容選單」事件:這個組合的 preventDefault() 可防止在使用者按一下滑鼠右鍵或長按 (或以任何其他方式顯示內容選單的方式) 時,顯示原生瀏覽器內容選單。

這份清單並未列出所有方法,但希望能幫助您瞭解如何運用 preventDefault()

這是個有趣的惡作劇嗎?

如果您在擷取階段中從文件開始,stopPropagation() preventDefault() 會發生什麼情況?接著就會發生爆笑場面!下列程式碼片段會將任何網頁轉譯成幾乎完全無用的狀態:

function preventEverything(e) {
  e.preventDefault();
  e.stopPropagation();
  e.stopImmediatePropagation();
}

document.addEventListener('click', preventEverything, true);
document.addEventListener('keydown', preventEverything, true);
document.addEventListener('mousedown', preventEverything, true);
document.addEventListener('contextmenu', preventEverything, true);
document.addEventListener('mousewheel', preventEverything, { capture: true, passive: false });

我不知道你可能會想這樣做的原因 (也許不是聽個笑話),但建議你思考一下這裡發生的原因,並瞭解發生相關情況的原因。

所有事件都會從 window 開始,因此在這個程式碼片段中,我們會停止所有 clickkeydownmousedowncontextmenumousewheel 事件,讓這些事件無法傳送至任何可能會監聽的元素。我們也會呼叫 stopImmediatePropagation,這樣一來,任何在這個處理常式之後連線至文件的處理常式,也會遭到阻斷。

請注意,stopPropagation()stopImmediatePropagation() 並不會 (至少) 轉譯無用頁面的情形。只是防止事件前往原本前往的位置。

但我們也會呼叫 preventDefault(),您會發現這會阻止預設動作。因此,系統會禁止所有預設動作 (例如滑鼠滾輪捲動、鍵盤捲動或醒目顯示、按鍵切換、連結點擊、內容選單顯示等),導致頁面處於相當無用的狀態。

現場示範

如要再次集中查看本文中的所有範例,請參閱下方嵌入的示範影片。

特別銘謝

主頁橫幅圖片由 Tom Wilson 提供,取自 Unsplash