一文搞懂 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) 反而比直接绑更难维护;
相关推荐
前端·柱子3 小时前
q-floodfill白边锯齿?一招搞定抗锯齿边缘问题
前端·html·canva可画
VXbishe4 小时前
【课程设计】基于SpringBoot的乡村政务服务系统的设计与实现-计算机毕设77011
javascript·vue.js·spring boot·python·php·课程设计·政务
志尊宝4 小时前
Vue3 零基础每日笔记(037):动手封装 useMouse 与 useWindowSize——事件监听的标准模板
前端·javascript·vue.js·笔记·html5
shmily麻瓜小菜鸡4 小时前
TDD(测试驱动开发)详解
开发语言·javascript·typescript·node.js·ecmascript
wcy03106 小时前
POST为什么会发送两次请求
javascript·ajax
志尊宝6 小时前
Vue3 零基础每日笔记(047):动态路由与路由参数——:id 传参、query 传参、props 解耦
前端·javascript·vue.js·笔记·html5
qq_589666057 小时前
移动前端开发之viewport的深入理解
html
zhanghaha13149 小时前
HTML系列教程:17_HTML 框架(frameset、frame、iframe)零基础详解
前端·html
朝屯暮蒙vi10 小时前
随记:h5+中uploader不能添加参数数据,要通过task
javascript
泯泷18 小时前
那段文字是谁删的?Yjs 14 正式版之前,一套删除归属方案的实现与边界
前端·javascript·算法