在前端开发中,事件机制(Event System)是 JavaScript 最重要的异步能力之一。无论是按钮点击、输入框变化、列表交互、还是复杂的组件通信,本质上都离不开事件模型。
很多前端刚入门时只会写:
element.onclick = () => {}
但当页面结构变复杂、组件关系多层嵌套时,“事件是怎么从上往下、再从下往上执行的?”、“为什么有时 parent 会先触发,有时 child 会先触发?”、“为什么监听事件不能直接在集合上绑定?”……这些问题就变得关键。
本文将从 DOM 事件模型开始,深入解析 JavaScript 事件捕获、冒泡、监听方式、事件委托等核心机制,并结合代码示例帮助你真正吃透事件原理。
一、浏览器事件是如何发生的?
在浏览器中,页面首先会解析成 DOM 树(Document Object Model) :
document
└─ body
└─ div#parent
└─ div#child
当用户点击 child 时,事件会按照 W3C 标准经历 三个阶段:
1. 捕获阶段(capture)
事件从 document 根节点开始,一层层往下捕获:
document → body → parent → child
如果某个节点注册了捕获阶段的监听器,它就会在此时被触发。
2. 目标阶段(target)
事件到达实际触发元素 event.target(如 #child)
child(事件目标)
3. 冒泡阶段(bubble)
触发完成后,事件再反向冒泡:
child → parent → body → document
如果一个监听器是绑定在冒泡阶段,它会在冒泡时触发。
二、addEventListener:真正标准的事件绑定方式
DOM2 事件模型推荐使用:
element.addEventListener(type, callback, useCapture)
参数解释:
-
type:事件类型,如
'click' -
callback:回调函数
-
useCapture:
false(默认)→ 冒泡阶段触发true→ 捕获阶段触发
例如:
parent.addEventListener('click', () => {
console.log('parent 捕获');
}, true)
child.addEventListener('click', () => {
console.log('child 冒泡');
}, false)
三、事件冒泡示例
下面的代码展示典型的冒泡行为:
<body onclick="alert('橘子')">
<div id="parent">
<div id="child"></div>
</div>
</body>
document.getElementById('parent').addEventListener('click', () => {
console.log('parent click');
}, false)
document.getElementById('child').addEventListener('click', (event) => {
event.stopPropagation(); // 阻止冒泡
console.log('child click');
})
点击 child 时输出:
child click
因为 event.stopPropagation() 阻止了冒泡,所以 parent click 不会再执行。
四、为什么事件监听不能对 “集合” 使用?
很多人喜欢这么写:
document.querySelectorAll('li').addEventListener('click', () => {})
然而 NodeList 是集合,不是 DOM 节点,因此你会得到报错:
TypeError: lis.addEventListener is not a function
必须手动遍历:
const lis = document.querySelectorAll('li')
lis.forEach(li => {
li.addEventListener('click', () => {})
})
但这样带来两个问题:
- 所有 li 都要单独绑定监听器(浪费内存)
- li 新增或删除时,需要重新绑定
这时就需要更优雅的解决方案 —— 事件委托。
五、事件委托(Event Delegation)——最优雅的事件绑定方式
事件委托利用的就是“事件冒泡机制”。
关键思路:
不给每个 li 单独绑定监听器
而是把监听器绑在父元素上
通过 event.target 判断点击的是哪个子元素
示例:
<ul id="list">
<li>1</li>
<li>2</li>
<li>3</li>
</ul>
错误方式(会绑定 3 次监听器):
const lis = document.querySelectorAll('#list li')
lis.forEach(li => {
li.addEventListener('click', () => {
console.log(li.innerHTML)
})
})
正确方式(仅绑定 1 次):
const ul = document.getElementById('list')
ul.addEventListener('click', function(event) {
console.log(event.target, event.target.innerHTML)
})
优势:
1. 内存占用大幅减少
只绑定 1 次,而不是 N 次。
2. 动态元素自动生效
新增 li,不需要额外绑定监听。
3. 性能更好,特别是循环大量 DOM 时
事件委托在实际项目中极高频使用,尤其在表格、列表、无限滚动、虚拟 DOM 等结构中更是标配。
六、event.target 与 event.currentTarget 的区别
event.target
→ 最深层的触发元素(真正被点击的元素)
event.currentTarget
→ 当前绑定监听器的元素
例:
ul.addEventListener('click', function(event) {
console.log('target:', event.target)
console.log('currentTarget:', event.currentTarget)
})
点击 li 时:
target: <li>1</li>
currentTarget: <ul id="list">
七、阻止冒泡、阻止默认行为
阻止事件向上冒泡:
event.stopPropagation()
阻止默认行为(如 a 标签跳转、表单提交等):
event.preventDefault()
八、DOM0 VS DOM2 的区别
DOM0(不推荐)
element.onclick = function() {}
缺点:
- 无法注册多个监听器(会覆盖)
- 与 HTML 耦合性强
- 不能精准控制捕获 / 冒泡
DOM2(推荐)
element.addEventListener('click', handler, false)
优势:
- 同一事件可绑定多个监听
- 可指定捕获/冒泡阶段
- 更符合标准
九、事件是如何成为异步的?
JS 的事件监听本质是 注册 → 等待触发 → 回调执行。
点击事件不是立即执行,而是浏览器在事件触发后:
- 把事件加入事件队列(Event Loop)
- 主线程空闲时取出执行监听函数
所以事件机制本质上就是:
事件注册 = 告诉浏览器:这个事件发生时请调用我的函数
回调执行 = 异步的
十、完整示例:捕获、冒泡、阻止冒泡、事件委托
<div id="parent">
<div id="child"></div>
</div>
<ul id="list">
<li>Apple</li>
<li>Banana</li>
<li>Orange</li>
</ul>
parent.addEventListener('click', () => {
console.log('parent 冒泡')
}, false)
child.addEventListener('click', (e) => {
console.log('child click')
e.stopPropagation() // 阻止冒泡
}, false)
list.addEventListener('click', (event) => {
console.log('委托触发:', event.target.innerHTML)
})
当你点击 child:
child click
(不会触发 parent)
当你点击 li:
委托触发: Apple
总结
JS 事件机制包括:
1.事件三阶段:捕获 → 目标 → 冒泡
2.addEventListener 的 useCapture 决定触发阶段
3.冒泡机制让事件委托成为可能
4.event.target 是事件触发源
5.不要在 NodeList 上直接绑定监听
6.事件委托更高效且适用于动态元素
7.stopPropagation / preventDefault 控制事件行为
8.DOM2 模型比 DOM0 更现代、灵活、标准
如果你理解了捕获、冒泡以及事件委托,你就已经掌握前端事件最核心的知识体系,能应对各种真实业务场景。