iT邦幫忙

2018 iT 邦幫忙鐵人賽
DAY 14
18
Modern Web

重新認識 JavaScript系列 第 14

重新認識 JavaScript: Day 14 事件機制的原理

本系列文章已重新編修,並在加入部分 ES6 新篇章後集結成書,有興趣的朋友可至天瓏書局選購,感謝大家支持。

購書連結 https://www.tenlong.com.tw/products/9789864344130

讓我們再次重新認識 JavaScript!


JavaScript 是一個事件驅動 (Event-driven) 的程式語言,當瀏覽器載入網頁開始讀取後,雖然馬上會讀取 JavaScript 事件相關的程式碼,但是必須等到「事件」被觸發(如使用者點擊、按下鍵盤等)後,才會再進行對應程式的執行。

什麼意思呢?

就好比辦公室擺了一台電話在桌上,但是電話要是沒響,我們不會主動去「接電話」 (沒人打來當然也無法主動接) 。

電話響了 (事件被觸發) -> 接電話 (去做對應的事)


換以我們很常見的網頁對話框 UI 來說,當使用者「按下了按鈕」之後,才會啟動對話框的顯示。 如果使用者沒有按下按鈕,就狂跳對話框,那使用者一定覺得這網站壞掉了吧。

以 Bootstrap Modal 為例:
https://i.imgur.com/4S9ai5V.gif

如同上面範例,當使用者點擊了按鈕,才會啟動對話框的顯示,那麼「點擊按鈕」這件事,就被稱作「事件」(Event),而負責處理事件的程式通常被稱為「事件處理者」(Event Handler),也就是「啟動對話框的顯示」這個動作。


事件流程 Event Flow

假設有兩個重疊的 div 元素,外層是 <div id="outer">,而內層是 <div id="inner">:

<div id="outer">
  <div id="inner">
  </div>
</div>

https://ithelp.ithome.com.tw/upload/images/20171217/20065504gLMf14K9qn.png

這時內層的位置一定在外層裡面對吧 (先不管 position: absolute; 的可能性),這表示 inner 也是 outer 的一部分。 那麼,當我們點擊了 inner 的時候,是不是代表我們也點擊到 outer,甚至再看遠一點,可以說實際上我們也點擊到整個網頁。

而事件流程 (Event Flow) 指的就是「網頁元素接收事件的順序」。

事件流程可以分成兩種機制:

  • 事件冒泡 (Event Bubbling)
  • 事件捕獲 (Event Capturing)

接著就來分別介紹事件的這兩種機制。


事件冒泡 (Event Bubbling)

http://www.java2s.com/Book/JavaScriptImages/eventBubble.png
圖片來源: 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> 元素,那麼在「事件冒泡」的機制下,觸發事件的順序會是:

  1. <div>CLICK</div>
  2. <body>
  3. <html>
  4. document

像這樣 click 事件逐層向上依序被觸發,就是「事件冒泡」機制。


事件捕獲 (Event Capturing)

http://www.java2s.com/Book/JavaScriptImages/eventCapture.png
圖片來源: 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> 元素,那麼在「事件捕獲」的機制下,觸發事件的順序會是:

  1. document
  2. <html>
  3. <body>
  4. <div>CLICK</div>

像這樣 click 事件由上往下依序被觸發,就是「事件捕獲」機制。


既然事件傳遞順序有兩種機制,那我怎麼知道事件是依賴哪種機制執行的?

答案是:兩種都會執行。

https://www.w3.org/TR/2003/NOTE-DOM-Level-3-Events-20031107/images/eventflow.png
圖片來源: W3C, DOM event flow

假設現在的事件是點擊上圖中藍色的 <td>

那麼當 td 的 click 事件發生時,會先走紅色的 「capture phase」:

  1. Document
  2. <html>
  3. <body>
  4. <table>
  5. <tbody>
  6. <tr>
  7. <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 會先被觸發,然後再到子層內部的 CapturingBubbling 事件。 最後才又回到父層的 Bubbling 結束。

