preventDefault
和 stopPropagation
:使用時機和各個方法的具體功能。
Event.stopPropagation() 和 Event.preventDefault()
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>
=>,以此類推,直到達成目標為止。
無論 window
或 document
或 <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()
方法。我說「大多數」是因為在少數情況下,呼叫此方法不會執行任何動作 (因為事件不會傳播至開始)。focus
、blur
、load
、scroll
和其他幾個事件都屬於這個類別。您可以呼叫 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
開始,因此在這個程式碼片段中,我們會停止所有 click
、keydown
、mousedown
、contextmenu
和 mousewheel
事件,讓這些事件無法傳送至任何可能會監聽的元素。我們也會呼叫 stopImmediatePropagation
,這樣一來,任何在這個處理常式之後連線至文件的處理常式,也會遭到阻斷。
請注意,stopPropagation()
和 stopImmediatePropagation()
並不會 (至少) 轉譯無用頁面的情形。只是防止事件前往原本前往的位置。
但我們也會呼叫 preventDefault()
,您會發現這會阻止預設動作。因此,系統會禁止所有預設動作 (例如滑鼠滾輪捲動、鍵盤捲動或醒目顯示、按鍵切換、連結點擊、內容選單顯示等),導致頁面處於相當無用的狀態。
現場示範
如要再次集中查看本文中的所有範例,請參閱下方嵌入的示範影片。
特別銘謝
主頁橫幅圖片由 Tom Wilson 提供,取自 Unsplash。