返回首页

事件委托与事件流:冒泡、捕获和一个高频实战模式

事件委托是前端最实用的性能技巧之一,但要真正用对,得先搞懂事件流的三个阶段、target 和 currentTarget 的区别。这篇把事件冒泡、捕获讲透,再落到动态列表、阻止冒泡等真实场景。

36 约 5 分钟 · 4444 字 前端

如果你给一个长列表的每一项都绑了点击事件,然后发现列表越长页面越卡、动态新增的项还点不动——那你需要的就是事件委托

而要理解事件委托为什么可行,得先从浏览器的事件流说起。

事件流的三个阶段

当你点击页面上一个元素时,这个事件并不是只在那个元素上发生。它会经历三个阶段:

  1. 捕获阶段(Capturing):事件从 window 往下,一层层穿过祖先元素,到达目标元素。
  2. 目标阶段(Target):到达被点击的那个元素本身。
  3. 冒泡阶段(Bubbling):事件再从目标元素往上,一层层回到 window

打个比方:你点击了 <button>,事件先从最外层"俯冲"下来(捕获),命中按钮(目标),再"上浮"回去(冒泡)。

<div id="outer">
  <div id="inner">
    <button id="btn">点我</button>
  </div>
</div>
;['outer', 'inner', 'btn'].forEach((id) => {
  const el = document.getElementById(id)
  // 第三个参数 true = 捕获阶段监听,false(默认)= 冒泡阶段监听
  el.addEventListener('click', () => console.log(`捕获 ${id}`), true)
  el.addEventListener('click', () => console.log(`冒泡 ${id}`), false)
})

点击按钮,输出顺序是:

捕获 outer
捕获 inner
捕获 btn
冒泡 btn
冒泡 outer   // 注意:btn 自己在目标阶段,捕获和冒泡按注册顺序触发
冒泡 inner
冒泡 outer

addEventListener 的第三个参数控制你在哪个阶段监听:true 是捕获,默认 false 是冒泡。日常 99% 用冒泡,事件委托正是建立在冒泡之上。

target 和 currentTarget:别搞混

事件对象上有两个容易混淆的属性,理解事件委托必须分清:

  • event.target:真正触发事件的那个元素(你点的是哪儿,它就是哪儿)。
  • event.currentTarget:当前正在处理事件的元素,也就是 addEventListener 绑定的那个元素,等同于回调里的 this
list.addEventListener('click', function (e) {
  console.log(e.currentTarget) // 永远是 list(绑定者)
  console.log(e.target)        // 你实际点中的子元素,可能是某个 <li> 或里面的 <span>
})

事件委托的核心,就是在父元素上监听,通过 e.target 判断实际点的是哪个子元素

什么是事件委托

事件委托(event delegation)= 利用冒泡,把本应绑在多个子元素上的监听器,统一绑到它们共同的父元素上

为什么要这么做?直接给每个子元素绑事件有两个问题:

  1. 性能:一千个 <li> 就是一千个监听器,占内存、绑定也慢。
  2. 动态元素:后来用 JS 新增的 <li>,不会自动拥有事件,得手动再绑一次。

委托到父元素就同时解决了这两点:只有一个监听器,而且无论子元素是初始就有还是后来动态加的,冒泡都会经过父元素。

基础写法

<ul id="todo-list">
  <li data-id="1">学习事件委托 <button class="del">删除</button></li>
  <li data-id="2">写一篇博客 <button class="del">删除</button></li>
</ul>
const list = document.getElementById('todo-list')

list.addEventListener('click', (e) => {
  // 只关心点到了"删除"按钮的情况
  const btn = e.target.closest('.del')
  if (!btn) return // 点的不是删除按钮,忽略

  const li = btn.closest('li')
  const id = li.dataset.id
  console.log('删除', id)
  li.remove()
})

这里的关键是 e.target.closest('.del'):

  • e.target 可能是按钮本身,也可能是按钮里的图标 <svg>
  • closest('.del') 会从 e.target 往上找最近的匹配 .del 的祖先(含自身),精准命中目标,且不怕内部还有嵌套元素

比起老式的 e.target.classList.contains('del'),用 closest 更健壮——因为点到按钮内部的子元素时,classList.contains 会判断失败,而 closest 不会。

实战:动态列表 + 多类型操作

一个列表项里往往有多个按钮(编辑、删除、置顶)。委托一次,用 data-action 区分:

<ul id="post-list">
  <li data-id="10">
    <span>一篇文章</span>
    <button data-action="edit">编辑</button>
    <button data-action="delete">删除</button>
    <button data-action="top">置顶</button>
  </li>
</ul>
const handlers = {
  edit: (id) => console.log('编辑', id),
  delete: (id) => console.log('删除', id),
  top: (id) => console.log('置顶', id),
}

document.getElementById('post-list').addEventListener('click', (e) => {
  const btn = e.target.closest('button[data-action]')
  if (!btn) return

  const action = btn.dataset.action
  const id = btn.closest('li').dataset.id
  handlers[action]?.(id)
})

哪怕之后动态 appendChild 新的 <li>,这套逻辑完全不用改——这就是委托的威力。

阻止冒泡与阻止默认行为

理解了事件流,就能正确使用这几个方法,别再傻傻分不清:

  • e.stopPropagation():阻止事件继续往上冒泡(或往下捕获)。常用于"卡片可点击,但卡片内的按钮点击不要触发卡片的点击"。
  • e.stopImmediatePropagation():不仅阻止冒泡,还阻止同一元素上其它监听器执行。
  • e.preventDefault():阻止浏览器默认行为(如 <a> 跳转、表单提交、右键菜单),但不影响冒泡
card.addEventListener('click', () => openDetail())

card.querySelector('.fav').addEventListener('click', (e) => {
  e.stopPropagation() // 点收藏按钮时,别触发卡片的 openDetail
  toggleFavorite()
})

⚠️ 注意:滥用 stopPropagation 会破坏事件委托。如果子元素阻止了冒泡,父元素的委托监听器就收不到事件了。所以能不阻止就不阻止,确有需要再用。

事件委托的边界

委托很好用,但不是万能,这几种情况要注意:

第一,不冒泡的事件没法委托。focusblurmouseentermouseleave 默认不冒泡。要委托得用会冒泡的版本:focusin / focusout 代替 focus / blur;mouseover / mouseout 代替 mouseenter / mouseleave

第二,需要精确控制时反而更麻烦。 如果每个子元素逻辑差异很大,委托里堆一堆 if 判断,可读性还不如直接绑定。

第三,target 命中要写稳。 子元素结构有嵌套时,务必用 closest,而不是直接比较 e.target,否则点到内部子节点会判断失败。

总结

事件委托背后只有一个原理:事件会冒泡

把它讲成一条链路:

  1. 事件流有捕获、目标、冒泡三个阶段,日常用冒泡。
  2. e.target 是实际点击的元素,e.currentTarget 是绑定监听的元素。
  3. 在父元素上监听,用 e.target.closest(selector) 判断点中了哪个子元素。
  4. 好处是只用一个监听器,且天然支持动态新增的元素。

记住这套模式,你会发现后台管理系统里大量的列表、表格操作,都能用一个委托监听器干净利落地搞定。