那麼,子層的 CapturingBubbling 誰先誰後呢?
要看你程式碼的順序而定。

若是 CapturingBubbling 前面:

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 方法對事件的綁定,事實上綁定事件的方式還有其他方式,我們這裡來一一介紹。

on-event 處理器 (HTML 屬性):

對 HTML 元素來說,只要支援某個「事件」的觸發,就可以透過 on+事件名 的屬性來註冊事件:

<button id="btn" onclick="console.log('HI');">Click</button>

如同上面範例,透過 onclick 屬性,我們就可以在 <button> 元素上面註冊 click 事件,換句話說,當我按下這個 <button> 元素時,就會執行 console.log('HI'); 的程式碼。

但是需要注意的是,基於程式碼的使用性與維護性考量,現在已經不建議用此方式來綁定事件,詳情可參考「維基百科: 非侵入式JavaScript」條文,或自行 Google 相關資訊。


on-event 處理器 (非 HTML 屬性):

像是 windowdocument 此類沒有實體元素的情況,我們一樣可以利用 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 hendlernull 即可:

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'。 因為 addEventListenerremoveEventListener 所移除的 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 事件機制原理的部分。
在下一篇分享中,我會繼續來介紹事件的種類,以及更多實務上處理「事件」時需要注意的事項。


上一篇
重新認識 JavaScript: Day 13 DOM Node 的建立、刪除與修改
下一篇
重新認識 JavaScript: Day 15 隱藏在 "事件" 之中的秘密
系列文
重新認識 JavaScript37
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中
1
Luke
iT邦研究生 5 級 ‧ 2017-12-27 15:37:42
var btn = document.getElementById('btn');

btn.addEventListener('click', function(){
  console.log('HI');
}, false);

若是以寫成這樣,有辦法取到 click 的實體 function 物件,來移除嗎?/images/emoticon/emoticon06.gif

Kuro Hsu iT邦新手 1 級 ‧ 2017-12-27 15:59:26 檢舉

你要移除的是「事件」還是 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

0
teamyc85
iT邦新手 5 級 ‧ 2019-06-20 17:55:31

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);

而我的疑問是:

  1. " 兩個不同實體的 function 物件 "
  • 是因為創造執行環境時, 兩個匿名函式已經分別被放進不同記憶中 而導致他們是獨立的物件嗎?
  1. " callback function 是無法被解除 "
  • 事件無法被解除的原因是因為 兩個匿名函式沒有指定給特定變數所以無法觸發嗎?
Kuro Hsu iT邦新手 1 級 ‧ 2019-06-20 18:02:48 檢舉

你好,

  1. 是的,以上面範例來說,兩個匿名函式會分別被放到不同的記憶體位置

  2. 同上,因未被指派到某個變數,所以也無法透過 removeEventListener 去指到你想取消的 callback function。 用白話一點的說法就像是潑出去的水,如果沒有用容器裝著就沒辦法回收了。

0
smile98
iT邦新手 5 級 ‧ 2019-09-04 15:04:58

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')

https://ithelp.ithome.com.tw/upload/images/20190903/20120589tADtXVIxZm.png

麻煩老師幫我看看我的邏輯!!
希望能在起步的時候可以釐清一些錯誤觀念
謝謝老師

看更多先前的回應...收起先前的回應...
Kuro Hsu iT邦新手 1 級 ‧ 2019-09-04 16:17:28 檢舉

你好,關於 DOM 的事件傳遞圖我們再看一次:
https://ithelp.ithome.com.tw/upload/images/20190904/20065504BJ4fRSkLQy.png

所以說,當我們點擊了子層元素,此時不管父層事件註冊的順序,第一個會觸發的一定是「Parent Capturing」,最後一個也必定是 「Parent Bubbling」

https://ithelp.ithome.com.tw/upload/images/20190904/20065504vl72QUcfnA.png

這個觀念一定要先建立起來。

