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

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

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

html

复制代码

点我

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 树中的传播分为三个阶段:

捕获阶段(Capture Phase) :事件从 window 出发,沿着 DOM 树从上到下,一路传播到目标元素的父节点。

目标阶段(Target Phase):事件到达事件源本身。

冒泡阶段(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"

三、事件冒泡

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

1. 冒泡示例

html

复制代码

点我

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

scss

复制代码

点击了 small

点击了 medium

点击了 large

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

2. 阻止冒泡

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

javascript

复制代码

document.querySelector('.small').addEventListener('click', (e) => {

e.stopPropagation(); // 阻止冒泡

console.log('点击了 small');

});

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

在 React 和 Vue 中阻止冒泡:

jsx

复制代码

// React

{

e.stopPropagation();

console.log('只在这里触发');

}}>

html

复制代码

点我

四、事件捕获

事件捕获 和冒泡方向正好相反------事件从最外层 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); // 捕获阶段监听

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

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

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

复制代码

  • 项目 A
  • 项目 B
  • 项目 C

事件委托的优点

减少内存消耗:1000 个按钮只需要 1 个监听器,不需要循环绑定。

动态元素自动生效:后续新增或删除列表项,不需要手动绑定/解绑事件。

事件委托的缺点

依赖事件冒泡 :对 focus、blur 等不冒泡的事件不适用。

可能被中途阻止 :如果某个子元素调用了 e.stopPropagation(),事件就到不了父元素,委托失效。

需要额外判断 e.target:代码中需要写匹配逻辑。

建议就近委托 :在 ul 上代理 li 就好,不要在 document 上代理所有东西,避免不必要的处理函数调用。

事件委托在现代框架中

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

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

六、总结

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

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



Copyright © 2088 世界杯惨案_世界杯主题 - tcjhsy.com All Rights Reserved.
友情链接