iT邦幫忙

2018 iT 邦幫忙鐵人賽
DAY 14
7
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
1
Luke
iT邦新手 3 級 ‧ 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

你要移除的是「事件」還是 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 是無法被解除 "
  • 事件無法被解除的原因是因為 兩個匿名函式沒有指定給特定變數所以無法觸發嗎?

你好,

  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

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

你好,關於 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 檢舉

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

我要留言

立即登入留言