JavaScript 事件深入解析

preventDefaultstopPropagation:兩者的使用時機,以及每個方法實際執行的動作。

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 產生,並如上所述開始傳輸。

在我們的範例中,點擊事件隨後會「傳播」 (這是個重要字詞,因為它會直接與 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"

event.stopPropagation()

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

stopPropagation() 方法可呼叫 (大多數) 原生 DOM 事件。我說「大多數」是因為有少數情況下,呼叫這個方法不會有任何作用 (因為事件一開始就不會傳播)。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"

要不要在冒泡階段的 #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目標階段中呼叫 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() from,因此事件的傳播會在該處停止。

在任何一項即時試用中,我們都鼓勵您盡情操作。請嘗試只點選 #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() 的作用是什麼?聽起來很類似。Does it?

算不上是。這兩者經常會混淆,但實際上彼此關聯不大。看到 preventDefault() 時,請在腦中加上「動作」一詞。請將其視為「防止預設動作」。

您可能會問預設動作是什麼?很抱歉,這個問題的答案並不十分明確,因為這高度取決於所討論的元素和事件組合。更令人困惑的是,有時根本沒有預設動作!

我們先從一個非常簡單的範例開始,您預期點選網頁上的連結後會發生什麼事?顯然,您會希望瀏覽器前往該連結指定的網址。 在本例中,元素是錨點標記,事件則是點擊事件。該組合 (<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」的連結會瀏覽至 www.theavettbrothers.com。不過,在這種情況下,我們已將點擊事件處理常式連線至 <a> 元素,並指定應防止預設動作。因此,使用者點選這個連結時,不會前往任何頁面,控制台只會記錄「Maybe we should just play some of their music right here instead?」(或許我們應該直接在這裡播放他們的音樂?)。

還有哪些元素/事件組合可讓您防止預設動作?我不可能列出所有項目,有時您必須進行實驗才能瞭解。以下簡要說明幾項:

  • <form> 元素 +「submit」事件:preventDefault()這個組合會防止表單提交。如果您想執行驗證,且驗證失敗時可有條件地呼叫 preventDefault,停止提交表單,這項功能就非常實用。

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

  • document +「mousewheel」事件:preventDefault()這個組合可防止使用滑鼠滾輪捲動網頁 (但仍可使用鍵盤捲動)。
    ↜ 這需要使用 { passive: false } 呼叫 addEventListener()

  • document +「keydown」事件:這個組合會造成致命錯誤。preventDefault()這會導致網頁幾乎無法使用,無法透過鍵盤捲動、使用 Tab 鍵,以及醒目顯示鍵盤焦點。

  • document +「mousedown」事件:這個組合會禁止使用滑鼠醒目顯示文字,以及使用滑鼠按下時會觸發的任何其他「預設」動作。preventDefault()

  • <input> 元素 +「keypress」事件:preventDefault() 組合會阻止使用者輸入的字元傳送到輸入元素 (但請勿這麼做;很少有正當理由這麼做,甚至可說沒有)。

  • document +「contextmenu」事件: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