前端基礎

-

JS 事件 Event 詳解 - JS 和 HTML 之間的交互動作 (addEventListener、冒泡、捕獲)

this.web

JavaScript 和 HTML 之間的交互動作是靠事件 Event 實現的。當文檔或瀏覽器發生一些用戶行為的瞬間(例如點擊、滾動、縮小放大視窗…等等),可以利用監聽器 Listener 來監聽並執行要執行的 JS。

而事件裡也有一些細節需要注意,例如事件物件、冒泡、捕獲 … 等等,本為由簡入深,全部詳細的說給你。

監聽事件 - 早期監聽事件的方法

在早期 (DOM Level 2 之前),我們要監聽元素是否被使用者執行某些行為時,要使用 on + [行為],例如我們希望某個按鈕被點擊後,跳出提示框:

<button onclick="alert('Hello World!')">點擊我</button>

整個 code 的意思是,當我點擊(onclick) 這個 button 時,要執行裡面的函數。

我們也可以先在 JS 寫好,並在 HTML Tag 裡直接使用:

<script>
function showMessage() {
  alert('Hello World!');
}
</script>
<button onclick="showMessage()">點擊我</button>

要注意的地方是,如果點擊 button 時,還沒有宣告 showMessage 這個函數,就會出錯。所以我們更常在 JS 裡去監聽事件,而不是在 HTML。舉例來說:

<button class="myBtn">點擊我</button>

<script>
// 獲取元素
const myBtn = document.querySelector('.myBtn');
// 宣告函數
function showMessage() {
  alert('Hello World!');
}
// 監聽事件
myBtn.onclick = showMessage;
</script>

用 JS 的另一個好處是利於管理,我們不需要再 HTML 找到底哪個元素有被監聽事件,哪些沒有。

而如果我們要取消監聽事件,只要設定 null 就好。

myBtn.onclick = null;

更多和更完整的事件類型,可以參考:

  1. HTML DOM Event Object
  2. HTML DOM 事件对象 | 菜鸟教程

這種早期的寫法會有一個問題,就是當我不小心重複監聽相同事件時,新的函數會把舊的覆蓋,例如說:

myBtn.onclick = showMessage;

// 一堆程式碼之後 ...

myBtn.onclick = showModal;

這樣會導致 showMessage 沒有執行,因此後來推出了新的寫法,我們繼續往下看。

現代監聽事件的方法

在 DOM2 定義了兩個方法,用於更好的處理監聽和刪除事件,分別為 addEventListener()removeEventListener()

所有的 DOM 節點都包含這兩個方法,並且它們都接受 3 個參數:

  1. 要處理的事件名稱
  2. 要被執行的函數
  3. 一個 boolean 值或物件 (可選)

前面兩個蠻好懂得,當最後一個參數是 true,則代表在捕獲階段就調用函數處理,若為 false,表示在冒泡階段才調用函數。並且也可以傳入物件做更詳細的設定,如只觸發一次、不阻止預設行為,或是設定取消監聽的條件

關於第三個參數的詳細解釋我們放在更下面說明。

所以我們可以接 onclick 改寫成這樣

<button class="myBtn">點擊我</button>

<script>
// 獲取元素
const myBtn = document.querySelector('.myBtn');
// 宣告函數
function showMessage() {
  alert('Hello World!');
}
// 監聽事件
myBtn.addEventListener('click', showMessage); // 第三個參數可以不填
</script>

而通過 addEventListener 添加的事件,只能透過 removeEventListener 移除:

myBtn.removeEventListener('click', showMessage);

我們也可以用匿名函數或箭頭函數來監聽事件:

// 一般匿名函數
myBtn.addEventListener('click', function() {
  console.log('Hello World!')
});

// 箭頭函數
myBtn.addEventListener('click', () => {
  console.log('Hello World!')
});

用 addEventListener 的好處就是可以重複監聽,彼此不會覆蓋

重複監聽事件

當我們用 addEventListener 重複監聽事件時,會照順序執行,例如:

並且,我們也跟以單獨針對指定的監聽事件去做移除:

myBtn.removeEventListener('click', showMessage2) // 移除 showMessage2 事件

事件流 - 冒泡、捕獲

