本系列文章已重新編修,並在加入部分 ES6 新篇章後集結成書,有興趣的朋友可至天瓏書局選購,感謝大家支持。
購書連結 https://www.tenlong.com.tw/products/9789864344130
讓我們再次重新認識 JavaScript!
JavaScript 是一個事件驅動 (Event-driven) 的程式語言,當瀏覽器載入網頁開始讀取後,雖然馬上會讀取 JavaScript 事件相關的程式碼,但是必須等到「事件」被觸發(如使用者點擊、按下鍵盤等)後,才會再進行對應程式的執行。
什麼意思呢?
就好比辦公室擺了一台電話在桌上,但是電話要是沒響,我們不會主動去「接電話」 (沒人打來當然也無法主動接) 。
電話響了 (事件被觸發) -> 接電話 (去做對應的事)
換以我們很常見的網頁對話框 UI 來說,當使用者「按下了按鈕」之後,才會啟動對話框的顯示。 如果使用者沒有按下按鈕,就狂跳對話框,那使用者一定覺得這網站壞掉了吧。
以 Bootstrap Modal 為例:
如同上面範例,當使用者點擊了按鈕,才會啟動對話框的顯示,那麼「點擊按鈕」這件事,就被稱作「事件」(Event),而負責處理事件的程式通常被稱為「事件處理者」(Event Handler),也就是「啟動對話框的顯示」這個動作。
假設有兩個重疊的 div
元素,外層是 <div id="outer">
,而內層是 <div id="inner">
:
<div id="outer">
<div id="inner">
</div>
</div>
這時內層的位置一定在外層裡面對吧 (先不管 position: absolute;
的可能性),這表示 inner
也是 outer
的一部分。 那麼,當我們點擊了 inner
的時候,是不是代表我們也點擊到 outer
,甚至再看遠一點,可以說實際上我們也點擊到整個網頁。
而事件流程 (Event Flow) 指的就是「網頁元素接收事件的順序」。
事件流程可以分成兩種機制:
接著就來分別介紹事件的這兩種機制。
圖片來源: Event Flow: capture, target, and bubbling
事件冒泡指的是「從啟動事件的元素節點開始,逐層往上傳遞」,直到整個網頁的根節點,也就是 document
。
假設 HTML 如下:
<!DOCTYPE html>
<html>
<head>
<title>TITLE</title>
</head>
<body>
<div>CLICK</div>
</body>
</html>
假設我們點擊 (click) 了 <div>CLICK</div>
元素,那麼在「事件冒泡」的機制下,觸發事件的順序會是:
<div>CLICK</div>
<body>
<html>
document
像這樣 click
事件逐層向上依序被觸發,就是「事件冒泡」機制。
圖片來源: Event Flow: capture, target, and bubbling
剛剛說過「事件冒泡」機制是由下往上來傳遞,那麼「事件捕獲」(Event Capturing) 機制則正好相反。
假設 HTML 同樣如下:
<!DOCTYPE html>
<html>
<head>
<title>TITLE</title>
</head>
<body>
<div>CLICK</div>
</body>
</html>
假設我們點擊 (click) 了 <div>CLICK</div>
元素,那麼在「事件捕獲」的機制下,觸發事件的順序會是:
document
<html>
<body>
<div>CLICK</div>
像這樣 click
事件由上往下依序被觸發,就是「事件捕獲」機制。
既然事件傳遞順序有兩種機制,那我怎麼知道事件是依賴哪種機制執行的?
答案是:兩種都會執行。
圖片來源: W3C, DOM event flow
假設現在的事件是點擊上圖中藍色的 <td>
。
那麼當 td 的 click
事件發生時,會先走紅色的 「capture phase」:
Document
<html>
<body>
<table>
<tbody>
<tr>
<td>
(實際被點擊的元素)由上而下依序觸發它們的 click
事件。
然後再繼續執行綠色的 「bubble phase」,反方向由 <td>
一路往上傳至 Document
,整個事件流程到此結束。
要檢驗事件流程,我們可以透過 addEventListener()
方法來綁定 click
事件:
<div>
<div id="parent">
父元素
<div id="child">子元素</div>
</div>
</div>
// 父元素
var parent = document.getElementById('parent');
// 子元素
var child = document.getElementById('child');
// 透過 addEventListener 指定事件的綁定
// 第三個參數 true / false 分別代表捕獲/ 冒泡 機制
parent.addEventListener('click', function () {
console.log('Parent Capturing');
}, true);
parent.addEventListener('click', function () {
console.log('Parent Bubbling');
}, false);
child.addEventListener('click', function () {
console.log('Child Capturing');
}, true);
child.addEventListener('click', function () {
console.log('Child Bubbling');
}, false);
當我點擊的是「子元素」的時候,透過 console.log
可以觀察到事件觸發的順序為:
"Parent Capturing"
"Child Capturing"
"Child Bubbling"
"Parent Bubbling"
而如果直接點擊「父元素」,則出現:
"Parent Capturing"
"Parent Bubbling"
由此可知,點擊子元素的時候,父層的 Capturing
會先被觸發,然後再到子層內部的 Capturing
或 Bubbling
事件。 最後才又回到父層的 Bubbling
結束。
那麼,子層的 Capturing
或 Bubbling
誰先誰後呢?
要看你程式碼的順序而定。
若是 Capturing
在 Bubbling
前面:
child.addEventListener('click', function () {
console.log('Child Capturing');
}, true);
child.addEventListener('click', function () {
console.log('Child Bubbling');
}, false);
則會得到:
"Child Capturing"
"Child Bubbling"
若是將兩段程式碼順序反過來的話,就會是這樣了:
child.addEventListener('click', function () {
console.log('Child Bubbling');
}, false);
child.addEventListener('click', function () {
console.log('Child Capturing');
}, true);
"Child Bubbling"
"Child Capturing"
剛剛我們看過 addEventListener
方法對事件的綁定,事實上綁定事件的方式還有其他方式,我們這裡來一一介紹。
對 HTML 元素來說,只要支援某個「事件」的觸發,就可以透過 on+事件名
的屬性來註冊事件:
<button id="btn" onclick="console.log('HI');">Click</button>
如同上面範例,透過 onclick
屬性,我們就可以在 <button>
元素上面註冊 click
事件,換句話說,當我按下這個 <button>
元素時,就會執行 console.log('HI');
的程式碼。
但是需要注意的是,基於程式碼的使用性與維護性考量,現在已經不建議用此方式來綁定事件,詳情可參考「維基百科: 非侵入式JavaScript」條文,或自行 Google 相關資訊。
像是 window
或 document
此類沒有實體元素的情況,我們一樣可以利用 DOM API 提供的「on-event 處理器」(on-event handler) 來處理事件:
window.onload = function(){
document.write("Hello world!");
};
如同我們昨天介紹過的 document.write
方法所說,上面這段程式碼會在 window
觸發 load
事件時執行對應的內容。
另外,若是實體元素也可透過 DOM API 取得 DOM 物件後,再透過 on-event 處理器來處理事件:
<button id="btn">Click</button>
var btn = document.getElementById('btn');
btn.onclick = function(){
console.log('HI');
};
若想解除事件的話,則重新指定 on-event hendler
為 null
即可:
btn.onclick = null;
EventTarget.addEventListener()
剛剛介紹的 on-event
對應的 function
指的是「事件的處理器」,而現在我們要回頭來說明 addEventListener()
這個「事件的監聽器」。
addEventListener()
基本上有三個參數,分別是「事件名稱」、「事件的處理器」(事件觸發時執行的 function
),以及一個「Boolean」值,由這個 Boolean 決定事件是以「捕獲」或「冒泡」機制執行,若不指定則預設為「冒泡」。
<button id="btn">Click</button>
var btn = document.getElementById('btn');
btn.addEventListener('click', function(){
console.log('HI');
}, false);
使用這種方式來註冊事件的好處是可以重複指定多個「處理器」(handler) 給同一個元素的同一個事件:
var btn = document.getElementById('btn');
btn.addEventListener('click', function(){
console.log('HI');
}, false);
btn.addEventListener('click', function(){
console.log('HELLO');
}, false);
點擊後 console
出現:
"HI"
"HELLO"
若是要解除事件的註冊,則是透過 removeEventListener()
來取消。
removeEventListener()
的三個參數與 addEventListener()
一樣,分別是「事件名稱」、「事件的處理器」以及「捕獲」或「冒泡」的機制。
但是需要注意的是,由於 addEventListener()
可以同時針對某個事件綁定多個 handler,所以透過 removeEventListener()
解除事件的時候,第二個參數的 handler 必須要與先前在 addEventListener()
綁定的 handler 是同一個「實體」。
var btn = document.getElementById('btn');
btn.addEventListener('click', function(){
console.log('HI');
}, false);
// 移除事件,但是沒用
btn.removeEventListener('click', function(){
console.log('HI');
}, false);
像上面這樣,即使執行了 removeEventListener
來移除事件,但 click
時仍會出現 'HI'。 因為 addEventListener
與 removeEventListener
所移除的 handler 實際上是兩個不同實體的 function 物件。
不知道為什麼這兩個 function 是兩個不同實體的朋友請參考:
「重新認識 JavaScript: Day 05 JavaScript 是「傳值」或「傳址」?」與 「重新認識 JavaScript: Day 10 函式 Functions 的基本概念」。
var btn = document.getElementById('btn');
// 把 event handler 拉出來
var clickHandler = function(){
console.log('HI');
};
btn.addEventListener('click', clickHandler, false);
// 移除 clickHandler, ok!
btn.removeEventListener('click', clickHandler, false);
那麼以上就是今天為各位介紹 JavaScript 事件機制原理的部分。
在下一篇分享中,我會繼續來介紹事件的種類,以及更多實務上處理「事件」時需要注意的事項。
var btn = document.getElementById('btn');
btn.addEventListener('click', function(){
console.log('HI');
}, false);
若是以寫成這樣,有辦法取到 click 的實體 function 物件,來移除嗎?
你要移除的是「事件」還是 btn
本身?
如果是 btn 的 DOM 的話,用 this
就可以存取到了。
如果是 click
事件的 Handler function 的話,如文中所說,像這樣匿名的 callback function 是無法被解除的:
btn.addEventListener('click', function(){
console.log('HI');
}, false);
必須要將這個 function 指向某個實體才能透過 removeEventListener
解除。
但如果只是不想讓他觸發事件,倒是可以藉由「事件傳遞」的特性來繞過:
window.addEventListener('click', function (event) {
// 當觸發事件的元素是 btn 的就阻擋事件冒泡
if( event.target === btn ){
event.stopPropagation();
}
}, true);
逃避雖可恥但有用 XD
Kuro 老師, 您好
首先,先謝謝Kuro老師
目前看到文章時距離撰寫時間已相隔一年半 儘管如此,這批系列文章讓我受益良多
但在看這篇文章時有困惑
不知道有沒有這個機會請Kuro 老師能解答? 謝謝 !
目前理解 '將 event handler 拉出來 這個方式
-> event handler 利用 "引用參考" 的方式, 使用function 物件
var btn = document.getElementById('btn');
var clickHandler = function(){
console.log('HI');
};
btn.addEventListener('click', clickHandler, false);
// 移除 clickHandler, ok!
btn.removeEventListener('click', clickHandler, false);
困惑 : "addEventListener 與 removeEventListener 所移除的 handler 實際上是兩個不同實體的 function 物件。"
var btn = document.getElementById('btn');
btn.addEventListener('click', function(){
console.log('HI');
}, false);
btn.removeEventListener('click', function(){
console.log('HI');
}, false);
而我的疑問是:
你好,
是的,以上面範例來說,兩個匿名函式會分別被放到不同的記憶體位置
同上,因未被指派到某個變數,所以也無法透過 removeEventListener
去指到你想取消的 callback function。 用白話一點的說法就像是潑出去的水,如果沒有用容器裝著就沒辦法回收了。
Kuro老師您好:
看了老師介紹的內容滿能理解的,但是有一個地方想要請教老師
依照上面的邏輯自己稍作修改,在還沒點擊chlid之前,先自己推理出現的順序為圖片中的(由上往下2,4,1,3),但實際操作卻是4,1,2,3 。
自己的邏輯為:
點擊chlid後是讀取到false(bubbling )所以由子層開始‘c.bubbling',再往下讀取子層發現是true(capturing),於是跳到最外層'P.bubbling’,此時又讀取到false(bubbling),又跳回子層'c.capturing',然後剩下一個'P.capturing')
麻煩老師幫我看看我的邏輯!!
希望能在起步的時候可以釐清一些錯誤觀念
謝謝老師
你好,關於 DOM 的事件傳遞圖我們再看一次:
所以說,當我們點擊了子層元素,此時不管父層事件註冊的順序,第一個會觸發的一定是「Parent Capturing」,最後一個也必定是 「Parent Bubbling」
這個觀念一定要先建立起來。
至於,事件傳遞的 2 跟 3 順序呢? (也就是 "Child Bubbling" 與 "Child Capturing" 的順序)
在文章當中我們有提到,要看你程式碼子層事件註冊的順序而定。
如果註冊的順序是先 Bubbling
再 Capturing
:
child.addEventListener('click', function () {
console.log('Child Bubbling');
}, false);
child.addEventListener('click', function () {
console.log('Child Capturing');
}, true);
那麼就會先印出 'Child Bubbling'
再 'Child Capturing'
,而反過來的話,順序則剛好相反。
懂了!!!
原來我一開始自己腦補很多~
才會這麼複雜
謝謝老師:)
老師您好,我想請問您所說印出 'Child Bubbling' 和 'Child Capturing' 的執行順序,是跟註冊事件的順序有關。
不過我測試無論順序誰先誰後註冊,都是先印出 'Child Capturing' 再來才是 'Child Bubbling'。
先謝謝老師撥冗回答
我查到的資料,看來是跟瀏覽器的改版有關係。
https://juejin.cn/post/6965682915141386254
Kuro 老師 您好:
正在對 onclick
與 addEventListener
的差異感到困惑,
搜尋到你的文章受益良多,但還是有以下幾點想釐清
var btn = document.getElementById('btn');
btn.onclick = function(){
console.log('HI');
console.log('Hello');
};
又換成若是 addEventListener
的話,這下面這樣的寫法和您本來分開寫有甚麼差異?
寫一起
var btn = document.getElementById('btn');
btn.addEventListener('click', function(){
console.log('HI');
console.log('HELLO');
}, false);
分開寫
var btn = document.getElementById('btn');
btn.addEventListener('click', function(){
console.log('HI');
}, false);
btn.addEventListener('click', function(){
console.log('HELLO');
}, false);
因為我這三種執行起來都一樣阿?所以我還是分不清楚差異在哪裡?請 Kuro 老師指教,謝謝
嗨,你好,就你提問裡面的寫法來說,這三種結果確實是一樣的。
但實務上我們不會總是用 id
來作為 DOM 的選取器,
考慮到 event handler 的重複使用,更多時候會用 class
或是 property
等等。
給個情境,假設畫面上有若干按鈕。
有些我們希望它被點擊的時候執行 console.log('HI');
,有些則是執行 console.log('HELLO');
,又有一些兩個都會執行。
這個時候問題就來了。
// 在兩個 onclick 同時存在的狀況下
// 後面的 onclick 會覆蓋掉前面的
// 也就是說最後被點擊時只會出現 Hello
btn.onclick = function(){
console.log('HI');
};
btn.onclick = function(){
console.log('Hello');
};
你用 onclick
的寫法就只能為每一個元素「個別」添加它的 onclick
屬性 (或者我們說事件處理器) 。
若是換成 addEventListener
的話,
// 在兩個 addEventListener 同時存在的狀況下
// 兩者並不會互相覆蓋,所以都會執行
btn.addEventListener('click', function(){
console.log('HI');
}, false);
btn.addEventListener('click', function(){
console.log('HELLO');
}, false);
所以,回到剛剛的情境,畫面上有若干按鈕,
有些我們希望它被點擊的時候執行 console.log('HI');
,有些則是執行 console.log('HELLO');
,又有一些兩個都會執行。
我們就可以利用 class
來處理:
假設點擊會印 HI
的按鈕,我們給個 click-hi
的 class
,印 HELLO
的的按鈕,我們給個 click-hello
的 class
。
<button class="click-hi">Hi 1</button>
<button class="click-hi">Hi 2</button>
<button class="click-hi">Hi 3</button>
<button class="click-hello">Hello 1</button>
<button class="click-hello">Hello 2</button>
<button class="click-hello">Hello 3</button>
const hiBtns = document.querySelectorAll('.click-hi');
const helloBtns = document.querySelectorAll('.click-hello');
for( let i = 0; i < hiBtns.length; i++) {
hiBtns[i].addEventListener('click', function(){
console.log('HI');
}, false);
}
for( let i = 0; i < helloBtns.length; i++) {
helloBtns[i].addEventListener('click', function(){
console.log('HELLO');
}, false);
}
那,兩個都要執行的怎麼辦? 你已經寫好了啊:
<button class="click-hi click-hello">Both</button>
就可以了。
Kuro 老師:
謝謝您快速的回覆,昨天其實就收到您的答覆了,我覺得我有更釐清了一些,但還是有些疑惑,思考了一天,想找您再 double check~
依照您給我的範例程式碼,我把 addEventListener
的寫法全部改成 onclick
。
const hiBtns = document.querySelectorAll('.click-hi');
const helloBtns = document.querySelectorAll('.click-hello');
for (let i = 0; i < hiBtns.length; i++){
hiBtns[i].onclick = function(){
console.log('HI');
}
}
for (let i = 0; i < helloBtns.length; i++){
helloBtns[i].onclick = function(){
console.log('HELLO');
}
}
若是依照這樣的寫法,[H1 1]、[H1 2]、[H1 3]、[Hello 1]、[Hello 2] 及 [Hello 3],全部都會有效果,
就唯獨 [both] 按鈕,只會顯示 Hello,表示 Hi 已被覆寫掉,
若要[both]按鈕也有效果,好像就不能用原來的兩個 class
,
而是要重新命名 class
並用一個新的 onclick
重新指定(這是不是您所謂的個別添加的意思)
例如:
<button class="click-hi click-hello">Both</button>
//原本的 class
<button class="both">Both</button>
//重新命名 class
然後 JS 部分也要重新指定
const both = document.querySelector('.both');
both.onclick = function(){
console.log('HI');
console.log('HELLO');
}
所以我整理如下:
onclick:
class
,該 class
可以被諸多 element
共用,都會產生應該要被觸發的事件,例如下方三個按鈕,都會顯示 HI。<button class="click-hi">Hi 1</button>
<button class="click-hi">Hi 2</button>
<button class="click-hi">Hi 3</button>
HI
及 HELLO
,如下:both.onclick = function(){
console.log('HI');
console.log('HELLO');
}
若是分開寫,前面的會被覆寫,只會顯示後面的那一個 HELLO
,如下:
both.onclick = function(){
console.log('HI');
}
both.onclick = function(){
console.log('HELLO'); //只會顯示這個
}
class
分別用 onclick
指定兩個不同的效果,但這兩個 class
不能被用在同一個 element
上,像是下面這樣,只會顯示後面的那一個 onclick
效果,所以只有 HELLO
。<button class="click-hi click-hello">Both</button>
而如果是 addEventListener
:
可以在一個 class
綁定效果,且被諸多 element
共用,這點和 onclick
一樣。
一個 class
要有多重效果,寫在同一個 addEventListener
內的 function
,或是分別寫在兩個不同的 addEventListener
都可以正常顯示,分成兩個寫的,後面的不會覆寫前面的,例如:
寫一起
for( let i = 0; i < hiBtns.length; i++) {
hiBtns[i].addEventListener('click', function(){
console.log('HI');
console.log('HELLO');
}, false);
}
或是,分開寫
for( let i = 0; i < hiBtns.length; i++) {
hiBtns[i].addEventListener('click', function(){
console.log('HI');
}, false);
}
for( let i = 0; i < hiBtns.length; i++) {
hiBtns[i].addEventListener('click', function(){
console.log('HELLO');
}, false);
}
都可以。
class
分別用 addEventListener
指定兩個不同的效果,且這兩個 class
可以被用在同一個 element
上,會同時顯示這兩個 class
的效果,如下`:所以該按鈕會同時顯示 HI 與 HELLO。
<button class="click-hi click-hello">Both</button>
以上,為了釐清觀念,稍微囉嗦了些,還請 Kuro 老師不吝指教,感恩~
沒錯,如果工作上同個專案內有多人經手維護的情況下,
用 onclick
的缺點就可能不小心覆蓋前人的 event handler,
而利用 addEventListener
就不會有這樣的問題囉。
釐清觀念後真是通體舒暢呀,謝謝老師呀~