一文搞懂 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) 反而比直接绑更难维护;
相关推荐
程序员Sunday3 小时前
JavaScript 事件循环面试题,宏任务与微任务怎么执行|Sunday面试指南
开发语言·javascript·面试·校招·事件循环·程序员sunday
默_笙4 小时前
🌊 向量库和 ES 都查不出"关系",我只好给奶茶建了一张人脉网
前端·javascript
思无邪665 小时前
用 AI 做 JS 逆向:从抓包到复现的完整方法论
开发语言·javascript·人工智能
福兮说7 小时前
浏览器里把 iPhone 的 HEIC 转成 JPG:Chrome 解不开、转完大了七成、拍摄时间全丢,六个坑实测
前端·javascript·图像处理·chrome·ios·iphone·heic
可乐鸡翅yeah_8 小时前
video.js 集成 hls.js 开发 M3U8 播放器,新手高频踩坑
开发语言·前端·javascript·后端·ecmascript·m3u8·音视频在线播放
用户117953654789 小时前
TRAE vs Claude Code 性能对比:从代码生成、多文件重构到底层吞吐,谁更适合你的日常开发?
javascript
kill52210 小时前
useRef
前端·javascript·react.js
米多科技10 小时前
我做了一个 3D 虚拟世界基底,在上面再搭 3D 世界或元宇宙,事半功倍
javascript·人工智能·github
霍营_托尼11 小时前
纯前端播 40GB 本地视频?我把浏览器改造成了「磁盘流式」播放器
前端·javascript·音视频开发
西柚小萌新11 小时前
【LLM&&AI应用开发 八股文】--4.2.Agent智能体(中)
前端·javascript·react.js