什么是事件流
如果你要生成一个商品列表 <li>,里面的每个元素都能点击
js
document.querySelectorAll('.item').forEach(el => {
el.addEventListener('click', () => console.log(el.dataset.id))
})
跑起来肯定没问题。但如果列表是动态渲染 的(搜索、翻页、无限滚动),新加进来的 <li> 就不会有反应 ------ 因为 querySelectorAll 是在那个瞬间抓的静态集合,监听器根本没绑到新<li>上。
新手解法是"每次渲染完重新绑一遍",但列表有 1000 项时要绑 1000 个监听器,内存和性能都不划算。
标准解法是事件委托:只在父元素上绑一个监听器,靠"事件会自己往上跑"来判断是谁被点了。
而这个"自己往上跑",就是事件流(Event Flow)。
事件流的特点
当你点击一个嵌套很深的元素,比如元素A内部有元素B,元素B内部有元素C,此时你触发了在最里面的C,此时浏览器并不会只通知它,而是沿着 DOM 树走一条完整的路径,顺便把B和A一起触发了,这分了三个阶段:
ini
window
↓ ①捕获 ↑ ③冒泡
document
↓ ↑
<div id="outer"> ↑
↓ ↑
<button id="target"> ↑ ← ②目标阶段
⌖ 你点这里
| 阶段 | 传播方向 | 监听器什么时候触发 |
|---|---|---|
| ① 捕获(Capture) | window → document → ... → 目标元素的父级 | 只有 capture: true 的监听器 |
| ② 目标(Target) | 停在目标元素本身 | 该元素上的所有监听器(不分 capture 真假) |
| ③ 冒泡(Bubble) | 目标元素的父级 → ... → document → window | 只有 capture: false(默认)的监听器 |
效果如下
html
<div id="outer" style="padding:40px;background:#eee">
outer
<button id="inner">inner</button>
</div>
<script>
const outer = document.getElementById('outer')
const inner = document.getElementById('inner')
outer.addEventListener('click', e => console.log('outer 捕获'))
outer.addEventListener('click', e => console.log('outer 冒泡'))
inner.addEventListener('click', e => console.log('inner 捕获'))
inner.addEventListener('click', e => console.log('inner 冒泡'))
</script>
输出:

先把inner全部触发,再把outer全部触发;注意两个细节:
- 捕获阶段到"目标的父级"就结束了,目标元素本身属于目标阶段;
- 默认注册的监听器只在冒泡阶段触发。想让它提前在捕获阶段直接跑,有这几种方法:
js
el.addEventListener('click', fn) // 冒泡(默认)
el.addEventListener('click', fn, true) // 老写法:第三参数 = capture
el.addEventListener('click', fn, { // 新写法:配置对象
capture: true,
once: true, // 触发一次后自动移除
passive: true // 承诺不调 preventDefault(见第七节)
})
验证一次:顺序到底是怎么跑的
html
<div id="outer" style="padding:40px;background:#eee">
outer
<button id="inner">inner</button>
</div>
<script>
const outer = document.getElementById('outer')
const inner = document.getElementById('inner')
outer.addEventListener('click', e => console.log('outer 捕获', e.eventPhase), true)
outer.addEventListener('click', e => console.log('outer 冒泡', e.eventPhase))
inner.addEventListener('click', e => console.log('inner 捕获', e.eventPhase), true)
inner.addEventListener('click', e => console.log('inner 冒泡', e.eventPhase))
</script>
点击 inner,控制台输出:
e.eventPhase 的取值:1 = 捕获,2 = 目标,3 = 冒泡。
事件委托:回到开头那个需求
html
<ul id="list"></ul>
<script>
const list = document.getElementById('list')
// 渲染 1000 条(真实项目里由框架来做)
list.innerHTML = Array.from({ length: 1000 }, (_, i) =>
`<li class="item" data-id="${i}"><span class="title">商品 ${i}</span></li>`
).join('')
// 只绑一个监听器,且对【之后动态新增】的 li 同样生效
list.addEventListener('click', (e) => {
const li = e.target.closest('.item') // ← 关键:向上找最近的 li
if (!li || !list.contains(li)) return // ← 关键:防止越界匹配
console.log('点击了商品', li.dataset.id)
})
</script>
委托绑在哪一层? 不是越外越好。绑到最近的、稳定的共同父元素:影响范围小、判断链短、调试直观,也避免和其他全局委托互相干扰。
什么时候别用委托?
- 事件不冒泡;
- 子元素需要独立的阻止逻辑 (比如子元素自己要
stopPropagation,会把委托容器也一起拦掉); - 层级很深且判断条件复杂时,一个巨大的
switch (e.target.className)反而比直接绑更难维护;