一、事件流三階段:從外到內,再從內到外
當一個事件(比如點擊)發生時,它會在DOM樹里走一個完整的“U型”路線:
- 捕獲階段:事件從
window往下走,經過祖先節點,一直到目標元素的父節點。這個階段像“下樓梯”。 - 目標階段:事件到達目標元素(你點的那個按鈕)。這個階段像“踩到地雷”。
- 冒泡階段:事件從目標元素往上走,經過父節點、祖先節點,一直到
window。這個階段像“上樓梯”。
用代碼驗證一下:
<div id="parent">
<button id="child">點我</button>
</div>
const parent = document.getElementById('parent');
const child = document.getElementById('child');
parent.addEventListener('click', () => console.log('parent 捕獲'), true);
parent.addEventListener('click', () => console.log('parent 冒泡'));
child.addEventListener('click', () => console.log('child 捕獲'), true);
child.addEventListener('click', () => console.log('child 冒泡'));
// 點擊按鈕,輸出順序:
// parent 捕獲
// child 捕獲
// child 冒泡
// parent 冒泡
addEventListener的第三個參數useCapture:true表示在捕獲階段觸發,false(默認)表示在冒泡階段觸發。
二、事件代理:把監聽器交給“外包公司”
假如你有一個列表,里面有一百個按鈕。給每個按鈕都加一個點擊事件,不僅代碼繁瑣,而且內存占用高。這時候,事件委托(又叫事件代理)就派上用場了。
原理:利用事件冒泡,把監聽器加在父元素上,然后通過event.target判斷具體是哪個子元素被點擊。
<ul id="list">
<li>選項1</li>
<li>選項2</li>
<li>選項3</li>
</ul>
const list = document.getElementById('list');
list.addEventListener('click', (e) => {
// e.target 是實際被點擊的元素
if (e.target.tagName === 'LI') {
console.log('你點了:', e.target.textContent);
}
});
這樣,不管以后動態添加多少<li>,都不用再單獨綁定事件了。這就是事件委托的威力——用一個監聽器管理所有子元素。
三、阻止傳播:中途截胡
有時候,你不想讓事件繼續往上冒泡,可以用stopPropagation()。
child.addEventListener('click', (e) => {
e.stopPropagation(); // 事件不再向上冒泡
console.log('點到按鈕了');
});
如果既想阻止冒泡,又不想影響當前元素的其他同類型監聽器,可以用stopImmediatePropagation()。
注意:不是所有事件都冒泡。比如focus、blur、scroll不冒泡,但focusin、focusout冒泡。
四、阻止默認行為:讓瀏覽器別“自動執行”
有些事件有默認行為,比如點擊<a>會跳轉,點擊表單提交按鈕會刷新頁面。你可以用preventDefault()阻止它。
document.querySelector('a').addEventListener('click', (e) => {
e.preventDefault(); // 不會跳轉
console.log('鏈接被點了,但不跳轉');
});
五、實戰:事件委托實現todo列表的刪除
我們來升級昨天的待辦列表,用事件委托管理刪除按鈕。
<div id="todo-app">
<input id="todo-input" type="text" placeholder="輸入待辦">
<button id="add-btn">添加</button>
<ul id="todo-list"></ul>
</div>
const input = document.getElementById('todo-input');
const addBtn = document.getElementById('add-btn');
const list = document.getElementById('todo-list');
function addTodo() {
const text = input.value.trim();
if (!text) return;
const li = document.createElement('li');
li.innerHTML = `${text} <button class="delete-btn">刪除</button>`;
list.appendChild(li);
input.value = '';
}
// 事件委托:監聽整個列表
list.addEventListener('click', (e) => {
if (e.target.classList.contains('delete-btn')) {
const li = e.target.closest('li'); // 找到按鈕所在的li
li.remove();
}
});
addBtn.addEventListener('click', addTodo);
input.addEventListener('keypress', (e) => {
if (e.key === 'Enter') addTodo();
});
這里用e.target.closest('li')而不是直接e.target.parentNode,因為刪除按鈕可能在<li>內部的任意層級,closest能向上找到最近的匹配元素,更健壯。
六、常見坑與最佳實踐
1. 混淆 target 和 currentTarget
e.target:實際觸發事件的元素(你點的是哪個)e.currentTarget:綁定了監聽器的元素(監聽器加在誰身上)
在事件委托中,currentTarget是父元素,target是子元素。不要搞混。
2. 阻止冒泡要謹慎
如果你用了第三方組件,隨便stopPropagation可能影響別人的監聽器。除非確有必要,否則別輕易阻止冒泡。
3. 事件委托的局限
如果事件本身不冒泡(如focus),或者你需要在捕獲階段做特殊處理,事件委托就派不上用場。但絕大多數點擊、輸入事件都冒泡。
4. 性能考慮
委托給父元素時,父元素不宜過于靠上(比如document),否則事件觸發頻率太高,判斷條件過多。最好委托給最近的、包含所有子元素的祖先。
七、總結:事件流的“傳話”邏輯
- 事件流分三階段:捕獲(從上到下)→ 目標 → 冒泡(從下到上)
- 事件委托:利用冒泡,把監聽器加在父元素上,通過
target判斷具體子元素。減少監聽器數量,動態元素也適用。 - 阻止傳播:
stopPropagation讓事件不再往上冒泡。 - 阻止默認行為:
preventDefault讓瀏覽器的默認動作失效。
掌握了事件流,你就掌握了交互的底層邏輯。明天我們將繼續深入,聊聊自定義事件——讓你能像瀏覽器一樣“派發”事件,讓代碼之間優雅通信。