[ 前端JavaScript的事件流机制 ] - 事件捕获、冒泡及委托原理

写在前面:本文是撰写的内容是小编个人对于JavaScript事件流机制的一点理解,阐述了包括事件捕获、冒泡以及事件委托等事件流的重点内容,不论是对于要为面试做准备还是想要深入的复习一下JavaScript知识的小伙伴们,希望这篇文章都能帮助到你!

文章内容如有纰漏,欢迎各位大佬指正!

前言

在JavaScript中,当一个事件发生时,它会经历三个阶段:

捕获阶段 -> 目标阶段 -> 冒泡阶段

想象你点击了一个嵌套很深的按钮,这个点击事件会先从最外层的窗口(window)像潜水一样"下沉"到目标按钮(捕获阶段 ),然后在目标按钮上触发(目标阶段 ),最后像气泡一样从按钮"上浮"回窗口(冒泡阶段)。

重点来了,你知道默认情况下,我们添加的事件监听器都是哪个阶段触发的吗?

答案是冒泡阶段。但是这只是JavaScript的默认行为,我们可以手动改变这个行为,按照你的意愿来选择在哪个阶段触发事件的回调函数,比如当你使用addEventListener来绑定事件回调时,你可以通过设置addEventListener的第三个参数来决定回调何时触发!

js 复制代码
<body>
    <div class="grafa">
        <div class="father">
            <div class="son"></div>
        </div>
    </div>
</body>
<script>
   document.querySelector('.son').addEventListener('click', callback, true)
   // 此时事件回调会在捕获阶段触发而不是在冒泡阶段触发!
</script>

一般情况下,当使用document.querySelector('son').addEventListener来为一个元素绑定事件监听时,我们一般不会设置第三个参数,这个时候第三个参数会默认为false,事件回调会默认在捕获阶段触发。当设置addEventListener的第三个参数为true时,就可以让监听器在捕获阶段就"抓住"这个事件,相当开启捕获,同时事件回调就不会在捕获阶段触发啦!

JavaScript的事件流过程

因小编疏忽未记录图片具体来源,若涉及侵权,请联系小编,立马删(;´༎ຶД༎ຶ`)🙏(^人^)

一、事件流的三个阶段

在了解具体的事件流过程之前,我们需要提前知道一些具体概念:

  • 在整个事件流过程,传递的是事件(click、error、keyup、change等等)而不是回调(callback
  • 事件目标(Event target)指事件最初触发的那个 DOM 元素(event.target),是事件流中 "目标阶段" 的核心
  • 注意区分event.target(实际触发元素)和event.currentTarget(当前处理事件的元素,通常是绑定事件的元素),在一次事件触发的过程总,前者是始终不变的,而后者指向回调绑定的元素!

下面是事件流三个阶段的具体过程:

1、捕获阶段 --> 从window对象向下传播到目标元素

  • 传播方向:从最外层的 window 对象开始,逐级向下传播到目标元素的父级
  • 标准:默认情况下,事件监听器不会在这个阶段触发(W3C标准规定)
  • 如何改变:设置 addEventListener 的第三个参数设置为 true 就可以启用捕获(该参数不设置时默认为false,代表启用的是冒泡)

2、目标阶段 --> 事件到达目标元素

  • 方向:事件到达实际触发事件的元素(即真正绑定了该事件的元素)
  • 标准:不论是设置捕获还是冒泡,目标阶段在目标元素的事件处理程序都会触发
  • 事件存在默认参数eventevent.target 指向实际触发事件的元素

3、冒泡阶段 --> 从目标元素向上传播回window对象

  • 从目标元素(event.target)开始,逐级向上传播到 window 对象
  • 标准:默认的事件监听器在这个阶段触发(W3C标准规定)
  • 大多数事件都会冒泡(除了少数非冒泡事件如 focus、blur 等)

为什么默认情况下事件监听器不会在捕获阶段触发?

  • 兼容性选择,早期浏览器(如 IE)只支持冒泡
  • 冒泡阶段更符合开发者的直觉,当点击一个按钮时,我们通常关心的是按钮本身的事件,然后才考虑它的父级(冒泡方向)
  • 如果默认启用捕获,浏览器需要在每个事件触发时额外检查捕获阶段的监听器,即使它们很少被使用,这会略微影响性能

二、捕获和冒泡的详解

光说不练假把式,理论讲了这么多,咱们肯定得来个小 demo 试试水!

代码跑起来的瞬间,才是知识点真正 "活" 过来的时候~

下面是小编自己写的一个小demo,希望通过这个小demo的展示可以加深你对于事件流的理解!由于默认标准下事件回调是在冒泡阶段触发的,所以小编没有按照顺序,先展示了大家更加熟悉的冒泡阶段hh

1、事件冒泡

当一个元素接收到事件的时候,会把它接收到的事件传给自己的父级,一直到 window (注意这里传递的仅仅是事件,例如click、focus等等这些事件, 并不传递所绑定的事件函数。)

方向:从事件源 =>最外层的根节点(由内到外)进行事件传播。举例说明👇

html 复制代码
<body>
    <div class="grafa">
        <div class="father">
            <div class="son"></div>
        </div>
    </div>
</body>
<script>
   document.querySelector('.grafa').addEventListener('click', (e) => {
        console.log('在grafa上点击了', e.target);
    });
    document.querySelector('.father').addEventListener('click', (e) => {
        console.log('在father上点击了', e.target); 
    })
   document.querySelector('.son').addEventListener('click', (e) => {
        console.log('在son上点击了', e.target); 
    })
</script>

点击最小的紫色盒子(son),click事件传播的方向为window -> grafa -> father -> son -> son -> father -> grafa -> window前面四个阶段是捕获阶段,后面四个阶段则是冒泡阶段。由于此刻这几个事件监听器都默认启用事件冒泡机制,因此捕获阶段不会触发任何盒子的回调函数,冒泡阶段则是按照son -> father -> grafa -> window的顺序触发回调!执行结果如下:

注意点 :这里为了加深影响,小编特地输出了event.terget,展示传递的目标元素始终是事件源元素,就算将son盒子的点击事件去掉,点击son盒子也会冒泡,且事件源还是son,如上图一样,不管在哪一个回调打印e.target,结果都是<div class="son"></div>

如上图,在father元素上触发click事件的原理也是一样的,尽管son元素上没有办好顶click事件,但是由于点击的是son盒子,所以event.terget依旧指向它!

如果不希望事件冒泡(也就是不希望点击son盒子时其他父元素也会触发相同的事件),在目标元素的事件回调中加入e.stopPropagation() 就可以取消冒泡了!

2、事件捕获

事件捕获: 当鼠标点击或者触发dom事件时(被触发dom事件的这个元素被叫作事件源),浏览器会从根节点 => 事件源(由外到内)进行事件传播。

事件捕获(由外到内)与事件冒泡(由内到外)是比较类似的,最大的不同在于事件传播的方向。而浏览器的事件流是从window对象开始,向下传播到目标元素,再向上传回window对象(捕获阶段 --> 目标阶段 ---> 冒泡阶段),所以如果同时监听了捕获事件和冒泡事件,那么会先触发捕获事件,再触发冒泡事件,如下例子👇

html 复制代码
<body>
    <div class="grafa">
        <div class="father">
            <div class="son"></div>
        </div>
    </div>
</body>
<script>
    const grafa = document.querySelector('.grafa');
    const father = document.querySelector('.father');
    const son = document.querySelector('.son');

    grafa.addEventListener('click', (e) => {
        console.log('grafa------捕获', e.target);
    },true);
    father.addEventListener('click', (e) => {
        console.log('father------捕获', e.target); 
    },true);
    son.addEventListener('click', (e) => {
        console.log('son------捕获', e.target); 
    },true)
    grafa.addEventListener('click', (e) => {
        console.log('grafa------冒泡', e.target);
    });
    father.addEventListener('click', (e) => {
        console.log('father------冒泡', e.target);
    });
    son.addEventListener('click', (e) => {
        console.log('son------冒泡', e.target);
    })
</script>

点击紫色son盒子时的运行效果如下,事件的传播方向为window -> grafa -> father -> son -> son -> father -> grafa -> window,事件回调会在中间的六个阶段被依次触发!所以你会注意到,事件触发的顺序和我的代码顺序是不一样的,因为这是由事件流的触发顺序决定的!

三、事件流的实际应用

看到这里,相信你已经对于JavaScript的事件流机制有一些见解了,下面是对于事件流机制的一些应用,包括利用它带来的便利,或规避它带来的限制!

1、事件委托

事件委托也称为事件代理。本质上是利用了浏览器事件冒泡的机制,把子元素的事件都冒泡绑定到父元素上。如果子元素阻止了事件冒泡,那么委托就无法实现,所以子元素必须要保证冒泡。

具体应用示例:像下面这种情况,你在一个father父盒子里面包裹了大量的son子盒子,如果你需要对每个son盒子都绑定一个点击事件回调监听,那么岂不是要手动遍历操作了?而利用事件委托机制,我们只需要在father父盒子上面绑定点击事件,就可以完成对于所有son元素的事件监听了!

举个栗子👇

html 复制代码
<body>
        <div class="father">
            <div class="son1 box">son1</div>
            <div class="son2 box">son2</div>
            <div class="son3 box">son3</div>
            <div class="son4 box">son4</div>
            <div class="son5 box">son5</div>
            <div class="son6 box">son6</div>
        </div>
</body>
<script>
    document.querySelector('.father').addEventListener('click', (e) => {
        console.log('触发father的点击事件', e.target);
    })
</script>

当我们点击son1盒子时,我们并没有在son盒子上绑定事件,但是由于事件冒泡的事件源始终是其所点击的目标元素,所以不管在事件流的哪一个阶段拿到事件对象event,结果都是事件源对应的元素,以上我们便可以拿到对应的e.targetson1

原理:事件委托不是在每个子节点上都单独设置事件监听器,而是只将事件监听器设置在所有节点的共同父节点上,然后利用事件冒泡始终只传递目标源的机制来影响设置每个子节点。

事件委托的好处:当父元素包裹大量元素时,使用事件委托机制来绑定事件监听器,避免手动给每一个元素都绑定监听器,这样能显著减少内存占用和提高性能!

2、控制事件的默认行为

很多事件在触发之后都会自动携带一些默认行为,这是浏览器的"本能反应",比如:

  • 点击<a>标签会自动跳转链接 (可能有时候我们希望有个确认框
  • 表单提交按钮(<button type="submit">)点击之后就会提交表单并刷新页面 (可能需要在提交之前做一些自定义校验、或者重组表单信息等操作
  • 点击鼠标右键会弹出浏览器默认菜单 (在一些特定功能下右键可能有别的用处
  • 按下键盘的方向键会让页面滚动 (这也并不一定是我们想要的

以上这些行为是浏览器内置的,而括号内的行为是我们可能需要自定义交互的,那么为了完成既定的需求,第一步就需要阻止默认行为

阻止默认行为的方式

使用事件对象的preventDefault()方法,比如:

js 复制代码
// 阻止链接跳转
document.querySelector('a').addEventListener('click', (e) => {
    e.preventDefault(); // 阻止默认跳转
    console.log('链接被点击,但不会跳转'); 
    // 处理完成后手动跳转
    // const url = e.target.href; // 获取链接的href属性
    // window.location.href = url; // 跳转到目标地址
}); 
// 阻止表单提交刷新 
document.querySelector('form').addEventListener('submit', (e) => {  
    e.preventDefault(); // 阻止默认提交
    console.log('表单内容将通过AJAX手动提交'); 
});

⚠️ 请注意:

  • preventDefault() 只阻止默认行为,不影响事件流(事件该冒泡还是会冒泡)
  • 有些事件本身就不能阻止默认行为(比如 scroll),这由浏览器规定。

3、终止事件流的方式

事件流默认会完整经历 "捕获→目标→冒泡" 三个阶段。但实际开发中,我们可能需要在某个节点 "截住" 它,避免事件继续传递 ------ 这就需要终止事件流

(1)e.stopPropagation() - 阻止事件向下传播

小编还是拿文章上面的例子,我们只在对于son元素的监听中增加了e.stopPropagation()这一行代码,来看看会有什么效果吧!

html 复制代码
<body>
    <div class="grafa">
        <div class="father">
            <div class="son"></div>
        </div>
    </div>
</body>
<script>
    document.querySelector('.son').addEventListener('click', (e) => {
        console.log('在son上点击了', e.target);
        e.stopPropagation(); // 阻止事件冒泡
    })
    document.querySelector('.father').addEventListener('click', (e) => {
        console.log('在father上点击了', e.target);
    })
    document.querySelector('.grafa').addEventListener('click', (e) => {
        console.log('在grafa上点击了', e.target);
    });
</script>

此时我们再点击son盒子(墨绿色)时会发现,只有绑定到该盒子上面的事件回调执行了,这是因为当son盒子上的事件回调触发之后,我们调用了事件对象e上面的stopPropagation()方法,这个方法会阻止事件的传播,让事件流在当前元素的当前阶段停下,不再传递给后续的元素或阶段!

(2)e.stopImmediatePropagation() - 更为彻底的终止

相比于stopPropagation方法,stopImmediatePropagation方法不仅会终止事件向下传播,而且还会让当前元素上所有的同类事件的后续处理函数都失效!

html 复制代码
<body>
    <div class="box"></div>
</body>
<script>
    document.querySelector('.box').addEventListener('click', (e) => {
        console.log('在box上点击了 - 1', e.target);
        e.stopImmediatePropagation() //更彻底的终止
    })
    document.querySelector('.box').addEventListener('click', (e) => {
        console.log('在box上点击了 - 2', e.target);
    })
    document.querySelector('.box').addEventListener('click', (e) => {
        console.log('在box上点击了 - 3', e.target);
    })
</script>

此时点击这个盒子,仅第一个回调执行,因为在该回调中调用了stopImmediatePropagation方法,导致当前盒子上所有的同类时间的后续处理函数都失效了!

总结:把JavaScript的事件流想象成一条磅礴向前的河流,从高山(window)顺流而下、经过一个个闸口(层层包裹的元素)、逆流而上回到高山上。stopPropagation方法就像在河道中间筑一道大坝,让事件流直接在当前闸口的当前阶段就停下,而stopImmediatePropagation方法更像直接断流,不仅影响后面的闸口,就连当前闸口的其它同类也会立刻罢工...
以上为本文的全部内容,小编技术水平有限,如果文章存在不妥指出,恳请指出,小编虚心求教,感谢各位小伙伴!

相关推荐
薛定谔的算法6 小时前
JavaScript栈的实现与应用:从基础到实战
前端·javascript·算法
魔云连洲7 小时前
React中的合成事件
前端·javascript·react.js
唐•苏凯8 小时前
ArcGIS Pro 遇到严重的应用程序错误而无法启动
开发语言·javascript·ecmascript
萌萌哒草头将军8 小时前
🚀🚀🚀 Oxc 恶意扩展警告;Rolldown 放弃 CJS 支持;Vite 发布两个漏洞补丁版本;Rslib v0.13 支持 ts-go
前端·javascript·vue.js
接着奏乐接着舞。8 小时前
3D地球可视化教程 - 第1篇:基础地球渲染系统
前端·javascript·vue.js·3d·three.js
薄雾晚晴9 小时前
Rspack 实战:用 image-minimizer-webpack-plugin 做图片压缩,优化打包体积
javascript·vue.js
kymjs张涛9 小时前
零一开源|前沿技术周刊 #15
前端·javascript·面试
古夕9 小时前
Vue3 + vue-query 的重复请求问题解决记录
前端·javascript·vue.js
不知名程序员第二部9 小时前
前端-业务-架构
前端·javascript·代码规范