在现代 Web 开发中,事件驱动是 JavaScript 的核心特征之一。无论是点击按钮、输入文本,还是页面加载完成,背后都依赖于一套精密的 DOM 事件机制。本文将系统讲解:
- 事件是如何发生的?
- 捕获 vs 冒泡:执行顺序到底谁先谁后?
- 为什么不能直接给 NodeList 添加监听器?
- 如何用 事件委托 优化性能?
event.target的关键作用- 大厂面试高频考点解析
一、事件是怎么发生的?—— DOM 事件流三阶段
尽管网页在视觉上是“平面”的,但 DOM 是一棵树形结构。当用户点击一个元素时,事件并非只发生在目标元素上,而是沿着 DOM 树经历三个阶段:
🌐 1. 捕获阶段(Capturing Phase)
- 从
window→document→html→body→ ... → 父级元素 - 目标:逐层向下缩小范围,找到最终被点击的元素
- 此阶段默认不触发监听器(除非显式设置
useCapture: true)
🎯 2. 目标阶段(Target Phase)
- 事件到达实际被点击的元素(即
event.target) - 无论监听器注册在捕获还是冒泡阶段,都会在此阶段执行
🔥 3. 冒泡阶段(Bubbling Phase)
- 从目标元素 → 父级 → 祖先 → ... →
document - 默认行为:所有未指定
useCapture: true的监听器在此阶段触发
✅ 图解点击
<li>时的事件流:
text
编辑
捕获: document → html → body → ul#list → li
目标: li(被点击)
冒泡: li → ul#list → body → html → document
二、addEventListener:DOM 2 级事件标准
✅ 推荐写法(DOM 2 级):
js
编辑
element.addEventListener('click', handler, false); // false 可省略
❌ 不推荐(DOM 0 级):
js
编辑
element.onclick = function() { ... }; // 覆盖风险,无法多监听
参数说明:
| 参数 | 类型 | 说明 |
|---|---|---|
event_type | string | 事件类型,如 'click'、'input' |
callback | function | 事件触发时执行的函数 |
useCapture | boolean | true:捕获阶段;false(默认):冒泡阶段 |
💡 关键点:
- 同一元素可注册多个同类型监听器
- 默认在冒泡阶段执行(更符合直觉:从内到外)
三、为什么不能直接给集合加监听器?
你可能会写出这样的代码(但会报错):
js
编辑
const lis = document.querySelectorAll('#list li');
lis.addEventListener('click', ...); // ❌ TypeError!
原因:
querySelectorAll返回的是 NodeList(类数组对象) ,不是单个 DOM 元素addEventListener是 Element 原型上的方法,只能用于单个节点
正确做法(传统方式):
js
编辑
const lis = document.querySelectorAll('#list li');
lis.forEach(li => {
li.addEventListener('click', () => {
console.log(li.innerHTML);
});
});
⚠️ 问题:
- 每个
<li>都绑定独立监听器 → 内存开销大 - 动态新增的
<li>不会自动绑定事件
四、事件委托(Event Delegation):性能优化利器
利用 事件冒泡 特性,将监听器绑定在父容器上,通过 event.target 判断实际点击元素。
✅ 示例:监听所有 <li>
html
预览
<ul id="list">
<li>1</li>
<li>2</li>
<li>3</li>
</ul>
js
编辑
document.getElementById('list').addEventListener('click', function(event) {
if (event.target.tagName === 'LI') {
console.log('点击了:', event.target.innerHTML);
}
});
✨ 优势:
- 只需一个监听器 → 节省内存
- 动态元素自动生效(无需重新绑定)
- 初始化更快(尤其列表项很多时)
📌 这就是你在代码中看到的:
js 编辑 console.log(event.target, event.target.innerHTML);
五、阻止事件传播:stopPropagation()
有时我们不希望事件冒泡到父级:
js
编辑
document.getElementById('child').addEventListener('click', function(event) {
event.stopPropagation(); // ⛔ 阻止冒泡
console.log('child click');
});
效果:
- 点击
#child→ 只输出"child click" #parent的监听器不会触发
⚠️ 注意:
stopPropagation()会同时阻止冒泡和后续捕获(如果还有祖先在捕获阶段监听)
六、全局事件与 body.onclick
你的代码中有一行:
html
预览
<body onclick="alert('橘子')">
这属于 DOM 0 级内联事件,虽然能用,但存在严重问题:
- 污染 HTML 结构
- 难以维护和测试
- 无法移除或复用
✅ 推荐改写为 JS 绑定:
js
编辑
document.body.addEventListener('click', () => {
alert('橘子');
});
七、大厂面试高频题
📌 Q1:点击子元素,父元素监听器何时执行?
- 若都注册在冒泡阶段:子 → 父
- 若父注册在捕获阶段:父 → 子
📌 Q2:事件委托的原理是什么?
利用事件冒泡,将监听器绑定在父容器,通过
event.target判断实际触发元素。
📌 Q3:event.target 和 this 的区别?
event.target:实际触发事件的元素(可能很深)this:监听器绑定的元素(通常是父容器)
js
编辑
ul.addEventListener('click', function(e) {
console.log(e.target); // <li>
console.log(this); // <ul id="list">
});
八、总结:最佳实践清单
| 场景 | 推荐做法 |
|---|---|
| 绑定事件 | 使用 addEventListener(DOM 2 级) |
| 多个同类元素 | 用事件委托,不要循环绑定 |
| 动态内容 | 必须用事件委托 |
| 阻止冒泡 | event.stopPropagation() |
| 获取真实点击元素 | 用 event.target,而非 this |
| 避免 | 内联事件(如 onclick="...") |
🔑 记住一句话:
“事件从外向内捕获,从内向外冒泡;委托靠冒泡,target 定真身。”
掌握这套机制,你不仅能写出高性能代码,还能轻松应对前端面试中的事件难题