Javascript - 事件傳遞原理與常見函式功用

秉著怕被嘴「怎麼連這麼基本的都不會?」和希望可以對瀏覽器的運作方式更加透徹,
希望趁有時間時來重新複習一下JS的事件是如何傳遞的,以及如何透過一些常見的手段來使網頁的listener不要過多來優化使用者體驗。

事件基礎知識

捕獲與冒泡

DOM的事件在傳播時,事件會以三個階段進行。

  1. 捕獲 - 事件從根節點開始往下傳遞到target的過程。
    若此向下過程有經過其他綁定同類型事件之元素,該元素之事件會處在CAPTURING_PHASE之階段。

  2. 目標 - 當事件確實到達目標元素時,該目標的事件函數會處在AT_TARGET之階段。

  3. 冒泡 - 事件從目標元素離開,一路往上從子節點逆向傳回至根節點的過程。
    若此向上過程有經過其他綁定同類型事件之元素,該元素之事件會處在BUBBLING_PHASE之階段。

事件傳遞三階段

綁定事件

參考了一下MDN跟Web Fundamental的文件,我們來整理一下為一個DOM物件綁定事件所要用到的參數。

element.addEventListener(
// 1. type:string,綁定事件類型
type,

/*
2. callback:func,事件觸發後會執行的函式
e.type - 什麼種類的事件
e.target - 目前事件傳遞之DOM物件
e.eventPharse - 目前事件所處之階段
CAPTURING_PHASE =1
AT_TARGET =2
BUBBLING_PHASE =3
*/
(e) => {
console.log(event.type, e.target, e.eventPharse);
},

/*
3. option:obj,額外設定(非必須可省略)
once - 預設值為false,
設成true表示該事件僅可被處發一次,觸發完後會自行移除。
passive - 預設值為false,
設成true將不會呼叫e.preventDefault(),
即使強制呼叫使用者代理也只會無視並以console.warn警告。
*/
{
once: true,
passive: true
},

/*
4. useCapture:bool,預設值為false,
初始接取機制,會影響有祖先元素的目標元素觸發事件的先後順序。
(非必須可省略)
true - 表示把listerner添加在捕獲階段。
false - 表示把listener添加在冒泡階段。
*/

);

移除事件

若要解除事件的註冊,則可透過removeEventListener()來取消。

// 須要注意cb這個函數必須是跟綁定時同一個實體(亦即是必須指向同一個位置的變數)
element.removeEventListener(type, cb, useCapture);
const btn = document.querySelector('#btn');
const cb = () => {
console.log('重要的是綁定事件跟移除事件時,要刪除指向同一個reference的event handler才有用!');
};
btn.addEventListener('click', cb, false);
btn.removeEventListener('click', cb, false);

事件傳遞之範例

試想我們有一個簡易的導航欄結構如下。

<html>
<head>
<meta charset="utf-8">
</head>
<body>
<ul class="navbar">
<li class="navbar__item">
<a class="navbar__link" href="/category/" target="_blank">分類</a>
</li>
</ul>
</body>
</html>

試著把這三個DOM物件之節點都各綁上兩個點擊事件(捕獲和點擊的區段都監聽),並記錄Phase是怎麼變化的。

const navbar = document.querySelector('.navbar');
const navbarItem = document.querySelector('.navbar__item');
const navbarLink = document.querySelector('.navbar__link');

navbar.addEventListener('click', (e) => {
console.log('navbar capturing', e.eventPharse);
}, true);

navbar.addEventListener('click', (e) => {
console.log('navbar bubbling', e.eventPharse);
}, false);

navbarItem.addEventListener('click', (e) => {
console.log('navbar__item capturing', e.eventPharse);
}, true);

navbarItem.addEventListener('click', (e) => {
console.log('navbar__item bubbling', e.eventPharse);
}, false);

navbarLink.addEventListener('click', (e) => {
console.log('navbar__link capturing', e.eventPharse);
}, true);

navbarLink.addEventListener('click', (e) => {
console.log('navbar__link bubbling', e.eventPharse);
}, false);

點一下導航欄的超連結,可得到以下結果。

navbar capturing
1
navbar__item capturing
1
navbar__link capturing
2
navbar__link bubbling
2
navbar__item bubbling
3
navbar bubbling
3

取消事件傳遞

可以透過stopPropagation()stopImmediatePropagation()中止事件鏈的傳遞,
加在哪裡事件就斷在哪裡,不會繼續傳遞。

stopPropagation()

btn.addEventListener('click', (e) = >{
// 事件不會被傳遞至下一個node,但無法避免同一層級的node之其他listener觸發事件。
e.stopPropagation();
console.log('我話講完?誰同意?誰反對?');
}, true);

btn.addEventListener('click', (e) => {
connsole.log('異議あり!');
}, true);

結果:

我話講完?誰同意?誰反對?
異議あり!

stopImmediatePropagation()

// 事件不會被傳遞至下一個node,且同層級的node之其他listener也無法觸發事件。
btn.addEventListener('click', (e) = >{
e.stopImmediatePropagation();
console.log('我話講完?誰同意?誰反對?');
}, true);

btn.addEventListener('click', (e) => {
connsole.log('異議あり!');
}, true);

結果:

我話講完?誰同意?誰反對?

取消預設行為

preventDefault()

瀏覽器常常會有一些預設行為,e.g.<a>會開啟新連結,<form>提交時會向server提交表單。
想要取消這些預設行為,可以使用preventDefault(),須要注意的是它無法中斷事件鏈的傳遞。

form.addEventListener('submit', (e) => {
e.preventDefault();
console.log('我先不提交表單啦Jojo!');
// TODO: 做些驗證或幹點活再做提交的動作
}, false);

事件代理

想像<ul>有一百個一千個<li>或是<svg>有很多個資料點<circle>的情況,
若是對它們的子代綁定event handler將會嚴重影響瀏覽器之效能破壞使用者體驗,
此時我們可以透過將event handler統一綁在它們共同的親代上來達到簡易的使用者體驗優化。

<ul class="pagination__bar">
<li class="pagination__item"><a href="/?paged=1">1</a></li>
<li class="pagination__item"><a href="/?paged=2">2</a></li>
<li class="pagination__item"><a href="/?paged=3">3</a></li>
<li class="pagination__item"><a href="/?paged=4">4</a></li>
<li class="pagination__item"><a href="/?paged=5">5</a></li>
</ul>
// 不跳轉頁面,純顯示子代<li>之href屬性
const paginationBar = document.querySelector('.pagination__bar');
paginationBar.addEventListener('click', (e) => {
e.preventDefault();
if(e.target && e.target.nodeName === 'A') {
console.log(e.target.href);
}
});

參考