JavaScript 事件流:彻底搞懂捕获、冒泡与事件委托

先看如下代码,当点击样式为 small 的div后,控制台会输出什么?猜猜输出顺序:

html 复制代码
<div class="large">
  <div class="medium">
    <div class="small">点我</div>
  </div>
</div>
javascript 复制代码
const large = document.querySelector('.large');
const medium = document.querySelector('.medium');
const small = document.querySelector('.small');

large.addEventListener('click', () => console.log('large 冒泡'));
large.addEventListener('click', () => console.log('large 捕获'), true);
medium.addEventListener('click', () => console.log('medium 冒泡'));
medium.addEventListener('click', () => console.log('medium 捕获'), true);
small.addEventListener('click', () => console.log('small 冒泡'));
small.addEventListener('click', () => console.log('small 捕获'), true);

输出结果是:

scss 复制代码
large 捕获
medium 捕获
small 捕获
small 冒泡
medium 冒泡
large 冒泡

这个顺序揭示了 DOM 事件流的核心机制:先捕获,再冒泡。下面我们来一起看一看:

一、DOM 事件流三阶段

DOM 标准规定,当一个事件发生时,它在 DOM 树中的传播分为三个阶段:

  1. 捕获阶段(Capture Phase) :事件从 window 出发,沿着 DOM 树从上到下,一路传播到目标元素的父节点。
  2. 目标阶段(Target Phase):事件到达事件源本身。
  3. 冒泡阶段(Bubble Phase) :事件从事件源开始,沿着 DOM 树从下往上冒泡,回到 window

画成图就是:

关键点:无论你绑定不绑定事件处理函数,这个传播过程都会发生。它传播的是"事件",不是"处理函数"。你可以在传播路径的任意节点上拦截它。

二、addEventListener 的第三个参数

addEventListener 有三个参数:

javascript 复制代码
element.addEventListener(event, handler, useCapture);
  • useCapture = false(默认值):事件处理函数在冒泡阶段触发。
  • useCapture = true:事件处理函数在捕获阶段触发。

也可以写成选项对象的形式:

javascript 复制代码
element.addEventListener('click', handler, {
  capture: true,    // 在捕获阶段触发
  once: true,       // 只触发一次后自动解绑
  passive: true     // 不会调用 preventDefault(),用于滚动性能优化
});

在 React 和 Vue 中,也有对应的写法:

冒泡阶段 捕获阶段
原生 JS addEventListener('click', fn) addEventListener('click', fn, true)
React onClick={fn} onClickCapture={fn}
Vue @click="fn" @click.capture="fn"

三、事件冒泡

事件冒泡 是最常用的阶段。当一个元素触发事件后,事件会像水中的气泡一样,不断向上传递到父元素、再到祖父元素,一直到 documentwindow

1. 冒泡示例

html 复制代码
<div class="large" onclick="console.log('点击了 large')">
  <div class="medium" onclick="console.log('点击了 medium')">
    <div class="small" onclick="console.log('点击了 small')">
      点我
    </div>
  </div>
</div>

点击最内层 small 后,控制台输出:

scss 复制代码
点击了 small
点击了 medium
点击了 large

事件依次从 smallmediumlarge 向上冒泡,每一层绑定的事件处理函数都触发了。注意这里传递的是事件本身 (如 click 事件),而不是事件处理函数。父元素上有绑定 click 事件的,就会触发对应的处理函数;没绑定的,事件照样经过,只是什么都不发生。

2. 阻止冒泡

如果你希望只在 small 上触发事件,不让父元素也收到,可以用 e.stopPropagation()

javascript 复制代码
document.querySelector('.small').addEventListener('click', (e) => {
  e.stopPropagation(); // 阻止冒泡
  console.log('点击了 small');
});

此时点击 small,只会输出 点击了 small,父元素的处理函数不会触发。

在 React 和 Vue 中阻止冒泡

jsx 复制代码
// React
<div onClick={(e) => {
  e.stopPropagation();
  console.log('只在这里触发');
}}>
html 复制代码
<!-- Vue:使用 .stop 修饰符 -->
<div @click.stop="handleClick">点我</div>

四、事件捕获

事件捕获 和冒泡方向正好相反------事件从最外层 window 开始,沿 DOM 树逐层向下传播,直到事件源。

javascript 复制代码
// 捕获阶段触发(第三个参数为 true)
large.addEventListener('click', () => console.log('large 捕获'), true);
medium.addEventListener('click', () => console.log('medium 捕获'), true);
small.addEventListener('click', () => console.log('small 捕获'), true);

点击 small,输出顺序:

scss 复制代码
large 捕获
medium 捕获
small 捕获

捕获阶段适合做什么?

捕获阶段在日常开发中用得不如冒泡多,但它在以下场景中不可或缺:

场景一:全局埋点/数据上报(防止漏记)

假设页面中某些元素调用了 e.stopPropagation() 阻止了冒泡。如果在 document 上使用冒泡阶段做埋点监听,这些元素的点击事件就记录不到了。在捕获阶段监听,可以在事件到达目标之前就记录到,避免被阻止冒泡影响。

javascript 复制代码
document.addEventListener('click', (e) => {
  analytics.track('button_click', { target: e.target });
}, true); // 捕获阶段监听

场景二:监听不冒泡的事件

focusblur 等事件默认不冒泡。如果你想在父容器上统一监听所有子元素的聚焦事件,只能在捕获阶段做:

javascript 复制代码
// 在表单容器上统一监听所有输入框的聚焦事件
form.addEventListener('focus', (e) => {
  console.log('输入框聚焦:', e.target.name);
}, true); // 必须用捕获,因为 focus 不冒泡

五、事件委托

假如一个列表有 1000 条数据,每条数据都有删除按钮。最直接的做法是给每个按钮都 addEventListener。但这样会创建 1000 个监听器实例,占用内存,而且新增或删除列表项时需要手动管理事件绑定/解绑。

事件委托的原理

事件委托 利用事件冒泡机制,只在父元素 上绑定一个事件处理函数,通过 e.target 判断实际触发事件的子元素是哪一个,再执行对应逻辑。

javascript 复制代码
// 只在父容器上绑定一个事件
document.querySelector('.list').addEventListener('click', (e) => {
  if (e.target.matches('.delete-btn')) {
    console.log('删除:', e.target.dataset.id);
  }
});
html 复制代码
<ul class="list">
  <li>项目 A <button class="delete-btn" data-id="1">删除</button></li>
  <li>项目 B <button class="delete-btn" data-id="2">删除</button></li>
  <li>项目 C <button class="delete-btn" data-id="3">删除</button></li>
  <!-- 后续动态新增的 li 也一样有效 -->
</ul>

事件委托的优点

  1. 减少内存消耗:1000 个按钮只需要 1 个监听器,不需要循环绑定。
  2. 动态元素自动生效:后续新增或删除列表项,不需要手动绑定/解绑事件。

事件委托的缺点

  1. 依赖事件冒泡 :对 focusblur 等不冒泡的事件不适用。
  2. 可能被中途阻止 :如果某个子元素调用了 e.stopPropagation(),事件就到不了父元素,委托失效。
  3. 需要额外判断 e.target:代码中需要写匹配逻辑。
  4. 建议就近委托 :在 ul 上代理 li 就好,不要在 document 上代理所有东西,避免不必要的处理函数调用。

事件委托在现代框架中

React 17+ 已经在框架层面做了事件委托------所有合成事件统一绑定在根节点(#root)上,通过内部的事件系统分发到具体组件。所以你在代码中写 onClick,React 并不会直接挂在真实 DOM 节点上。

Vue 的事件绑定虽然默认直接挂在真实 DOM 上,但在 v-for 循环中大量使用 @click 也不会有明显性能问题,因为 Vue 在模板编译阶段做了优化。

六、总结

JS事件流整个过程就像一块石头丢进水里,涟漪先向内收缩(捕获),然后从落点向外扩散(冒泡)。默认情况下,我们通过 addEventListener 或 React 的 onClick 绑定的事件,都是在冒泡阶段触发的。

这三个概念是日常开发中解决弹窗关闭、列表交互、性能优化等问题的基础。希望这篇文章能帮你一次性彻底搞懂它们。

相关推荐
RainmeoX1 小时前
【实战】用纯前端打造绝区零风格 AI 角色助手 WebUI 并联调 vLLM
前端
杨利杰YJlio1 小时前
OpenClaw / clawdbot 是什么?看懂 Agent 体系
前端·后端
CodeSheep2 小时前
他俩只靠写代码,登上了胡润财富榜!
前端·后端·程序员
IT_陈寒2 小时前
React状态更新总是慢半拍?你可能忘了这个默认行为
前端·人工智能·后端
candyTong2 小时前
阿里开源 AI Code Review 工具:ocr review 的执行链路解析
javascript·后端·架构
铁皮饭盒3 小时前
TypeBox 比 Zod.js 校验 快10倍, 还兼容AI 工具调用, 他做对了什么?
前端·javascript·后端
Bigger11 小时前
Tauri (26)——托盘图标总对不上系统主题?一行 Template Image 搞定
前端·rust·app
To_OC12 小时前
从一次栈溢出报错说起,我把递归彻底扒明白了
javascript·算法·程序员