在透過 JS 實作互動效果時,我們時常使用監聽器去監聽頁面中的 DOM 元素,並在該元素觸發事件時進行對應的操作去實現想要的互動效果。
而要觸發事件,我們可以被動地藉由使用者操作:
// HTML
<button class="btn">click me!</button>// JS
document.querySelector(".btn").addEventListener("click", function () {
console.log("button is clicked!");
});
直接呼叫元素的方法:
// HTML
<form action="/">
<input type="email" />
<input type="password" />
<button class="btn">submit</button>
</form>const myForm = document.querySelector("form");
document.querySelector(".btn").addEventListener("click", function () {
if (isValid()) {
myForm.submit();
}
});
也可以直接使用 Event constructor:
// HTML
<button class="btn-1">btn1</button>
<button class="btn-2">btn2</button>// JS
const btn1 = document.querySelector(".btn-1");
const btn2 = document.querySelector(".btn-2");
btn1.addEventListener("click", function () {
console.log("btn1 is clicked!");
});
btn2.addEventListener("click", function () {
const evt = new Event("click");
btn1.dispatchEvent(evt); // log: btn1 is clicked!
});
而 Event 有非常多的子介面,CustomEvent 便是其中之一。
什麼是子介面呢?我是這樣理解的:
假設 B 是 A 的子介面,就代表 B 繼承了 A 的屬性及方法;換句話說,我們可以在 B 的原型鏈中找到 A。
例如第一個例子在 .btn 上掛了一個監聽器,如果試著在 function 中把 event 印出:
// HTML
<button class="btn">click me!</button>
// JS
document.querySelector(".btn").addEventListener("click", function (e) {
console.log(e);
});
並將 PointerEvent 的 [[prototype]] 及其的 [[prototype]] 不斷展開,途中應該就能看到原型指向了 Event:
這是因為 PointerEvent 繼承了 MouseEvent,MouseEvent 繼承了 UIEvent,而 UIEvent 則繼承了 Event。如果在 MDN 上查閱 PointerEvent,會直接看到它們的關係:
基於 Event,CustomEvent 有增加且未被棄用的僅有 CustomEvent.detail 這個屬性。這個屬性可以讓我們傳遞各種型別的值,例如:
const evt1 = new CustomEvent("randomEventName", {
detail: "foo",
});
const evt2 = new CustomEvent("randomEventName", {
detail: true,
});
const evt3 = new CustomEvent("randomEventName", {
detail: {
name: "John",
},
});
接著便能將事件 dispatch 給 DOM 元素,例如:
// 監聽事件名稱,並印出接收到事件的 detail 屬性
document.addEventListener("randomEventName", (e) => {
console.log(e.detail); // 對應到 new CustomEvent(eventName, { detail } 的 detail
});// 將事件
document.dispatchEvent(evt1); // "foo"
document.dispatchEvent(evt2); // true
document.dispatchEvent(evt3); // {name: 'John'}
知道了 CustomEvent 的用法,那我們可以怎麼應用呢?
假設我的登入元件使用 Vue.js 建構,它在點選 submit 按鈕時會發送登入請求並取得使用者資訊,而我在頁面中的一個區塊需要在登入時同步顯示使用者的名稱:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<h1>Login as:<span></span></h1><div id="app"></div>
<script src="https://unpkg.com/vue@3"></script>
<script>
Vue.createApp({
template: `
<form>
<input type="email" v-model="email" />
<input type="password" v-model="password" /><button @click.prevent="handleLogin">submit</button>
</form>
`,
data() {
return {
email: "",
password: "",
};
},
methods: {
handleLogin() {
const mockRes = {
name: "John",
};
console.log(mockRes);
// TODO: 將使用者名稱更新至 h1 > span 的 textContent 中
},
},
}).mount("#app");
</script>
</body>
</html>
一個很簡單的做法是我在 handleLogin 中直接去抓取 h1 > span 元素,並將 mockRes.name 更新至其中:
handleLogin() {
const mockRes = {
name: "John",
};
document.querySelector("h1 > span").textContent = mockRes.name;
},
這樣做當然可以正常運作,但試想若這個元件在多個頁面共用,而這些頁面的不同元素都需要使用到 mockRes.name 呢?我們當然也能這樣:
handleLogin() {
const mockRes = {
name: "John",
};
// 首頁用
if (location.pathname === "/") {
document.querySelector("h1 > span").textContent = mockRes.name;
}
// 個人頁用
if (location.pathname === "/me") {
document.querySelector("div > h2").textContent = mockRes.name;
}
// 結帳頁用
if (location.pathname === "/check") {
document.querySelector("section div p").textContent =
mockRes.name;
}
},
隨著頁面越來越多,handleLogin 要檢查的頁面也越來越多。某天我們完成了結帳頁的樣式翻新,檢查了結帳頁自己的 JS 檔確定沒問題,實際操作可能就會發現結帳頁依然有登入成功時使用者的名字未被更新的問題,實際追回問題源頭才發現更新的邏輯被寫在了登入元件的 handleLogin 內。若結帳頁如果還有複用其他元件,每一次的改動可能就要花費額外的時間去檢查這些元件。
如果我們使用 CustomEvent 將其改寫:
handleLogin() {
const mockRes = {
name: "John",
};
const evt = new CustomEvent("loginSuccess", {
detail: {
name: mockRes.name,
},
});
document.dispatchEvent(evt);
}
在每個頁面中,我們就可以藉由掛載獨立的監聽器去做對應的事情:
// 首頁的JS
document.addEventListener("loginSuccess", function (e) {
document.querySelector("h1 > span").textContent = e.detail.name;
});// 個人頁的JS
document.addEventListener("loginSuccess", function (e) {
document.querySelector("div > h2").textContent = e.detail.name;
});// 結帳頁的JS
document.addEventListener("loginSuccess", function (e) {
document.querySelector("section div p").textContent = e.detail.name;
});
這樣一來,登入元件就不會隨著它的複用而變得更加複雜,當我們樣式翻新時,也僅需留意結帳頁自身的 JS 是否有需要調整的部分,而不必回頭去翻理論上沒有直接關係的登入元件。
在《JavaScript設計模式》針對觀察者模式(Observer Pattern)的介紹:
"…這種模式(指觀察者模式)也稱為自訂事件(custom events),指的是你用程式所建立,而非瀏覽器所觸發的事件…"
"…此模式背後的主要動機,是促使降低耦合性。…"
其實我們使用 CustomEvent 就是比較接近觀察者模式,目的也都是為了降低程式之間的耦合性。
關於 CustomEvent 的記錄就到這邊,希望可以幫助到有需要的你。順帶一提,CustomEvent 雖然不被舊型瀏覽器如 IE11 所支援,只要導入它的 Polyfill 就能正常使用。如果這篇文有任何錯誤,也煩請不吝指出,謝謝大家。
References: