iT邦幫忙

2018 iT 邦幫忙鐵人賽
DAY 15
9
Modern Web

重新認識 JavaScript系列 第 15

重新認識 JavaScript: Day 15 隱藏在 "事件" 之中的秘密

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

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

讓我們再次重新認識 JavaScript!


上回我們提到,註冊事件的方法 addEventListener() 內有三個參數,分別是「事件名稱」、「事件的處理器」(Event Handler),以及「捕獲」或「冒泡」的機制切換。

那麼,今天我們要來看的第一個部分,就是隱藏在 Event Handler 中的 event(柯南音樂請下)


隱藏在 Handler 中的 "event"

當監聽的事件發生時,瀏覽器會去執行我們透過 addEventListener() 註冊的 Event Handler (EventListener) ,也就是我們所指定的 function

這個時候,EventListener 會去建立一個「事件物件」 (Event Object),裡面包含了所有與這個事件有關的屬性,並且以「參數」的形式傳給我們的 Event Handler:

<button id="btn">Click</button>
var btn = document.getElementById('btn');

// 參數 e 就是上面說的事件物件 (Event Object)
// 因為是參數,當然也可以自己定義名稱
btn.addEventListener('click', function(e){
  console.log(e);
}, false);

當點擊 <button> 後,可以從 console 看到 event 物件提供了這麼多東西:

https://ithelp.ithome.com.tw/upload/images/20171218/20065504XDv4csqNtL.png

像是

  • type : 表示事件的名稱
  • target : 表示觸發事件的元素
  • bubbles : 表示這事件是否是在「冒泡」階段觸發 (true / false)
  • pageX / pageY : 表示事件觸發時,滑鼠座標在網頁的相對位置

相關資訊當然還有很多,這裡就不一一介紹。

不過要注意的是,每個「事件物件」所提供的屬性都會根據觸發的事件而稍微不同。


阻擋預設行為 event.preventDefault()

HTML 部分元素會有預設行為,像是 <a> 的連結,或是表單的 submit 等等...,
如果我們需要在這些元素上綁定事件,那麼適當地取消它們的預設行為就是很重要的一件事。

像這樣,有一個通往 google 連結 <a>:

<a id="link" href="https://www.google.com">Google</a>

假設今天點擊這個 link 時,我希望瀏覽器執行 console.log('Google!'); ,那麼根據先前所說,我可以先註冊 click 事件:

var link = document.querySelector('#link');

link.addEventListener('click', function (e) {
  console.log('Google!');
}, false);

結果你會發現,即便我們為了 <a> 去註冊了 click 事件,但是當我點擊這個 link 的時候,瀏覽器依然會把我帶去 google 的網頁。

如果這時候,我希望執行的是 console.log('Google!'); 而不是直接把我帶去 google 的網站,那麼可以怎麼做?

這時候我們就可以利用剛剛介紹的「事件物件」 event 提供的 event.preventDefault() 方法:

var link = document.querySelector('#link');

// 在 evend handler 加上 e.preventDefault();
link.addEventListener('click', function (e) {
  e.preventDefault();
  console.log('Google!');
}, false);

這個時候,再試著點擊 link 一次,你會發現瀏覽器預設的跳轉頁面的行為不見了, console.log('Google!'); 也可順利執行。

但要注意的是, event.preventDefault() 並不會阻止事件向上傳遞 (事件冒泡) 。

另外,值得一提的是,在 event handler function 的最後加上 return false; 也會有 event.preventDefault() 的效果,但切記不可以加在前面,若是加在前面 event handler function 就直接結束了。


阻擋事件冒泡傳遞 event.stopPropagation()

如果我們想要阻擋事件向上冒泡傳遞,那麼就可以利用 event object 提供的另一個方法: event.stopPropagation()

最常見的使用情境如下:

應該很多人都知道,為了增強 checkbox 的易用性,通常會特地加個 label 標籤,然後給個 for 屬性給對應的 chkbox:

  <label for="xxx">Label2</label>
  <input type="checkbox" name="chkbox" id="xxx">

但有時候因為排版需求、不想增加太多 id,或是某些其他原因,可能會寫成這樣:

<label class="lbl">
  Label <input type="checkbox" name="chkbox">
</label>

在單純的使用上也有一樣的效果。

但是,這時如果我們在 label 上面註冊了 click 事件時:

var lbl = document.querySelector('.lbl');

lbl.addEventListener('click', function (e) {
  console.log('lbl click');
}, false);

你會發現 console.log('lbl click'); 執行了兩次:

"lbl click"
"lbl click"

