事件 (Event) 的註冊、觸發與傳遞

讓我們來聊聊 JavaScript 裡的 Event 與 EventTarget 之間的那些事兒

realdennis
11 min readDec 26, 2018

本文寫給初次接觸事件的讀者 — JavaScript 最初被發明於用戶端環境 (client-side) 的開發語言,為了能夠處理使用者的互動、交互,我們總是需要仰賴事件機制。這篇文章正是想探討 JavaScript 中的事件機制,先討論什麼是事件?為什麼我們需要事件?事件的機制到底是什麼?以及最簡單的註冊與觸發。

我會以寫給當初的自己的角度出發,想辦法完整且詳細的介紹每一個與事件的相關且必須了解的知識,告訴自己,仔細看完這篇就夠了。

那就讓我們開始吧!

希望這篇文章能夠抓在 2 min read (寫完發現好像沒辦法..)
更新一下:medium好像改了中文文章閱讀時間,所以時間飆高QQ

目錄

  1. 初見事件 — 事件的使用場景
  2. 註冊事件 — addEventListener 解釋、API 語法。
  3. 事件的非同步 — 回呼函數(callback function)
  4. 捕獲、冒泡 — addEventListener 的第三個參數
  5. 事件停止 — stopPropogation()preventDefault() 的誤解
  6. 主動觸發事件 — EventTarget 賦予的 dispatchEvent()

看來,不可能 2 分鐘講完了。。。

為什麼我們需要事件?

如果你跟我一樣,一開始寫的語言不是 JavaScript ,那麼在剛接觸這個語言的時候,肯定會產生這個疑問,為什麼我們需要事件?給我程式進入點,接下來開始運作我寫的腳本不就好了?

這正是我一開始的想法,我感覺對此格格不入的,當初只想找到進入點,比如 jQuery 的 ready ,或是 document 的 load / DOMContentLoaded 。接下來定義一個名為 main 的函數…

