瀏覽器事件是前端開發的基礎,但很多人對事件捕獲、冒泡、委託的理解不夠深入,踩了不少坑。這篇文章系統整理一下。
事件流的三個階段
當用戶點選一個元素,瀏覽器經歷三個階段:
Window
└── Document
└── html
└── body
└── div#container(1. 捕獲階段 ↓)
└── button(2. 目標階段)
└── div#container(3. 冒泡階段 ↑)
javascript
// addEventListener 第三個引數 true = 捕獲階段,false(預設)= 冒泡階段
element.addEventListener("click", handler, true); // 捕獲
element.addEventListener("click", handler, false); // 冒泡(預設)
// 推薦用 options 物件寫法,更清晰
element.addEventListener("click", handler, { capture: true });
阻止冒泡
javascript
document.getElementById("child").addEventListener("click", (e) => {
e.stopPropagation(); // 阻止事件繼續冒泡
// e.stopImmediatePropagation() // 還阻止同元素上的其他監聽器
});
事件委託
不在每個子元素上繫結事件,而是在父元素統一處理,利用冒泡:
javascript
// 不好的做法:給每個 li 繫結事件(記憶體消耗大,動態新增的元素無效)
document.querySelectorAll("li").forEach((li) => {
li.addEventListener("click", handleItemClick);
});
// 好的做法:委託給父元素
document.getElementById("list").addEventListener("click", (e) => {
const li = e.target.closest("li"); // closest 向上找最近的 li
if (!li) return;
const id = li.dataset.id;
handleItemClick(id);
});
// 動態新增的 li 也能響應事件 ✅
const newLi = document.createElement("li");
newLi.dataset.id = "100";
newLi.textContent = "新專案";
document.getElementById("list").appendChild(newLi);
e.target vs e.currentTarget
javascript
document.getElementById("parent").addEventListener("click", (e) => {
console.log(e.target); // 實際觸發事件的元素(可能是子元素)
console.log(e.currentTarget); // 繫結監聽器的元素(parent)
});
常用滑鼠事件
javascript
element.addEventListener("mouseenter", () => {}); // 進入元素,不冒泡
element.addEventListener("mouseleave", () => {}); // 離開元素,不冒泡
element.addEventListener("mouseover", () => {}); // 進入元素或子元素,會冒泡
element.addEventListener("mouseout", () => {}); // 離開元素或子元素,會冒泡
mouseenter / mouseleave 不會在經過子元素時觸發,通常更好用。
常用鍵盤事件
javascript
document.addEventListener("keydown", (e) => {
console.log(e.key); // 'Enter', 'Escape', 'ArrowUp' 等
console.log(e.code); // 'KeyA', 'Digit1' 等(物理鍵)
console.log(e.keyCode); // 已廢棄,用 e.key
// 組合鍵
if (e.ctrlKey && e.key === "z") {
/* Ctrl+Z */
}
if (e.metaKey && e.key === "s") {
/* Cmd+S */
}
if (e.shiftKey && e.key === "Enter") {
/* Shift+Enter */
}
});
自定義事件
javascript
// 建立和派發自定義事件
const event = new CustomEvent("user:login", {
bubbles: true,
cancelable: true,
detail: { userId: 123, username: "Alice" },
});
document.dispatchEvent(event);
// 監聽自定義事件
document.addEventListener("user:login", (e) => {
console.log(e.detail.username); // 'Alice'
});
在 Vue 裡可以用這個方式做跨元件通訊(雖然 Vuex 更適合)。
防抖:頻繁觸發只執行最後一次
javascript
// scroll/resize/input 等高頻事件需要防抖
function debounce(fn, delay) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
window.addEventListener(
"resize",
debounce(() => {
console.log("resize 結束後才執行");
}, 300),
);
事件監聽器的清除
javascript
// 常見記憶體洩漏:綁了事件但沒有清除
class Component {
handleClick = () => {};
mount() {
document.addEventListener("click", this.handleClick);
}
destroy() {
// 必須清除!否則元件已銷燬,監聽器還在
document.removeEventListener("click", this.handleClick);
}
}
// 使用 AbortController(新方式,更優雅)
const controller = new AbortController();
document.addEventListener("click", handler, { signal: controller.signal });
// 清除時
controller.abort(); // 移除所有用該 signal 繫結的監聽器
小結
- 事件經過捕獲 → 目標 → 冒泡三個階段
- 事件委託利用冒泡,減少事件繫結,支援動態元素
e.target是觸發元素,e.currentTarget是繫結監聽的元素mouseenter/mouseleave不冒泡,通常比mouseover/mouseout好用- 記得在元件銷燬時清除事件監聽,防止記憶體洩漏