會有這樣的問題,是因為在 label 標籤包覆 checkbox 的情況下,我們去點擊了 label 觸發 click 事件,此時瀏覽器會自動把這個 click 事件帶給 checkbox

checkbox 受到事件冒泡的影響,又會再度把 click 事件傳遞至 label 上,導致 "lbl click" 出現了兩次。

這裏我們分別為 labelcheckbox 註冊 click 事件來實驗:

<label class="lbl">
  Label <input type="checkbox" name="chkbox" id="chkbox">
</label>
// label
var lbl = document.querySelector('.lbl');
// chkbox
var chkbox = document.querySelector('#chkbox');

lbl.addEventListener('click', function (e) {
  console.log('lbl click');
}, false);

chkbox.addEventListener('click', function (e) {
  console.log('chkbox click');
}, false);

執行結果:

"lbl click"
"chkbox click"
"lbl click"

現在既然已經知道是 checkbox 受到事件冒泡的影響將事件向上傳遞,這時如果要修正 labelclick 觸發兩次的錯誤行為時,我們就可以這樣做:

// label
var lbl = document.querySelector('.lbl');
// chkbox
var chkbox = document.querySelector('#chkbox');

lbl.addEventListener('click', function (e) {
  console.log('lbl click');
}, false);

// 阻擋 chkbox 的事件冒泡
chkbox.addEventListener('click', function (e) {
  e.stopPropagation();
}, false);

checkboxclick 事件加上 e.stopPropagation(); 就可以囉!

注意, stopPropagation() 不是掛在 label ,而是要放在 checkbox 上才有效!

另外值得一提的是,很多人會在 jQuery 的 event handler 最後加上 return false 來得到 preventDefault()stopPropagation() 的效果,這是沒問題的。

但是在 JavaScript 的 addEventListener() 裡,最後面加上 return false 並不會有上述的的作用,只有在 onclick="return false" 的情況下會有作用。


在事件中找回「自己」

在 Event Handler 的 function 裡頭,若是想要對「觸發事件的元素」做某些事時,該怎麼處理? 在 Event Handler 重新用選擇器挑選嗎? 如果沒有 id 肯定是個大工程吧!

沒關係,這時候我們可以用「this」。

lbl.addEventListener('click', function (e) {
  console.log(this.tagName);      // "label"
}, false);

像這樣,當事件被觸發時,此時 this 就會是觸發事件的元素,也就是這個範例中的 label

注意:this 代表的會是「觸發事件的目標」元素,也就是 event.currentTarget 而不是 e.target

回到剛剛的範例:

<label class="lbl">
  Label <input type="checkbox" name="chkbox" id="chkbox">
</label>
// label
var lbl = document.querySelector('.lbl');
// chkbox
var chkbox = document.querySelector('#chkbox');

// 為了區分在後面加上了 1 & 2

lbl.addEventListener('click', function (e) {
  console.log(e.target.tagName, 1);
  console.log(this.tagName, 1);
}, false);


chkbox.addEventListener('click', function (e) {
  console.log(e.target.tagName, 2);
  console.log(this.tagName, 2);
}, false);

然而,當我按下了 label 觸發 click 事件時:

"LABEL 1"
"LABEL 1"
"INPUT 2"
"INPUT 2"
"INPUT 1"
"LABEL 1"

前面兩組 "LABEL 1""INPUT 2" 不意外,因為 labelchkbox 確實會各自觸發一次 click 事件。

然而最後的 "INPUT 1""LABEL 1" 從後面的「1」可以看出事件是由 label 所發動,但為什麼這時的 e.targetthis 會是不同的兩個對象呢?

在事件主動被發動的時候,此時 e.targetthis 確實會指向同一個 DOM node。

但我們已經知道最後的 "INPUT 1""LABEL 1" 其實是由 chkbox 的冒泡機制所發動的 (注意這裡沒有 e.stopPropagation() ),此時這裡的 e.target 指的是 chkbox

換言之,e.target 其實是「觸發事件的元素」,而 this 指的是「觸發事件的目標」元素,也就是 event.currentTarget

當然,如果在不考慮事件傳遞的情況下,this 實質上就等同於 e.target 了。


事件指派 (Event Delegation)

事件指派是利用前面介紹的「事件流程」以及「單一事件監聽器」來處理多個事件目標。

重新認識 JavaScript: Day 12 透過 DOM API 查找節點 的範例來說:

<ul id="myList">
  <li>Item 01</li>
  <li>Item 02</li>
  <li>Item 03</li>
</ul>

如果我們要分別為 myListli 綁定 click 事件,就要使用 for 迴圈來一個個綁定:

// 取得容器
var myList = document.getElementById('myList');

// 分別為 li 綁定事件
if( myList.hasChildNodes() ) {
  for (var i = 0; i < myList.childNodes.length; i++) {

    // nodeType === 1 代表為實體 HTML 元素
    if( myList.childNodes[i].nodeType === 1 ){
      myList.childNodes[i].addEventListener('click', function(){
       console.log(this.textContent);
      }, false);
    }

  }
}

此時,若是我們再新增元素至 myList

// 取得容器
var myList = document.getElementById('myList');

// 分別為 li 綁定事件
if( myList.hasChildNodes() ) {
  for (var i = 0; i < myList.childNodes.length; i++) {

    // nodeType === 1 代表為實體 HTML 元素
    if( myList.childNodes[i].nodeType === 1 ){
      myList.childNodes[i].addEventListener('click', function(){
       console.log(this.textContent);
      }, false);
    }

  }
}

// 建立新的 <li> 元素
var newList = document.createElement('li');

// 建立 textNode 文字節點
var textNode = document.createTextNode("Hello world!");

// 透過 appendChild 將 textNode 加入至 newList
newList.appendChild(textNode);

// 透過 appendChild 將 newList 加入至 myList
myList.appendChild(newList);

這個時候,你會發現一個問題,就是後來才新增的 newList 節點並不會有 click 事件的註冊。

解法很簡單啊,appendChild 之後再 addEventListener 就好了

如果每次新增後要再重新 addEventListener 那就沒完沒了,而且若是我們不斷地的去重覆監聽事件,又忘了移除監聽,甚至可能會造成 memory leak 的嚴重問題。

所以接下來要介紹的「事件指派」(Event Delegation) 就會是比較好的做法:

// 取得容器
var myList = document.getElementById('myList');

// 改讓外層 myList 來監聽 click 事件
myList.addEventListener('click', function(e){

  // 判斷目標元素若是 li 則執行 console.log
  if( e.target.tagName.toLowerCase() === 'li' ){
    console.log(e.target.textContent);
  }

}, false);


// 建立新的 <li> 元素
var newList = document.createElement('li');

// 建立 textNode 文字節點
var textNode = document.createTextNode("Hello world!");

// 透過 appendChild 將 textNode 加入至 newList
newList.appendChild(textNode);

// 透過 appendChild 將 newList 加入至 myList
myList.appendChild(newList);

發現了嗎?

我們把 click 事件改由外層的 myList 來監聽,利用事件傳遞的原理,判斷 e.target 是我們要的目標節點時,才去執行後續的動作。

這樣的好處是你的事件管理會非常輕鬆,而且後續加上的 newList 也會有 click 的效果,無需另外再去綁定 click 事件。


上一篇
重新認識 JavaScript: Day 14 事件機制的原理
下一篇
重新認識 JavaScript: Day 16 那些你知道與不知道的事件們
系列文
重新認識 JavaScript37
1
VagrantPi
iT邦新手 5 級 ‧ 2018-03-14 11:18:40

長姿勢,難怪我之前的 checkbox click 時會呼叫兩次 function

0
RocMark
iT邦新手 5 級 ‧ 2018-03-18 20:51:59

想請教一下 stopPropagation() 防止向上冒泡傳遞,
除了上述的例子外還有甚麼地方會用到?

我想知道capture phase與 bubble phase
是不是類似於做 DOM 的搜索與操作(對於效能有影響)

如果是的話,
是否應該在所有沒有子元素的 eventListener的最後,
都加上stopPropagation() 會更好?
以及 capture phase時,是否有優化的可能性?

stopPropagation() 的用途就是防止事件冒泡到父元素,除了文中 <label><input type="checkbox"> 這類天然的範例外,最常使用的場景會是:
當同一組 DOM Tree 的直系 (如父子元素) 都綁定同樣事件時,子元素事件被觸發的同時也會去觸發父層元素的事件,為了避免這樣的錯誤發生,就必須在子元素上加上 stopPropagation()

至於 Capture phase 與 Bubble phase 的部分則是由瀏覽器內部實作,就執行機制上來說,如果上層元素沒有綁定事件,對效能的影響可以說幾乎是沒有。

RocMark iT邦新手 5 級‧ 2018-03-20 22:59:46 檢舉

了解了! 感謝分享,看您這系列文章,才知道之前學的東西都太淺,沒有建構好整個觀念,一個大補血,感謝回應~

0
chunwen
iT邦新手 5 級 ‧ 2019-07-08 14:09:11