至於,事件傳遞的 2 跟 3 順序呢? (也就是 "Child Bubbling" 與 "Child Capturing" 的順序)

在文章當中我們有提到,要看你程式碼子層事件註冊的順序而定。

如果註冊的順序是先 BubblingCapturing:

child.addEventListener('click', function () {
  console.log('Child Bubbling');
}, false);

child.addEventListener('click', function () {
  console.log('Child Capturing');
}, true);

那麼就會先印出 'Child Bubbling''Child Capturing',而反過來的話,順序則剛好相反。

smile98 iT邦新手 5 級 ‧ 2019-09-04 17:57:29 檢舉

懂了!!!
原來我一開始自己腦補很多~
才會這麼複雜
謝謝老師:)

老師您好,我想請問您所說印出 'Child Bubbling' 和 'Child Capturing' 的執行順序,是跟註冊事件的順序有關。
不過我測試無論順序誰先誰後註冊,都是先印出 'Child Capturing' 再來才是 'Child Bubbling'。

先謝謝老師撥冗回答

我查到的資料,看來是跟瀏覽器的改版有關係。
https://juejin.cn/post/6965682915141386254

2
Alec
iT邦新手 3 級 ‧ 2019-12-11 16:57:05

Kuro 老師 您好:

正在對 onclickaddEventListener 的差異感到困惑,
搜尋到你的文章受益良多,但還是有以下幾點想釐清

  1. onclick 只能綁定一個處理器,無法重複對同一個元素綁定第二個處理器,但是用下面這樣的寫法,不是也可以達到一樣的效果嗎?
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 老師指教,謝謝

看更多先前的回應...收起先前的回應...
Kuro Hsu iT邦新手 1 級 ‧ 2019-12-12 09:47:14 檢舉

嗨,你好,就你提問裡面的寫法來說,這三種結果確實是一樣的。

但實務上我們不會總是用 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-hiclass,印 HELLO 的的按鈕,我們給個 click-helloclass

  <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>

就可以了。

Alec iT邦新手 3 級 ‧ 2019-12-13 16:46:27 檢舉

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:

  1. 可以綁定一個 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>
  1. 一個 class 要有多重效果,必須將效果寫在同一個 function 內,例如要顯示 HIHELLO,如下:
both.onclick = function(){
    console.log('HI');
    console.log('HELLO');
}

若是分開寫,前面的會被覆寫,只會顯示後面的那一個 HELLO,如下:

both.onclick = function(){
    console.log('HI');
}
both.onclick = function(){
    console.log('HELLO');  //只會顯示這個
}
  1. 兩個不同的 class 分別用 onclick 指定兩個不同的效果,但這兩個 class 不能被用在同一個 element 上,像是下面這樣,只會顯示後面的那一個 onclick 效果,所以只有 HELLO
<button class="click-hi click-hello">Both</button>

而如果是 addEventListener

  1. 可以在一個 class 綁定效果,且被諸多 element 共用,這點和 onclick 一樣。

  2. 一個 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);
}

都可以。

  1. 不同的 class 分別用 addEventListener 指定兩個不同的效果,且這兩個 class 可以被用在同一個 element 上,會同時顯示這兩個 class 的效果,如下`:

所以該按鈕會同時顯示 HI 與 HELLO。

<button class="click-hi click-hello">Both</button>

以上,為了釐清觀念,稍微囉嗦了些,還請 Kuro 老師不吝指教,感恩~

Kuro Hsu iT邦新手 1 級 ‧ 2019-12-13 16:56:03 檢舉

沒錯,如果工作上同個專案內有多人經手維護的情況下,

onclick 的缺點就可能不小心覆蓋前人的 event handler,

而利用 addEventListener 就不會有這樣的問題囉。

Alec iT邦新手 3 級 ‧ 2019-12-14 02:40:39 檢舉

釐清觀念後真是通體舒暢呀,謝謝老師呀~

我要留言

立即登入留言