事件 (Event) 的註冊、觸發與傳遞
讓我們來聊聊 JavaScript 裡的 Event 與 EventTarget 之間的那些事兒
本文寫給初次接觸事件的讀者 — JavaScript 最初被發明於用戶端環境 (client-side) 的開發語言,為了能夠處理使用者的互動、交互,我們總是需要仰賴事件機制。這篇文章正是想探討 JavaScript 中的事件機制,先討論什麼是事件?為什麼我們需要事件?事件的機制到底是什麼?以及最簡單的註冊與觸發。
我會以寫給當初的自己的角度出發,想辦法完整且詳細的介紹每一個與事件的相關且必須了解的知識,告訴自己,仔細看完這篇就夠了。
那就讓我們開始吧!
目錄
- 初見事件 — 事件的使用場景
- 註冊事件 —
addEventListener
解釋、API 語法。 - 事件的非同步 — 回呼函數(callback function)
- 捕獲、冒泡 — addEventListener 的第三個參數
- 事件停止 —
stopPropogation()
與preventDefault()
的誤解 - 主動觸發事件 — 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 的實例,都擁有這樣的能力。
讓我們來看看 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 裡頭排隊。
可以來解答什麼是非同步執行了
服務生沒有辦法預估下一秒會發生什麼事,可能便當才打開,客人就走進來了!當註冊的事件逐漸龐大之後,你甚至沒辦法理解某段時間內事件被觸發的順序。
「點餐完有人借廁所,帶完路之後有人想結賬,然後有人走進來,所以必須說歡迎光臨」。
也是因此,很多時候我們希望能稍微控制事件被觸發的順序。
關於事件順序以及其方法的探討,我們另外撰寫一篇文章說明:callback hell 與 Promise ,一起來把 setTimeout 封裝成 Promise 吧!
捕獲 (capture) 與冒泡 (bubble)
我們把視角拉回用戶端的程式,當我們在某幾個 DOM 節點註冊事件時,你可能會發現其實 DOM 節點是有嵌套關係。
<Parent> --> click事件
<Child> --> click事件
</Child>
</Parent><!--這時你玩child點下去,會發生什麼事情呢?-->
當我們在父節點與子節點都註冊了一樣的事件,總要有個先來後到吧?
先講結論,現在的開發環境有 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 吧!
如果這篇文章有幫助到你更理解事件機制的話,也許可以繼續看下去!