Kuro大大您好
我想請教一下,關於event.preventDefault();
您在文章中提到,可以在event handler function 的最後加上 return false; 也會有 event.preventDefault() 的效果

我自己實際實驗後發現還是不行,還是會跳轉,想請大大幫我看一下是哪邊寫錯 感恩
連結如下:
https://codepen.io/chunwen/pen/JQmpKd?editors=1010

哈囉你好,那段確實是我記錯了,正確來說,只有在 onclick="return false" 的情況下可以阻擋跳轉。

謝謝你的提問!

chunwen iT邦新手 5 級‧ 2019-07-08 22:18:41 檢舉

謝謝Kuro大大的回答!

我使用你那個連結,你註解的那段(補充:下面這方法是錯誤,還是會跳轉)
確實可以防止跳轉啊
???

https://ithelp.ithome.com.tw/upload/images/20191118/20122184KdgNpivxl3.png

0
noway
iT邦新手 5 級 ‧ 2019-08-31 21:53:25

您好:
阻擋事件冒泡傳遞 event.stopPropagation()
會出現2 個 lbl click
但我 試,卻都只有 1個 lbl click

看更多先前的回應...收起先前的回應...

您好,方便的話可否留下你測試的程式碼?

noway iT邦新手 5 級‧ 2019-09-01 09:02:51 檢舉

您好: 不好意思 沒說清楚

Label

noway iT邦新手 5 級‧ 2019-09-01 09:03:05 檢舉

您好: 不好意思 沒說清楚

Label

noway iT邦新手 5 級‧ 2019-09-01 09:04:05 檢舉
<label class="lbl">
  Label <input type="checkbox" name="chkbox">
</label>

var lbl = document.querySelector('.lbl');

lbl.addEventListener('click', function (e) {
  console.log('lbl click');
}, false);

你會發現 console.log('lbl click'); 執行了兩次:

但只有列印出一次 ?? 只有一次

你好,文本上述是以點擊 label 作為範例,你應該是點到了 checkbox

noway iT邦新手 5 級‧ 2019-09-02 15:03:37 檢舉

您好:我確定 只在label點選
然後,checkbox 就被勾選了
所以 就出現2次
但若只直接勾選 chkbox他就只有一次
https://ithelp.ithome.com.tw/upload/images/20190902/20104095DqfalPeU3g.png

若你直接點擊 checkboxcheckbox 會將 click 事件向上層傳遞,所以只會印出一次。

而點擊 label 會先觸發自己的 click 事件,所以會先印出一次 'lbl click'
而因為 labelcheckbox 會互相連動的關係,所以會觸發 checkbox 再執行一次 click 事件,因而印出第二次 'lbl click'

關於這點,我們可以在 labelclick 事件加上 console.log(e.target) 來做驗證:

var lbl = document.querySelector('.lbl');

lbl.addEventListener('click', function (e) {
  console.log('lbl click');
  console.log(e.target);
}, false);

https://ithelp.ithome.com.tw/upload/images/20190902/200655048fkU5fxfNM.png

0
noway
iT邦新手 5 級 ‧ 2019-09-01 10:02:15

您好:
另外請教 在事件中找回「自己」 的範例
我修改如下

<label class="lbl">
  Label <input type="checkbox" name="chkbox" id="chkbox">
</label>


<script>

// label
var lbl = document.querySelector('.lbl');
// chkbox
var chkbox = document.querySelector('#chkbox');

// 為了區分在後面加上了 1 & 2

lbl.addEventListener('click', function (e) {
  console.log(e.target.tagName, 1,"e");
  console.log(this.tagName, 1 ,"this");
  console.log(e.currentTarget.tagName, 1,"c");
  
}, false);

chkbox.addEventListener('click', function (e) {
  console.log(e.target.tagName, 2 ,"e");
  console.log(this.tagName, 2 ,"this");
  console.log(e.currentTarget.tagName, 2,"c");
}, false);


</script>

當我直接 點選 chkbox,結果是
INPUT 2 e
INPUT 2 this
INPUT 2 c
INPUT 1 e
LABEL 1 this
LABEL 1 c <--請問 event.currentTarget 為何是 lab ,而不是chkbox,因我是直接點選chkbox?

是的,文本上述是以點擊 label 作為範例。

noway iT邦新手 5 級‧ 2019-09-02 15:05:13 檢舉

您好:
那請問 chkbox
而event.currentTarget 為何是 lab?
謝謝!

我在文中有提到,e.target 是「觸發事件的元素」,而 event.currentTarget 指的是「觸發事件的目標」。

我要留言

立即登入留言