Learning Objectives
- 說明事件、事件物件與事件處理器(事件監聽器),並比較 HTML 屬性、onclick 屬性與 addEventListener 三種寫法
- 用 addEventListener 加入多個處理器,用 removeEventListener 移除,並說明處理器裡 this 與 e.currentTarget 的意思
- 使用事件物件的 type、target、clientX、key 等屬性,處理 click、mouseenter、keydown、input 等常用事件
- 說明事件流的捕獲、目標與冒泡階段,並用 stopPropagation、preventDefault 與事件委派處理事件
事件:網頁對使用者的回應
使用者點按鈕、移動滑鼠、在輸入框打字,瀏覽器都會產生一個事件(event)。網頁要有互動,就是「某個事件發生時,執行某段程式」。
| 名詞 | 意思 | 例子 |
|---|---|---|
| 事件(event) | 發生了什麼事 | click、mouseover、keydown、input、submit |
| 事件物件(event object) | 這次事件的詳細資訊,瀏覽器自動傳給處理器 | 點在哪裡(clientX)、按了哪個鍵(key)、發生在哪個元素(target) |
| 事件處理器(event handler) | 事件發生時要執行的函式,也叫事件監聽器(event listener) | function (e) { … } |
事件不只來自使用者,也可能來自瀏覽器本身(網頁載入完成的 load)、非同步工作(下載完成),或由程式自己觸發。
三種加上事件處理器的寫法
<!-- 1. HTML 屬性(嵌入式,DOM 0 級) -->
<button onclick="sayHi()">按我</button>
// 2. 指定元素的 on… 屬性(傳統寫法,DOM 0 級):名稱一律小寫
btn.onclick = sayHi;
btn.onclick = null; // 移除
// 3. addEventListener(W3C 標準,DOM 2 級):事件名稱不加 on
btn.addEventListener('click', sayHi);
btn.removeEventListener('click', sayHi);
| HTML 屬性 | onclick 屬性 |
addEventListener |
|
|---|---|---|---|
| 程式寫在哪裡 | HTML 裡 | JavaScript 裡 | JavaScript 裡 |
| 同一個事件幾個處理器 | 一個 | 一個(再指定一次會取代前一個) | 多個,依加入順序執行 |
| 可以選擇捕獲階段 | 不行 | 不行 | 可以(第三個參數) |
HTML 屬性把程式和 HTML 混在一起,不好除錯與維護,看得懂即可;新的程式一律用 addEventListener。
(很舊的 IE 瀏覽器有自己的 attachEvent,現在已經不需要了。)
addEventListener(事件名稱, 處理器, 第三個參數) 的規則:
- 第三個參數是
true表示在捕獲階段執行,省略或false表示在冒泡階段執行(後面說明);也可以傳物件,例如{ once: true }表示只執行一次後自動移除。 - 同一個函式對同一個事件重複加入,只會留下一個。
- 要移除時,
removeEventListener必須傳入同一個函式。直接寫在參數裡的匿名函式沒有名字可以參照,就移除不了。 - 處理器用一般函式時,裡面的
this就是掛上監聽器的元素(等於e.currentTarget);用箭頭函式時this不是元素,請改用e.currentTarget。
加入與移除處理器的規則
執行動畫在背景執行緒裡跑,沒有網頁與按鈕,所以用 EventTarget 物件代替按鈕(網頁上的元素都是 EventTarget),用 dispatchEvent 觸發事件。猜猜看:第一次 click 時 sayHi 會執行幾次?兩個內容一樣的箭頭函式呢?
想一想:事件處理器
下面的程式執行後點一下按鈕,主控台會出現什麼?
btn.onclick = () => console.log('A');
btn.onclick = () => console.log('B');
下面的程式會印出什麼?
const t = new EventTarget();
let n = 0;
function f() { n++; }
t.addEventListener('go', f);
t.addEventListener('go', f);
t.dispatchEvent(new Event('go'));
console.log(n);
btn.addEventListener('click', () => alert('嗨')) 之後,怎麼樣才能移除這個處理器?
事件物件與常用事件
處理器的第一個參數就是事件物件,習慣命名為 e 或 event:
pad.addEventListener('click', function (e) {
console.log(e.type); // 'click'
console.log(e.target); // 實際被點到的元素
console.log(e.clientX, e.clientY); // 滑鼠相對於瀏覽器可視區域的座標
});
| 屬性 | 意思 |
|---|---|
e.type |
事件名稱 |
e.target |
事件真正發生的元素(例如被點到的最內層元素) |
e.currentTarget |
目前執行的這個監聽器掛在哪個元素上 |
e.clientX/e.clientY |
滑鼠相對於瀏覽器可視區域的座標 |
e.offsetX/e.offsetY |
滑鼠相對於目標元素左上角的座標 |
e.screenX/e.screenY |
滑鼠相對於整個螢幕的座標 |
e.button |
按下的是哪一個滑鼠按鍵(0 左鍵、1 中鍵、2 右鍵);e.buttons 可以同時表示多個按鍵 |
e.key |
鍵盤事件按下的鍵,例如 'a'、'Enter'、'ArrowUp' |
滑鼠事件:
| 事件 | 何時發生 |
|---|---|
mousedown/mouseup |
按下/放開滑鼠按鍵 |
click |
單擊;順序是 mousedown → mouseup → click |
dblclick |
雙擊;在兩次 mousedown → mouseup → click 之後才發生 |
mousemove |
滑鼠在元素裡移動,移動時會連續發生很多次 |
mouseenter/mouseleave |
滑鼠進入/離開元素;不會冒泡,移到子元素上也不會再發生 |
mouseover/mouseout |
滑鼠進入/離開元素;會冒泡,在子元素之間移動時會發生很多次 |
contextmenu |
按滑鼠右鍵 |
鍵盤與輸入事件:keydown(按下按鍵)、keyup(放開按鍵)、input(輸入框內容每次改變)、change(內容改變且離開輸入框,或選項改變)。
舊的 keypress 已經不建議使用,請改用 keydown。
事件流:捕獲、目標與冒泡
按鈕放在 div 裡,div 又放在 body 裡。點了按鈕,等於也點了外面的 div 與 body。
那麼,掛在這三個元素上的 click 處理器,誰先執行?瀏覽器把一次事件的傳遞分成三個階段:
- 捕獲階段(capturing phase):事件從最外層的
window、document一路往內,傳到目標元素的外層 - 目標階段(at target):到達真正被點到的元素
- 冒泡階段(bubbling phase):事件再從目標一路往外傳回
document
捕獲 ↓ document → body → div → [ button ] ← 目標
冒泡 ↑ document ← body ← div ← [ button ]
addEventListener 預設在冒泡階段執行,所以點按鈕時的順序是 button → div → body(由內往外)。
第三個參數傳 true,處理器就改在捕獲階段執行,會比內層的處理器先執行。
處理器裡呼叫 e.stopPropagation(),事件就不再往下一個元素傳遞。不過它也會讓外層需要知道這次點擊的程式(例如點空白處關閉選單)收不到事件,非必要不要用。
下面的小工具在三層元素上掛了真的監聽器,由瀏覽器實際派送事件,並依序亮起執行到的元素。
事件委派與 preventDefault
事件委派(event delegation):因為事件會冒泡,不必幫清單的每一項都掛監聽器,只要在外層的 <ul> 掛一個,
再用 e.target 判斷點到哪一項:
todo.addEventListener('click', (e) => {
const li = e.target.closest('li'); // 從被點到的元素往外找最近的 <li>
if (!li) return; // 點到的不是任何一項
li.classList.toggle('done');
});
事件委派有兩個好處:監聽器只有一個;之後才新增的項目自動就有效果,不必再幫它掛監聽器。
這裡的 e.currentTarget 是 <ul>(監聽器掛的地方),e.target 是實際被點到的元素。用 closest('li') 是因為項目裡可能還有 <b> 這類子元素,e.target 不一定剛好是 <li>。
preventDefault:有些事件有瀏覽器的預設動作:點連結會前往網址、送出表單會重新載入頁面、點核取方塊會勾選。
在處理器裡呼叫 e.preventDefault() 就能取消預設動作,改由你的程式決定要做什麼。它和 stopPropagation 不同:
preventDefault 取消的是預設動作,事件仍然會繼續冒泡。下一課的表單驗證會一直用到它。
想一想:事件物件與事件流
<div id="box"><button>按我</button></div>,box 與按鈕都用 addEventListener('click', …) 掛了處理器(沒有第三個參數)。點按鈕時,哪一個先執行?
用事件委派在 <ul> 上處理清單點擊,最主要的好處是什麼?
要讓表單送出時不要重新載入頁面,處理器裡要呼叫哪一個方法?
滑鼠在一個有很多子元素的 <div> 裡移動,哪一個事件只會在進入 div 時發生一次?
btn.addEventListener('click', () => console.log(this === btn)),點按鈕時會印出什麼?
重點精華回顧
- 事件是發生了什麼事,事件物件帶著這次事件的資訊,事件處理器(監聽器)是事件發生時要執行的函式。
- 三種寫法:HTML 屬性
onclick="…"、元素的onclick屬性(一個事件只能有一個處理器)、addEventListener(可以有多個,建議使用)。 removeEventListener要傳入同一個函式,匿名函式移除不了;{ once: true }讓處理器只執行一次。- 事件物件常用的屬性:
type、target、currentTarget、clientX/offsetX、key。 - 滑鼠單擊的順序是
mousedown→mouseup→click;mouseenter/mouseleave不會冒泡,mouseover/mouseout會。輸入框每次改變用input,改完離開才觸發的是change。 - 事件流分成捕獲、目標、冒泡三個階段;
addEventListener預設在冒泡階段執行,第三個參數true改成捕獲階段。 - 事件委派:在外層掛一個監聽器,用
e.target.closest(…)判斷點到哪一項;preventDefault取消預設動作,stopPropagation停止事件傳遞。