iT邦幫忙

2024 iThome 鐵人賽

DAY 30
0
JavaScript

Don't make JavaScript Just Surpise系列 第 30

JavaScript 裡的事件(Event)

  • 分享至 

  • xImage
  •  

今年心得留給別篇,最後一天還是來寫點想寫的。
其實基本的 JavaScript 定義在 ECMAScript 裡比較常用的應用內容或概念,其實都多少有介紹了。
像做一樣,今天也來寫一下同樣主要被定義在 W3C UI EventHTML Standard 裡的事件(Event)。

事件的定義是由使用者或瀏覽器觸發的行為,例如點擊、輸入、載入等等。

看一下從 UI Event 借來的這張概念圖。

這張概念圖大概講了內建的 DOM Event 的介面與繼承。

事件監聽器(EventListener)指的是一個函式,用於當事件觸發時,響應該事件並執行對應的操作。
事件監聽器會掛在一個物件上,他監聽的事件只有該物件或該物件的子層傳遞而來的事件。

element.addEventListener('eventType', function(event) {
    //像這樣的方式宣告一個監聽器
});

這個被掛著事件的物件稱作事件對象(EventTarget),而事件被觸發的時候,也會帶有從何者被觸發的資訊。

element.addEventListener('click', function(event) {
    console.log('Event target:', event.target);
});
//element 是其中一個事件對象,但 event.target 卻有可能不是 element,這個概念後述

W3C 的標準文件裡有一個目前被定義的 UI Event 事件列表,抽象來看大概就是上面圖列出的幾種:FocusEvent、MouseEvent、KeyboardEvent、CompositionEvent。
隨著時間的演進,可能有已經被汰用的事件,也因為事件可以被自定義的關係,一些執行環境也可能有為該環境量身定做的事件。

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

事件冒泡是事件傳遞的一種方式。
上面有說過,事件監聽器能夠接收到除了自身以外,還有自身子層的事件就是這個意思。
事件冒泡的行為是指,當一個事件發生後,該事件會從觸發事件的元素開始,一路往上層傳遞,從父元素,直至 頂層物件如 document。

事件捕獲是另一種傳遞方式,他和事件冒泡相反。
事件會從最外層開始發生,直到觸發元素。

舉個例子

<div>
Level 1 <span>Level 2 <span>Level 3</span></span>
</div>

如果點擊了 Level 3,則順序如下:
事件冒泡:Level3 -> Level2 -> Level1 -> document
事件捕獲:document-> Level1 -> Level2 -> Level3

為什麼會有兩種不同的方式呢?
因為在初期,網路標準尚未統一,存在競爭狀態時,這兩個方法由微軟(事件冒泡)和網景(事件捕獲)分別提出。
直至後來,標準被統一了 W3C 提出了新的方式來為相容兩種方法:先捕獲再冒泡,也是現在瀏覽器的實作方式。

去翻 MDN 文件,可以知道在宣告一個事件監聽器的時候,是有第三個參數可以傳入的:useCapture
這個參數即是聲明要使用事件委託或冒泡,不給值的時候預設是 false,也就是使用事件冒泡。
同一個元素可以同時存在捕獲和冒泡的監聽器,我們來做個實驗。

<div id="l1">
Level 1 <span id="l2">Level 2 <span id="l3">Level 3</span></span>
</div>
<script>
const l1 = document.getElementById('l1');
const l2 = document.getElementById('l2');
const l3 = document.getElementById('l3');

l1.addEventListener("click", ()=>console.log("L1 Bubbling"),false);
l1.addEventListener("click", ()=>console.log("L1 Captured"),true);
l2.addEventListener("click", ()=>console.log("L2 Bubbling"),false);
l2.addEventListener("click", ()=>console.log("L2 Captured"),true);
l3.addEventListener("click", ()=>console.log("L3 Bubbling"),false);
l3.addEventListener("click", ()=>console.log("L3 Captured"),true);
</script>

透過上面的說明,可以先在心裡想一下這樣印出來的按按鈕的時候下面的答案。
.
.
.

"L1 Captured"
"L2 Captured"
"L3 Captured"
"L3 Bubbling"
"L2 Bubbling"
"L1 Bubbling"

答案是這樣的,可以理解嗎?
因為先捕獲:所以所有元素的捕獲事件被處理,捕獲事件自頂而下,所以是 1 -> 2 -> 3 的順序,接著處理冒泡事件,冒泡事件自裡而外,所以是 3 -> 2 -> 1。

歸納成順序就是:

  1. 頂而下的捕獲事件
  2. 觸發者,先捕獲後冒泡
  3. 自裡而外的冒泡事件

事件委託(Event Delegation)

事件冒泡的設計其實還引入了另一個行為:事件委託。
假設一個 <div> 標籤裡有很多的<span> 元素,我們希望當 <span> 元素被點擊的時候,我們要能用事件監聽做出反應,那我們是不是必須要在每個 <span> 上都掛上事件監聽器呢?
在事件冒泡的設計基礎下,不用的。