document.addEventListener( 'load' , main );function main(){ // 你以為 Python 膩?}

這種方式只是讓我們把原本語言的習慣帶進來,就像是明明身為吸血鬼,卻堅持要用跟主角一樣的替身使者戰鬥。

我們從使用者的立場來想,使用者會不停的去做各種事情,比如點按按鈕、滾動、手勢、送出表單,而身為程式開發者,就應該在適當的時機點去回應使用者。

事件就是這麼的單純

沒錯,如果不針對非同步或是各種巧妙的機制去衍生各種炫炮的名詞的話,事件其實就是在服務各個行為。而我們把這個服務先撰寫好,等到對應的事件產生了,便執行對應的函數。

let btn = document.querySelector('button#btn1');
btn.addEventListener('click',()=>console.log('hello world'));

上述程式碼其實就是事件註冊,我們透過 addEventListener 這個方法把函數註冊上去。

講講 addEventListener

所有 EventTarget 的實例都有這個方法,也就是說不只是 DOM 節點, window 也是 ,甚至你自己 new 一個 EventTarget 的實例,都擁有這樣的能力。

真心不騙,dispatchEvent則是去觸發事件,在其他框架與實現常看到的就像emit、trigger也是如此。

讓我們來看看 addEventListener 的語法格式。

target.addEventListener(type, listener[, useCapture]);

如同上述舉的例子,最純粹的事件註冊方式如上,我們告訴整個系統,我們在等待些什麼。

我們希望能在使用者做了一些操作後(比如點擊 — click),做一些什麼事情(函數 — callback)。這個函數可以是為某個變數做+1、發起網路請求亦或是彈出警告窗與廣告(比如迷片網站)。

事件回調函數 (callback)

在 JavaScript 中,函數為一級物件,長話短說我們可以把函數當做參數傳入,就像是這個樣子。

而事件回調也是如此,我們把 HelloWorld 傳進去之後,等待事件被觸發那一刻,就會去執行他,就如同第3行的 func()

常常聽到別說「非同步事件」到底在非同步什麼?

(這標題真長…)

想舉一個簡單的例子,比如餐廳的服務生,他在上班的時候很多時間是處於待命的狀態,主管只跟他說:「如果客人進門,就說歡迎光臨」、「如果客人點餐完,就幫他結賬」、「如果XXX發生,就去做OOO」。

你可能會直覺的認為這是一系列的 if & else statement ,但必須理解的是,在循序執行的程式中,這樣子的做法是沒問題的,但是餐廳服務生在下班前的那刻,永遠無法預估幾點幾分客人會做什麼樣的事,只能被動去服務對應的情況。我們可以說,服務生是一個事件目標的實體。

服務生 →註冊 客人進來的事件 →說歡迎光臨。

服務生 →註冊 客人點餐事件 →幫忙結賬

服務生 →註冊 OOO →執行 XXX

var waiter = new EventTarget();function XXX(){

// Do Something!
}waiter.addEventListener('OOO',XXX)

先來個小結論:當一個慣老闆、慣主管,你就是去幫下面的工讀生註冊事件,然而你沒有辦法控制事件被觸發的順序。

註:removeListener 可以移除掉這個事件。

來解釋一下為什麼不用 while true

(如果你沒興趣可以直接往下滑)

JavaScript 一開始被設計為單執行緒的語言,這就像服務生只有一個人,我們希望他能夠 flex 一點,遇到什麼狀況去做什麼事。

可以試試看,在某個事件的 callback 裡寫下 while true 的無窮迴圈,會發現再也無法服務其他事件,而卡死在迴圈之中,並不會被 switch 走喔。

切記 JavaScript 是 single thread。

而使用 setInterval 則可以讓每個時間段的 callback 重新回到 callback queue 裡頭排隊。

while(true) vs setInterval(function(),0)

可以來解答什麼是非同步執行了

服務生沒有辦法預估下一秒會發生什麼事,可能便當才打開,客人就走進來了!當註冊的事件逐漸龐大之後,你甚至沒辦法理解某段時間內事件被觸發的順序。

「點餐完有人借廁所,帶完路之後有人想結賬,然後有人走進來,所以必須說歡迎光臨」。

也是因此,很多時候我們希望能稍微控制事件被觸發的順序。

關於事件順序以及其方法的探討,我們另外撰寫一篇文章說明:callback hell 與 Promise ,一起來把 setTimeout 封裝成 Promise 吧!

捕獲 (capture) 與冒泡 (bubble)

我們把視角拉回用戶端的程式,當我們在某幾個 DOM 節點註冊事件時,你可能會發現其實 DOM 節點是有嵌套關係。

<Parent>  --> click事件
<Child> --> click事件
</Child>
</Parent>
<!--這時你玩child點下去,會發生什麼事情呢?-->

當我們在父節點與子節點都註冊了一樣的事件,總要有個先來後到吧?

Source : https://stackoverflow.com/questions/7398290/unable-to-understand-usecapture-parameter-in-addeventlistener

先講結論,現在的開發環境有 87% 是預設冒泡的,如果父子都被註冊了相同的事件,他會從內部一路往上詢問。這種感覺就像是…

「叫你們老闆出來!」員工:『我問問我們主管..』 (一段時間之後...)主管:『怎麼了嗎?』「叫你們老闆給我出來!」主管:『我問問老闆秘書…』 (一段時間之後...)秘書:『我是老闆的直屬秘書,擁有權力處理你的問題』 

只有當你在註冊事件 — addEventListener的最後一個參數設為 true 的時候,事件才會用捕獲方式傳遞。

所以以正常邏輯來看事件的路徑大概是這樣:一路傳下去傳到最下面,再慢慢往上回傳。這就是我們常說的 — 先捕獲後冒泡。

老闆 → 秘書(親信) → 主管 → 員工 → 主管 → 秘書(親信) → 老闆

事件停止傳遞

思考一個問題,如果老闆的親信,想要第一時間把重大事件攔截掉該怎麼辦?我們先來複習兩種可能的傳遞方式:

捕獲

↓ 老闆          --> 心臟病發
↓ >> 親信知道 --> 捕獲並且停止傳遞
↓ >>>> 主管 --> (不知)
- >>>>>> 員工 -->(不知)

或是說員工不小心幹了壞事,主管想把事情壓下來。

冒泡

- 老闆        --> (不知)
↑ >> 親信 --> (不知)
↑ >>>> 主管 --> 冒泡 停止傳遞
↑ >>>>>> 員工 --> 出大事

箭頭方向為事件傳遞方向,反正現在看來,這個事件必須停止傳遞!

事件的回調函數拿得到事件實體

window.addEventListener('click', function(e){  console.log(e)})// click --> log: MouseEvent

or 用另一個比較 modern 的方式撰寫回調

window.addEventListener('click', e => console.log(e));// click --> log: MouseEvent

那麼,這個「事件實體」給予了我們什麼能力呢?

Event.stopPropagation() : 我這不就來了?

我們可以透過 Event 實體(也就是 e)的方法 stopPropogation,來控制事件不再往前傳遞,這意味著不管往下(捕獲時)或是往上(冒泡時),都能停止它。

Kira.addEventListener('beFound',e=>{
KILLER_QUEEN.bomb();
e.stopPropogation();
})

我們直接說文解字,Stop(停止) Propogation(傳播)。

所以當我們能拿到事件,就可以控制它的傳遞行為,如同老闆的親信可以掩蓋消息,或員工的主管能夠把事情壓下來這樣。

Event.preventDefault() 呢?

prevent default 防止預設行為觸發,它指的是瀏覽器的預設行為,並非事件傳遞。

比如最常見的<a>href 跳轉,<form>submit action,等等的。

form.addEventListener('submit',e=>{
e.preventDefault();
// 把原生的 Form submit 跳轉行為停掉
})

因此它常常被與前者的停止傳遞混淆,如果傳遞是二維的線性,那麼 default 行為就是第三維的瀏覽器行為,停止預設行為與停止事件傳遞完全是兩碼子事。

看到這裡,代表你已經能明白幾件事:比如下次有人問你「JavaScript到底在 callback 三小?」的時候,你可以想到打工的服務生。

又或者,當有人問你「事件到底在傳遞什麼鬼?」的時候,你會想到大老闆與小員工之間的故事。

事件目標 (EventTarget) 實體註冊 & 事件目標實體觸發事件實體 (Event)

這句完全是繞口令。

從上面的例子我們可以很明顯知道,事件目標事件兩者都有一些特殊的方法,某些時候我們希望能夠去主動觸發已掛起的事件,如果是使用者行為,在 DOM 節點有一些 click()focus() ,但若是有一些自定義的事件類型,我們就需要能有個方法把事件塞進去。

那個塞的動作正是 EventTarget 上都有的dispatchEvent 方法,我們可以透過這種方式把事件實體給塞進去。

事件目標.addEventListener('事件類型',回呼函數)
事件目標.dispatchEvent(事件實體)
ex: window.dispatch(new CustomEvent('Hello'))
^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^
EventTarget Event實體

寫在後面

為了不讓本篇文章過於冗長、跑題,原文章最後 計時器、網路請求和最後的Promise 介紹皆被搬移到 — callback hell 與 Promise ,一起來把 setTimeout 封裝成 Promise 吧!

如果這篇文章有幫助到你更理解事件機制的話,也許可以繼續看下去!

--

--