preventDefault
和 stopPropagation
:兩者的使用時機,以及每個方法實際執行的動作。
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>
=> 等等,直到達到目標為止。
無論 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"
event.stopPropagation()
瞭解事件的來源,以及事件在擷取階段和冒泡階段中如何透過 DOM 傳播 (即傳播) 後,我們現在可以將注意力轉向 event.stopPropagation()
。
stopPropagation()
方法可呼叫 (大多數) 原生 DOM 事件。我說「大多數」是因為有少數情況下,呼叫這個方法不會有任何作用 (因為事件一開始就不會傳播)。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"
要不要在冒泡階段的 #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
,因此在這個程式碼片段中,我們會停止所有 click
、keydown
、mousedown
、contextmenu
和 mousewheel
事件,避免這些事件傳送至任何可能正在監聽這些事件的元素。我們也會呼叫 stopImmediatePropagation
,確保文件後續連結的任何處理常式也會遭到阻撓。
請注意,stopPropagation()
和 stopImmediatePropagation()
並非 (至少不是大部分) 導致網頁無法使用的原因。這類事件只會阻止事件傳送至原本的目的地。
但我們也會呼叫 preventDefault()
,您會記得這會防止預設的動作。因此,系統會禁止所有預設動作 (例如滑鼠滾輪捲動、鍵盤捲動或醒目顯示/使用 Tab 鍵、點選連結、顯示內容選單等),導致網頁處於相當無用的狀態。
特別銘謝
主頁橫幅圖片來源:Tom Wilson,Unsplash。