當流器發展到第四代時 (IE4),瀏覽器開發團隊遇到一個很有意思的問題:處發事件的究竟是頁面的哪個指定元素。

可以想像一在一張紙上,有多個同心圓,如果你把手指放在圓心上,那你的手指指像的究竟是一個元,還是指上所有的圓?而當時瀏覽器開發團隊一致認同是後者。

也就是說,當你點擊某個按鈕時,點擊事件不指發生在按鈕上,而是整個頁面。而事件流就是描述頁面接收事件的順序。

如圖,如果頁面上有多個圓,當我們指像最內部的圓時,所有的圓都會觸發事件。

<div class="circle-1">
  <div class="circle-2">
    <div class="circle-3">
      <div class="circle-4"></div>
    </div>
  </div>
</div>

事件冒泡

IE 當時提出的事件流叫為事件冒泡 (event bubbling),即事件開始時,最深層的節點接收,然後逐級像上傳播到較為不具體的節點。以這個 HTML 為例

<html>
<body>
  <div>Click me</div>
</body>
</html>

如果你點擊了頁面中的 <div> 元素,那這個 click 事件會依照以下順序傳播:

  1. <div>
  2. <body>
  3. <html>
  4. document
image

捕獲事件

當時開發瀏覽器的另一個團隊 Netscape Communicator 團隊提出另一種事件流,叫做事件捕獲 (event capturing)。事件捕獲和事件冒泡的流程是完全相法的,拿相同的例子

<body>
  <button>Click me</button>
</body>

如果你點擊了頁面中的 <div> 元素,那這個 click 事件會依照以下順序傳播:

  1. document
  2. <html>
  3. <body>
  4. <div>
image

DOM 事件流

最後 DOM 2 Level 採納了這兩種事件流並整合起來,所以現在當你點級的一個元素,總共會有三個事件狀態

  1. 事件捕獲階段
  2. 處於目標階段
  3. 事件冒泡階段
image

但捕獲跟冒泡這樣的機制,在實作上可能會遇到這樣的問題

  1. 在 body 監聽 click 事件
  2. 在 body 裡的 button 監聽 click 事件

當我們點擊 button 時,整的事件流是

(開始捕獲) → body → button (目標元素) → (開始冒泡) → body

但我希望點擊 button 時,不要觸發 body 的 click 事件,這時候怎麼辦呢?此時就要提到事件物件這個東西了。

事件物件

在觸發 DOM 上的事件時,JS 會自動產生一個事件物件 (通常被命名為 event 或 e),這個物件包含所有和事件有關的訊息,包含導致事件的元素、事件的類型等等。

要使用 event 這個事件物件的方式很簡單,指要在事件觸發函數傳入一個參數即可:

<body>
  <button>Click me</button>
</body>
// 獲取元素
const myBtn = document.querySelector('.myBtn');
// 監聽事件
myBtn.addEventListener('click', function(event) { // 👈 傳入 event 參數
  console.log(event.type) // 打印事件類型,這裡是 'click'
});

// 點擊按鈕後
// 'click'

我們也可以單獨宣告具名函數並傳入參數:

// 獲取元素
const myBtn = document.querySelector('.myBtn');
// 宣告函數
function showMessage(event) { // 👈 傳入 event 參數,結果一樣
console.log(event.type);
}
// 監聽事件
myBtn.addEventListener('click', showMessage);

// 點擊按鈕後
// 'click'

新手特別容易搞混的是,只要位於事件函數的第一個參數,就是事件物件,名字是可以亂取的,例如很常簡化指寫 e

function showMessage(e) {  // 👈 傳入 e 參數,結果一樣
  console.log(e.type);
}
// 監聽事件
myBtn.addEventListener('click', showMessage);

你要亂取名也可以

function showMessage(thisweb) {  // 👈 傳入 thisweb 參數,結果一樣
  console.log(thisweb.type);
}
// 監聽事件
myBtn.addEventListener('click', showMessage);

當然,習慣上的命名會是 evente,不建議亂取名字。

事件物件的常見屬性