因為事件會往上冒泡,不管你點擊哪個 <span>,在往上傳遞這個點擊事件的過程中,必定會經過這個共同的父元素 <div>,所以我們可以直接把事件監聽器掛在 <div> 上,就可以監聽他底下所有的元素的事件,這樣的行為就稱作事件委託
在子元素會被動態添加或移除的情況,這樣的事件委託方式也可以避免需要持續手動掛上新的事件監聽器。

因為事件委託倚賴於事件冒泡,而不是所有的事件都有冒泡階段,請參考上面 UI Event 提供的連結,該表格有清楚列出不支援事件冒泡的事件,對於這些事件,就無法使用事件委託的方式處理。

阻止事件冒泡和默認事件行為

除了事件冒泡和事件委託以外,還有一些瀏覽器的默認事件行為(Default Behavior),例如點擊超連結就是要跳轉到對應頁面,點擊提交按鈕就是要提交表單。
在同一元素的順序上,會是事件捕獲 -> 事件冒泡 -> 默認事件行為,默認事件行為會被放在最後。

事件冒泡和默認事件行為有時候我們並不想他觸發,比如說點擊事件,一樣是上面的例子:

<div>
Level 1 <span>Level 2 <span>Level 3</span></span>
</div>

如果我們今天點擊了 Level 3,但我們只想觸發 Level 3 的事件,而不想讓 Level 1 2 也收到點擊事件,那該怎麼辦?
舉更具體的例子,比如一個大的 <div>,裡面有很多零散元素,也有沒有元素的區塊,我們可能有些行為只想對沒有元素的區塊做,而點擊元素則應該只執行元素上的事件。

有個函式專門做這件事:event.stopPropagation()

...
//l3.addEventListener("click", ()=>console.log("L3 Captured"),true);
l3.addEventListener("click", (event)=>{
	console.log("L3 Bubbling");
  event.stopPropagation();},false);
...

假設我們把上面的 JS 改成這樣,則點擊 Level 3 的時候會顯示的文字變成:

"L1 Captured"
"L2 Captured"
"L3 Captured"
"L3 Bubbling"

這個函式一旦執行就會停止該事件的傳遞行為,如同上面說的順序,因為先捕獲後冒泡,所以捕獲事件沒有受到影響,但如果設為

...
l1.addEventListener("click", (event)=>{
	console.log("L1 Captured");
  event.stopPropagation();},true);
...

掛在 Level 1 的捕獲事件上,則即使不對 Level 3 的冒泡事件做任何事,這個點擊事件也會僅在 Level 1 的捕獲觸發後,就不會有任何傳遞,印出來的結果會是

"L1 Captured"

因為順序上,對點擊 Level 3 這個事件而言, Level1 的捕獲是最先發生的(見前面印出來全部的例子)。
但這個 event.stopPropagation(); 函式並不會去阻止默認事件行為,所以比如今天的情境是特定情況下要阻止一個 <a> 的超連結,那要改用這個函式 event.preventDefault()

這個函式就是專門用來處理不觸發默認事件的。
但部分默認事件仍不能被取消,能不能被取消取決於事件上的 Event.cancelable,可以用如 MDN 上的這段程式碼來檢查:

function preventScrollWheel(event) {
  if (typeof event.cancelable !== "boolean" || event.cancelable) {
    // The event can be canceled, so we do so.
    event.preventDefault();
  } else {
    // The event cannot be canceled, so it is not safe
    // to call preventDefault() on it.
    console.warn(`The following event couldn't be canceled:`);
    console.dir(event);
  }
}

document.addEventListener("wheel", preventScrollWheel);

如果想看表列,一樣是 UI Event 連結裡有一個列是用來標注 cancelable 的。

DOM 上屬性掛載事件

想必大家都看過 <span onclick="dosth()"> 的寫法,這樣的寫法也是掛上事件的方式,但這是透過 DOM 元素上的屬性去設置,背後也是事件機制運作。但這種方式設置的事件只限於事件冒泡(無法設定為捕獲),且同一事件同一元素僅能有一個觸發事件,後設置的事件會覆蓋本來已在上面的事件。
相對來說,使用監聽器的方式設置的事件可以有多個,而且可以更靈活的處理。

查看單一 DOM 元素上的事件

到目前 JS 尚未有直接標準定義確認單一元素上的事件列表的方式,但如果是在 Chrome 環境開啟網頁,可以透過開發者工具,使用 getEventListeners(DOM Element) 的方式來檢查該元素上的事件列表,顯示出來如:

{click: Array(1), mouseenter: Array(2), mouseleave: Array(2), focus: Array(2), blur: Array(2)}blur: (2) [{…}, {…}]click: [{…}]focus: (2) [{…}, {…}]mouseenter: (2) [{…}, {…}]mouseleave: (2) [{…}, {…}][[Prototype]]: Object

上一篇
JavaScript 裡的二進位與關於檔案的那些事(ArrayBuffer, Blob and File)
下一篇
Hope JavaScript is less suprise to you now
系列文
Don't make JavaScript Just Surpise31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言