JavaScript 事件深入解析

preventDefaultstopPropagation:使用時機和每個方法的確切內容。

史蒂芬史圖爾
Stephen Stchur

Event.stopPropagation() 和 Event.preventDefault()

JavaScript 事件處理通常非常簡單。處理簡易 (相對平坦) HTML 結構時更是如此。不過,當事件透過元素階層進行移動 (或傳播) 時,情況會變得更加複雜。這通常是因為開發人員為 stopPropagation() 和/或 preventDefault() 解決問題,才能解決遇到的問題。如果您曾想過「我們只試用 preventDefault(),如果這個方法無效,我就試試 stopPropagation();如果無效,我不妨試試這兩種方法」,就是想體驗一下這篇文章!我會詳細說明每種方法的用途、使用時機,並提供一些實用的範例,方便您探索。我的目標是要徹底解決這個困惑,

不過在深入說明之前,請務必簡單說明 JavaScript 中有兩種可能的事件處理方式 (在所有新式瀏覽器中,即第 9 版之前的 Internet Explorer 完全不支援事件擷取)。

事件樣式 (拍攝和冒泡)

所有新版瀏覽器都支援事件擷取功能,但開發人員很少使用這種做法。有趣的是,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 發出,並如上所述開始行程。

在本範例中,點擊事件會由各個元素從 window#C 之間的各個元素傳播 (這是重要的字詞,因為這是 stopPropagation() 方法的直接關聯,本文件稍後將會說明) window 到目標元素 (在本例中為 #C)。

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

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

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

接下來,事件會傳播<html> 元素,瀏覽器會詢問:「在擷取階段,是否有監聽 <html> 元素的任何點擊?」如果答案為肯定,系統會觸發適當的事件處理常式。

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

接著,事件會傳播#A 元素。再次,瀏覽器會詢問:「是否在擷取階段監聽 #A 的點擊事件,如果會,就會觸發適當的事件處理常式。

接著,活動會傳播#B 元素,並詢問同一個問題。

最後,事件會到達目標,瀏覽器會詢問:「在擷取階段,是否在監聽 #C 元素的點擊事件?」這次答案是「當然!」事件位於目標的短暫時間,稱為「目標階段」。此時,事件處理常式會觸發,瀏覽器會 console.log「#C was 點選」然後結束。答錯了!我們並非一切完成。流程繼續進行,但現在改變了興奮階段。

活動泡泡

瀏覽器會詢問:

「是否有任何在準備階段監聽 #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,
);

你可以在下方的現場示範中,透過互動方式玩遊戲。按一下「The Avett Brothers」連結,查看主控台輸出內容 (還有您未重新導向至 Avett Brothers 網站)。

通常,按一下標示著 The Avett Brothers 的連結會前往 www.theavettbrothers.com。不過,我們已將點擊事件處理常式連結至 <a> 元素,並指定要禁止預設動作。因此,使用者點選這個連結時不得前往任何頁面,控制台只會記錄「或許我們應該直接在這裡播放音樂嗎?」

你還能利用哪些其他元素/事件組合防止特定動作?我無法將所有內容一一列出 有時您只是要做實驗看看以下簡要說明其中幾項:

  • <form> 元素 +「提交」事件:如果將這個組合設為 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(),提醒您,系統不會執行預設動作。因此,所有預設動作 (例如滑鼠滾輪捲動、鍵盤捲動或醒目顯示或 Tab 鍵、連結點擊、內容選單顯示等) 都會受到阻止,導致頁面處於無使用狀態。

現場示範

如要在同一處再次探索本文中的所有範例,請查看下方的嵌入式示範。

特別銘謝

Tom WilsonUnsplash 上提供的主頁橫幅。