而事件物件有很多好用的屬性,這裡簡單介紹一些常見的事件物件屬性,以及一些用法:

  • event.type:事件的類型(如 "click", "keydown" 等)。
  • event.target:觸發事件的元素。
  • event.currentTarget:綁定事件監聽器的元素。
  • event.clientX / event.clientY:提供了事件發生時的鼠標在視窗中的位置。這在做某些網頁特效時特別好用
  • event.keyCode:在鍵盤事件中,返回被按下鍵的代碼。
  • event.preventDefault():一個方法,用於阻止事件的默認行為(如阻止表單提交)。
  • event.stopPropagation()阻止事件冒泡到父元素

回到事件流的問題,要如何在點擊 button 時,不要觸發 body 的事件呢,沒錯,就是這裡的 event.stopPropagation()

// 獲取 body 元素並添加 click 事件監聽器
document.body.addEventListener('click', function() {
  console.log('Body 被點擊了');
});

// 獲取 button 元素並添加 click 事件監聽器
const button = document.querySelector('button');
button.addEventListener('click', function(event) {
  console.log('Button 被點擊了');
  
  // 阻止事件繼續冒泡到 body
  event.stopPropagation();
});

小知識:event.stopPropagation() 可以阻止冒泡,也可以阻止捕獲。網路上很常都只提到阻止冒泡,這其實和 addEventListner 的第三個參數有關。

AddEventListener 的第三個參數

AddEventListener 的第三個參數可以傳入

  1. 布林值,預設 false
  2. 物件

當傳入布林值時,若傳入 true,則代表是再捕獲階段執行事件;若傳入 false,則代表是在冒泡階段執行事件。

所以 stopPropagation() 是阻止冒泡還是捕獲,是由第三個參數決定,還是前面的例子

<body>
  <button>Click me</button>
</body>
// 獲取 body 元素並添加 click 事件監聽器
document.body.addEventListener('click', function() {
  console.log('Body 被點擊了');
  
  // 阻止事件繼續捕獲到 button
  event.stopPropagation();
  
}, true); // 👈 傳入 true 代表是捕獲階段執行函數

const button = document.querySelector('button');
button.addEventListener('click', function(event) {
  console.log('Button 被點擊了'); // 因為在 body 就被阻止事件捕獲的,這個永遠不會觸發
});

當點擊 button 時,由於 body 再捕獲階段就觸發函數,執行 event.stopPropagation() 所以不會觸發到 button 的事件。

而若傳入物件,可有這些選項:

const options = {
  capture: false,
  once: false,
  passive: false,
  signal: AbortSignal
}

myBtn.addEventListener("click", showMessage, options);
  • capture: Boolean。 同上面的布林值,若傳入 true,則代表是再捕獲階段執行事件;若傳入 false,則代表是在冒泡階段執行事件。
  • once: Boolean。表示listener在新增後最多只呼叫一次。如果是truelistener會在被呼叫之後自動移除(類似自動執行 removeEventListener)
  • passive: Boolean。表示listener永遠不會呼叫 event.preventDefault()。如果listener 仍然呼叫了這個函數,瀏覽器會忽略它並拋出一個控制台警告。
  • signal : AbortSignal。調用給定 AbortSignal 物件 abort() 的方法時,偵聽器將被刪除。

前面兩個參數都比較好懂,如果我們確定不會用到 event.preventDefault() 就可以用第三個參數則加速效能。

第四個參數比較特別,他是 JS 的另一個 Web API - AbortSignal,他允許我們能手動取消非同步的請求。這裡不多說太多,但放個簡單的示範參考

function showMessage(e) {
  console.log(e.type);
}

const controller = new AbortController();
const signal = controller.signal; // 宣告 AbortSignal 

const options = {
  signal: signal
};

myBtn.addEventListener("click", showMessage, options); // 將 AbortSignal 傳入 addEventListener

setTimeout(() => {
  controller.abort();
  console.log("stop");
}, 5000); 

// 5 秒前監聽事件正常運作

// 5 秒後因為調用了 controller.abort()
// 自動移除監聽事件

小結

到這邊就很詳細的解釋了事件監聽 addEventListener、冒泡、捕獲等等觀念,希望這篇能幫助你鞏固知識。

那今天就這樣,下篇貼文見~!

相關系列文章