事件冒泡
我們已經知道網頁是由元素組成的——標題、文字段落、圖片、按鈕等等——並且你可以監聽這些元素上發生的事件。例如,你可以給一個按鈕新增一個監聽器,當用戶點選這個按鈕時它就會執行。
我們也看到這些元素可以相互巢狀:例如,一個<button>可以放在一個<div>元素裡面。在這種情況下,我們稱<div>元素為父元素,<button>為子元素。
在本章中,我們將學習事件冒泡——當你給父元素新增事件監聽器,而使用者點選子元素時會發生什麼。
事件冒泡簡介
讓我們透過一個例子來介紹和定義事件冒泡。
在父元素上設定監聽器
考慮這樣一個網頁
<div id="container">
<button>Click me!</button>
</div>
<pre id="output"></pre>
這裡按鈕位於另一個元素,一個<div>元素內部。我們稱這裡的<div>元素是它所包含元素的父級。如果我們給父級新增一個點選事件處理程式,然後點選按鈕,會發生什麼?
const output = document.querySelector("#output");
function handleClick(e) {
output.textContent += `You clicked on a ${e.currentTarget.tagName} element\n`;
}
const container = document.querySelector("#container");
container.addEventListener("click", handleClick);
你會看到當用戶點選按鈕時,父級會觸發一個點選事件
You clicked on a DIV element
這是有道理的:按鈕在<div>裡面,所以當你點選按鈕時,你也隱式地點選了它所在的元素。
冒泡示例
如果我們同時給按鈕和父元素新增事件監聽器,會發生什麼?
<body>
<div id="container">
<button>Click me!</button>
</div>
<pre id="output"></pre>
</body>
讓我們嘗試給按鈕、它的父元素(<div>)以及包含它們的<body>元素新增點選事件處理程式
const output = document.querySelector("#output");
function handleClick(e) {
output.textContent += `You clicked on a ${e.currentTarget.tagName} element\n`;
}
const container = document.querySelector("#container");
const button = document.querySelector("button");
document.body.addEventListener("click", handleClick);
container.addEventListener("click", handleClick);
button.addEventListener("click", handleClick);
你會看到當用戶點選按鈕時,所有三個元素都會觸發一個點選事件
You clicked on a BUTTON element You clicked on a DIV element You clicked on a BODY element
在這種情況下
- 首先觸發按鈕上的點選事件。
- 接著觸發其父級(
<div>元素)上的點選事件。 - 接著觸發
<div>元素的父級(<body>元素)上的點選事件。
我們把這種現象描述為事件從被點選的“最裡層”元素冒泡到其父元素。
這種行為可能很有用,也可能導致意想不到的問題。在接下來的章節中,我們將看到它導致的一個問題,並找到解決方案。
影片播放器示例
在這個例子中,我們的頁面包含一個影片,它最初是隱藏的,還有一個名為“顯示影片”的按鈕。我們希望實現以下互動:
- 當用戶點選“顯示影片”按鈕時,顯示包含影片的框,但不要立即開始播放影片。
- 當用戶點選影片時,開始播放影片。
- 當用戶點選影片外部的框中任何位置時,隱藏該框。
HTML程式碼如下所示
<button>Display video</button>
<div class="hidden">
<video>
<source src="/shared-assets/videos/flower.webm" type="video/webm" />
<p>
Your browser doesn't support HTML video. Here is a
<a href="rabbit320.mp4">link to the video</a> instead.
</p>
</video>
</div>
它包含
- 一個
<button>元素。 - 一個
<div>元素,它最初有一個class="hidden"屬性。 - 一個巢狀在
<div>元素內的<video>元素。
我們正在使用 CSS 來隱藏設定了"hidden"類的元素。
JavaScript 程式碼如下所示
const btn = document.querySelector("button");
const box = document.querySelector("div");
const video = document.querySelector("video");
btn.addEventListener("click", () => box.classList.remove("hidden"));
video.addEventListener("click", () => video.play());
box.addEventListener("click", () => box.classList.add("hidden"));
這添加了三個'click'事件監聽器
- 一個在
<button>上,它顯示包含<video>的<div>。 - 一個在
<video>上,它開始播放影片。 - 一個在
<div>上,它隱藏影片。
我們來看看它是如何工作的
你應該會看到,當你點選按鈕時,影片框和其中包含的影片會顯示出來。但是當你點選影片時,影片開始播放,但影片框又被隱藏了!
影片位於<div>內部——它是<div>的一部分——所以點選影片會同時執行兩個事件處理程式,導致這種行為。
使用stopPropagation()解決問題
正如我們在上一節中看到的,事件冒泡有時會產生問題,但有一種方法可以阻止它。 Event 物件上有一個名為 stopPropagation() 的函式,當在事件處理程式內部呼叫時,它會阻止事件冒泡到任何其他元素。
我們可以透過將 JavaScript 更改為以下內容來解決當前問題
const btn = document.querySelector("button");
const box = document.querySelector("div");
const video = document.querySelector("video");
btn.addEventListener("click", () => box.classList.remove("hidden"));
video.addEventListener("click", (event) => {
event.stopPropagation();
video.play();
});
box.addEventListener("click", () => box.classList.add("hidden"));
我們在這裡所做的只是在<video>元素的'click'事件的處理程式中呼叫事件物件上的stopPropagation()。這將阻止該事件冒泡到框。現在嘗試點選按鈕,然後點選影片
事件捕獲
事件傳播的另一種形式是事件捕獲。這類似於事件冒泡,但順序相反:事件不是首先在最內部的目標元素上觸發,然後依次在巢狀較少的元素上觸發,而是首先在最不巢狀的元素上觸發,然後依次在巢狀更多的元素上觸發,直到到達目標元素。
事件捕獲預設是停用的。要啟用它,您必須在addEventListener()中傳遞capture選項。
這個例子與我們前面看到的冒泡例子非常相似,只是我們使用了capture選項
<body>
<div id="container">
<button>Click me!</button>
</div>
<pre id="output"></pre>
</body>
const output = document.querySelector("#output");
function handleClick(e) {
output.textContent += `You clicked on a ${e.currentTarget.tagName} element\n`;
}
const container = document.querySelector("#container");
const button = document.querySelector("button");
document.body.addEventListener("click", handleClick, { capture: true });
container.addEventListener("click", handleClick, { capture: true });
button.addEventListener("click", handleClick);
在這種情況下,訊息的順序是相反的:<body>事件處理程式首先觸發,接著是<div>事件處理程式,最後是<button>事件處理程式
You clicked on a BODY element You clicked on a DIV element You clicked on a BUTTON element
為什麼要同時使用捕獲和冒泡呢?在過去,當瀏覽器遠不如現在相容時,Netscape 只使用事件捕獲,而 Internet Explorer 只使用事件冒泡。當 W3C 決定嘗試標準化行為並達成共識時,他們最終採用了包含兩者的系統,這也是現代瀏覽器所實現的。
預設情況下,幾乎所有事件處理程式都註冊在冒泡階段,這在大多數情況下更有意義。
事件委託
在上一節中,我們探討了事件冒泡導致的一個問題以及如何解決它。然而,事件冒泡不僅僅是煩人:它也可以非常有用。特別是,它實現了事件委託。在這種做法中,當我們希望在使用者與大量子元素中的任何一個互動時執行一些程式碼時,我們將事件監聽器設定在其父元素上,並讓發生在子元素上的事件冒泡到它們的父元素,而不是不得不為每個子元素單獨設定事件監聽器。
讓我們回到我們的第一個例子,在那裡當用戶點選一個按鈕時,我們設定了整個頁面的背景顏色。假設頁面被分成16個瓷磚,並且我們希望當用戶點選那個瓷磚時,將每個瓷磚設定為一個隨機顏色。
這是HTML程式碼
<div id="container">
<div class="tile"></div>
<div class="tile"></div>
<div class="tile"></div>
<div class="tile"></div>
<div class="tile"></div>
<div class="tile"></div>
<div class="tile"></div>
<div class="tile"></div>
<div class="tile"></div>
<div class="tile"></div>
<div class="tile"></div>
<div class="tile"></div>
<div class="tile"></div>
<div class="tile"></div>
<div class="tile"></div>
<div class="tile"></div>
</div>
我們有一些CSS,用來設定瓷磚的大小和位置
#container {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-auto-rows: 100px;
}
現在在 JavaScript 中,我們可以為每個瓷磚新增一個點選事件處理程式。但是一個更簡單、更高效的選擇是將點選事件處理程式設定在父元素上,並依靠事件冒泡來確保當用戶點選瓷磚時,該處理程式會被執行
function random(number) {
return Math.floor(Math.random() * number);
}
function bgChange() {
const rndCol = `rgb(${random(255)} ${random(255)} ${random(255)})`;
return rndCol;
}
const container = document.querySelector("#container");
container.addEventListener("click", (event) => {
event.target.style.backgroundColor = bgChange();
});
輸出結果如下(嘗試點選它)
注意:在這個例子中,我們使用event.target來獲取事件的目標元素(即最內部的元素)。如果我們要訪問處理此事件的元素(在此情況下是容器),我們可以使用event.currentTarget。
注意:完整原始碼請參見useful-eventtarget.html;也可在此處即時執行。
target和currentTarget
如果您仔細觀察本頁中介紹的示例,您會發現我們正在使用事件物件的兩個不同屬性來訪問被點選的元素。在在父元素上設定監聽器中,我們使用event.currentTarget。然而,在事件委託中,我們使用event.target。
區別在於,target指的是事件最初觸發的元素,而currentTarget指的是附加此事件處理程式的元素。
當事件冒泡時,target保持不變,而對於層次結構中附加到不同元素的事件處理程式,currentTarget將不同。
如果我們將上面的冒泡示例稍作修改,就可以看到這一點。我們使用與之前相同的 HTML
<body>
<div id="container">
<button>Click me!</button>
</div>
<pre id="output"></pre>
</body>
JavaScript 程式碼幾乎相同,只是我們同時記錄了target和currentTarget
const output = document.querySelector("#output");
function handleClick(e) {
const logTarget = `Target: ${e.target.tagName}`;
const logCurrentTarget = `Current target: ${e.currentTarget.tagName}`;
output.textContent += `${logTarget}, ${logCurrentTarget}\n`;
}
const container = document.querySelector("#container");
const button = document.querySelector("button");
document.body.addEventListener("click", handleClick);
container.addEventListener("click", handleClick);
button.addEventListener("click", handleClick);
請注意,當我們點選按鈕時,每次target都是按鈕元素,無論事件處理程式是附加到按鈕本身、<div>還是<body>。然而,currentTarget標識了我們當前正在執行其事件處理程式的元素
target屬性通常用於事件委託,如我們上面事件委託示例所示。
總結
您現在應該已經掌握了這個早期階段關於 Web 事件所需的所有知識。如前所述,事件並非真正屬於核心 JavaScript 語言的一部分——它們是在瀏覽器 Web API 中定義的。
在下一篇文章中,我們將為您提供一些測試,您可以用來檢查您對我們提供的所有事件資訊的理解和記憶程度。
另見
- domevents.dev
-
一個有用的互動式遊樂場應用程式,可以透過探索學習 DOM 事件系統的行為。
- DOM 事件
-
一份關於理解和處理事件的全面指南。
- 事件順序
-
Peter-Paul Koch 對捕獲和冒泡的精彩詳細討論。