一文搞懂 JS 事件流:捕获、冒泡、事件委托

什么是事件流

如果你要生成一个商品列表 <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) 反而比直接绑更难维护;
相关推荐
米米商聊8 小时前
同名附件为什么被 Map 覆盖?重复键与一对多索引
前端·javascript
kyriewen11 小时前
我把 10 道面试题答案真跑了一遍:6 道是错的
前端·javascript·面试
WebInfra12 小时前
Rspack 2.3 发布:联手 SWC 团队带来全新编译器
前端·javascript·github
FRing0313 小时前
JavaScript入门到精通三十一(原型继承、原型链)
开发语言·前端·javascript
程序员大阳13 小时前
副队长JavaScript教程(52)-DOM操作元素的内容
javascript·dom·innertext·innerhtml·value·内容操作
裕波15 小时前
苏千|从 cnpm 到 Vite+:AI 时代的开发者体验演进
javascript
console.log('npc')15 小时前
周期任务功能完整实现:CRON 编辑器 + 模板变量 + 执行历史
javascript·vue.js·编辑器
程序员大阳15 小时前
副队长JavaScript教程(58)-基于DOM的网页留言板开发
javascript·实例·dom·案例·留言板
咚咚咚小柒15 小时前
【前端】LangChain.js学习
前端·javascript·人工智能·typescript·langchain·vue·ai编程
程序员大阳17 小时前
副队长JavaScript教程(55)-DOM节点增删改查
javascript·dom·增删改查·节点·克隆