前端基礎
-JS 事件 Event 詳解 - JS 和 HTML 之間的交互動作 (addEventListener、冒泡、捕獲)
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;
更多和更完整的事件類型,可以參考:
這種早期的寫法會有一個問題,就是當我不小心重複監聽相同事件時,新的函數會把舊的覆蓋,例如說:
myBtn.onclick = showMessage;
// 一堆程式碼之後 ...
myBtn.onclick = showModal;
這樣會導致 showMessage 沒有執行,因此後來推出了新的寫法,我們繼續往下看。
現代監聽事件的方法
在 DOM2 定義了兩個方法,用於更好的處理監聽和刪除事件,分別為 addEventListener()
、removeEventListener()
。
所有的 DOM 節點都包含這兩個方法,並且它們都接受 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 事件會依照以下順序傳播:
- <div>
- <body>
- <html>
- document
捕獲事件
當時開發瀏覽器的另一個團隊 Netscape Communicator 團隊提出另一種事件流,叫做事件捕獲 (event capturing)。事件捕獲和事件冒泡的流程是完全相法的,拿相同的例子
<body>
<button>Click me</button>
</body>
如果你點擊了頁面中的 <div> 元素,那這個 click 事件會依照以下順序傳播:
- document
- <html>
- <body>
- <div>
DOM 事件流
最後 DOM 2 Level 採納了這兩種事件流並整合起來,所以現在當你點級的一個元素,總共會有三個事件狀態
- 事件捕獲階段
- 處於目標階段
- 事件冒泡階段
但捕獲跟冒泡這樣的機制,在實作上可能會遇到這樣的問題
- 在 body 監聽 click 事件
- 在 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);
當然,習慣上的命名會是 event
、e
,不建議亂取名字。
事件物件的常見屬性
而事件物件有很多好用的屬性,這裡簡單介紹一些常見的事件物件屬性,以及一些用法:
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 的第三個參數可以傳入
- 布林值,預設 false
- 物件
當傳入布林值時,若傳入 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
在新增後最多只呼叫一次。如果是true
,listener
會在被呼叫之後自動移除(類似自動執行 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、冒泡、捕獲等等觀念,希望這篇能幫助你鞏固知識。
那今天就這樣,下篇貼文見~!