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()

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"

您可以在下方的直播示範中,與此互動式遊戲互動。按一下即時示範中的 #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> 元素 + 「submit」事件:preventDefault() 這個組合會防止表單提交。如果您想執行驗證,且發生錯誤,則可有條件地呼叫 preventDefault,停止表單提交。

  • <a> 元素 +「點擊」事件: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(),您會發現這會阻止預設動作。因此,系統會禁止所有預設動作 (例如滑鼠滾輪捲動、鍵盤捲動或醒目顯示或選取,連結點擊、內容選單顯示等),導致頁面處於相當無用的狀態。

現場示範

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

特別銘謝

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