你点了一个按钮,但它可能不是"它"自己响应的。今天我们就来扒一扒浏览器里那场"谁该负责"的点击风波------事件流。从捕获到冒泡,从目标阶段到事件委托,让你彻底弄懂点一下按钮,背后的整个江湖。
前言
想象一下,你点了一个按钮。这个按钮在一个卡片里,卡片在一个列表里,列表在一个页面里。那么问题来了:是按钮自己"听到"了点击,还是卡片先听到,还是页面先听到?
浏览器其实有一套严格的"传话"机制:事件流。它规定了事件从哪来,到哪去,谁先响应。今天我们就来当一回"事件侦探",追踪一次点击的完整旅程。
一、事件流三阶段:从外到内,再从内到外
当一个事件(比如点击)发生时,它会在DOM树里走一个完整的"U型"路线:
- 捕获阶段 :事件从
window往下走,经过祖先节点,一直到目标元素的父节点。这个阶段像"下楼梯"。 - 目标阶段:事件到达目标元素(你点的那个按钮)。这个阶段像"踩到地雷"。
- 冒泡阶段 :事件从目标元素往上走,经过父节点、祖先节点,一直到
window。这个阶段像"上楼梯"。
用代码验证一下:
html
<div id="parent">
<button id="child">点我</button>
</div>
js
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判断具体是哪个子元素被点击。
html
<ul id="list">
<li>选项1</li>
<li>选项2</li>
<li>选项3</li>
</ul>
js
const list = document.getElementById('list');
list.addEventListener('click', (e) => {
// e.target 是实际被点击的元素
if (e.target.tagName === 'LI') {
console.log('你点了:', e.target.textContent);
}
});
这样,不管以后动态添加多少<li>,都不用再单独绑定事件了。这就是事件委托的威力------用一个监听器管理所有子元素。
三、阻止传播:中途截胡
有时候,你不想让事件继续往上冒泡,可以用stopPropagation()。
js
child.addEventListener('click', (e) => {
e.stopPropagation(); // 事件不再向上冒泡
console.log('点到按钮了');
});
如果既想阻止冒泡,又不想影响当前元素的其他同类型监听器,可以用stopImmediatePropagation()。
注意:不是所有事件都冒泡 。比如focus、blur、scroll不冒泡,但focusin、focusout冒泡。
四、阻止默认行为:让浏览器别"自动执行"
有些事件有默认行为,比如点击<a>会跳转,点击表单提交按钮会刷新页面。你可以用preventDefault()阻止它。
js
document.querySelector('a').addEventListener('click', (e) => {
e.preventDefault(); // 不会跳转
console.log('链接被点了,但不跳转');
});
五、实战:事件委托实现todo列表的删除
我们来升级昨天的待办列表,用事件委托管理删除按钮。
html
<div id="todo-app">
<input id="todo-input" type="text" placeholder="输入待办">
<button id="add-btn">添加</button>
<ul id="todo-list"></ul>
</div>
js
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让浏览器的默认动作失效。
掌握了事件流,你就掌握了交互的底层逻辑。明天我们将继续深入,聊聊自定义事件------让你能像浏览器一样"派发"事件,让代码之间优雅通信。
如果你觉得今天的事件"旅程"够过瘾,点个赞让更多人看到。我们明天见!