JS事件机制详解(1)--- 事件流讲解

173 阅读9分钟

前言

大家好,作者最近准备更新几篇文章来讲讲前端面试中的重难点之一--JS事件机制,这篇文章为事件机制系列的第一篇,先铺垫一些事件机制的基础知识,主要讲讲捕获、目标、冒泡这些阶段,后面会更新文章聊聊更进阶的知识。


DOM0 与 DOM2 事件

JS的原生事件机制分为DOM0与DOM2两种

DOM0事件

DOM0事件是更古老的一种事件机制,曾经的早期JS会用这种方式,但是现代的JS已经几乎抛弃了这种方式

让我来为你介绍:

DOM0事件机制是将事件绑定到元素的属性中,这个事件绑定的机制也叫内联事件绑定

<body>
 <audio id="myAudio" src="./sounds/snare.wav"></audio>
 <!-- 内联事件绑定是DOM0的典型特征 -->
 <button id="play" onclick="playAudio()">播放</button>

 <script>
    function playAudio() {
        const audio = document.getElementById('myAudio');
        audio.play(); 
    }
 </script>
</body>

你会发现DOM0事件机制是没有我们现代的事件监听器addEventListener的,它的方式是内联事件绑定(如html标签上直接写onClick等) 这种方式会带来一些问题:

  • 高度耦合:由于事件直接放在html标签上,导致html和js代码耦合在一起,这会带来不清晰的代码结构,html中有js,js中有html。
  • 扩展性差:html和js耦合一起时,会导致难以动态绑定/解绑事件

所以这种方式就被淘汰了,进化到了DOM2事件机制

DOM2事件机制

这种方式是现代js采用的事件机制。

让我来为你介绍:

DOM2事件机制是在script部分为元素添加事件监听器addEventListener,当监听到事件发生时(例如点击事件,移动事件,缩放事件等),会触发监听器中写好的函数

<body>
 <audio src="./sounds/snare.wav"></audio>
 <button id="play">播放</button>

 <script>
    const aAudio = document.querySelector('audio');
    //为DOM元素添加事件监听器
    document.querySelector('#play').addEventListener('click', () => {
        aAudio.play();
    })
 </script>
</body>

它相对于DOM0事件机制的好处是

  • html部分和script部分分离,逻辑清晰
  • 通过addEventListener机制可以更专业地控制事件(这就是我们后面要介绍的)

为什么没有DOM1?

有DOM0和DOM2的事件触发机制,那么为什么没有DOM1的事件触发机制呢?

其实是因为从DOM0更新到DOM1时对于事件机制的特性并没有迭代更新,直接采用了DOM0的事件触发机制,所以人们通常只会提DOM0与DOM1事件机制。

我们本文要介绍的内容主要是针对于DOM2编程的!!!


注册事件

好的,让我们正式开始讲事件机制的流程吧,首先是注册事件,我们需要理清楚这个概念:

何为注册事件

注册事件就是将事件处理函数绑定到指定的目标元素,为在DOM2中,就是为目标元素添加一个addEventListener监听器。

比如:为目标元素button注册事件

<!-- 目标元素 -->
<button id="myButton">点击我</button>
//注册事件
button.addEventListener("click", function(event) {
  alert("按钮被点击了!");
  console.log("事件对象:", event); 
});

事件注册的异步特性分析

JavaScript作为单线程语言,其异步机制是实现非阻塞执行的核心特性。事件注册机制同样遵循这一异步原则,其执行流程具有以下特点:

  1. 事件驱动的异步模型:
  • 事件监听器的注册操作本身是同步执行的
  • 但监听器回调函数的执行是异步的,由特定事件触发
  • 只有当目标元素发生指定事件(如click)时,才会将回调函数加入任务队列等待执行
  1. 与其它典型异步操作的对比:
  • setTimeout():在同步代码执行后,经过指定延迟将回调加入任务队列
  • 事件监听:在同步代码执行后,等待特定事件发生才将回调加入任务队列

需要特别注意的是,虽然回调执行是异步的,但事件监听器的注册操作本身是同步的


事件流(Event Flow)

下面请看一个例子:写一个父元素(蓝色),里面放着一个子元素(红色)
为父元素和子元素都注册事件click:

<!-- 省略了css -->
<body>
    <div id="parent">
        <div id="child">
        </div>
    </div>
    <script>
    document.getElementById('parent').addEventListener('click',function(event) {
        console.log('父元素clicked')    
    })
    document.getElementById('child').addEventListener('click',function(event) {
        console.log('子元素clicked')
    })
    </script>
</body>

点击子元素以及父元素 查看输出结果

ddd.gif

我们可以看到,点击子元素后,先输出了'子元素clicked',后输出了'父元素clicked',

接下来我们要分析的是:

  • 从点击元素到输出的过程中浏览器做了什么?
  • 点击子元素时 一定会按照这个输出顺序进行吗?

相信看完下面的事件流(Event Flow),你就能清楚这其中的奥秘了!


什么是事件流

当你在网页上点击一个按钮——这个简单的动作背后,浏览器其实在进行一场精心编排的"事件传递表演",这就是事件流。事件流描述了从事件发生到被处理的完整过程,包括三个阶段:捕获阶段、目标阶段和冒泡阶段。

捕获阶段

捕获阶段事件从最外层的元素逐级向内部元素传播,直到到达事件的目标元素

image.png

就像侦察兵从山顶向下搜索:

  • 事件从最外层的 document 对象开始(有时候会从window对象开始)
  • 逐级向内传递,直到到达触发事件的目标元素

上述例子的分析:

image.png

对于我们上述例子,它的捕获阶段是这样的:

在点击红色部分子元素后
window → document → html → body → parent元素 → child元素

目标阶段

目标阶段:在这个阶段,事件已经到达了目标元素,即触发事件的DOM元素,这是大多数事件处理发生的地方

image.png


上述例子的分析:

image.png

对于我们上述例子,它的目标阶段是这样的: 当点击红色区域子元素时,即使捕获阶段会经过父元素和子元素,但是在目标阶段最终到达的一定是子元素!

此时我们输出console.log(event.target),一定会是子元素的DOM元素输出!


冒泡阶段

冒泡阶段:事件从目标元素逐级向上地冒泡直至DOM树的根节点。

image.png

冒泡阶段是我们后面要讲的委托处理和事件传播的重要知识,现在你只需要了解它是从目标节点开始 逐级往上直至根节点即可


上述例子的分析:

image.png

对于我们上述例子,它的冒泡阶段是这样的:

此时已经经过了捕获阶段和目标阶段:
child元素(目标元素) → parent元素 → body → html → document → window

输出顺序

知道了这三个阶段后,接下来我为你解答:在点击红色部分子元素时,浏览器到底为什么先输出先输出'子元素clicked',后输出'父元素clicked'。 ddd.gif 这是因为事件默认会按照冒泡阶段的顺序执行:

在冒泡阶段

child元素(目标元素) → parent元素 → body → html → document → window

所以自然会先执行子元素绑定的事件,再执行父元素绑定的事件


但是我们也可以更改输出顺序!!!

当我们将addEventListener的第三个参数设置为true时,会按照捕获阶段的顺序进行输出

<body>
    <!-- 省略了css -->
    <div id="parent">
        <div id="child">
        </div>
    </div>
    <script>
    // 更改addEventListener的第三个参数为true!!!
    document.getElementById('parent').addEventListener('click',function(event) {
        console.log('父元素clicked')    
    },true)
    document.getElementById('child').addEventListener('click',function(event) {
        console.log('子元素clicked')
    },true)
    </script>
</body>

此时我们再点击红色部分子元素,注意到控制台会先输出父元素再输出子元素了 fff.gif

实际上更改第三个参数为true,就是在告诉浏览器:"请在捕获阶段调用这个事件监听器"。所以自然就会按照捕获阶段的顺序进行事件的执行了!

window → document → html → body → parent → child

浏览器做了什么?

所以我们来总结一下,当我们点击网页上的元素时,浏览器到底做了什么?

  1. 命中目标
    👆 浏览器找到你点击的具体元素(比如按钮)
  2. 向下捕获
    ⬇️ 从 window 开始向下检查,执行所有设置了 addEventListener(..., true) 的监听器
  3. 命中执行
    💥 在目标元素上按顺序执行所有监听器(不管捕获/冒泡设置)
  4. 向上冒泡
    ⬆️ 从目标元素向上检查,执行所有默认的(addEventListener(..., false))监听器
  5. 最终动作
    🚀 如果没有被阻止,执行默认行为(如提交表单/跳转链接)

这里的中间三个步骤我们在上面详细进行讲解了:

对于第一个步骤和最后一个步骤不是我们今天讲解的重点,就简要聊聊:

  1. 命中目标:你在网页上点击一个按钮时,实际上你只是在该由像素点构成的图层中点击了一下鼠标左键按钮,浏览器并不知道你的目标元素到底是什么,它需要经过一系列分析:结合布局树、分层等计算,将物理像素坐标转换成对应的的具体的DOM元素,从而才能进行下面的捕获、执行、冒泡等操作。

5.最终动作:当事件完成捕获、目标和冒泡阶段后,如果事件未被显式阻止(event.preventDefault()),浏览器会执行该元素的原生默认行为。比如:表单提交、链接跳转等,在早期,这一机制保证了基础交互功能无需手动实现,但如今,开发者可通过阻止默认行为覆盖原生逻辑


总结

好了,通过阅读完这篇文章,现在我相信你对JS事件机制有了基本了解,对于事件流有了详细的认识。后面我还会更新文章,讲解事件机制的更多内容,包含更多进阶的知识!有了这篇文章的铺垫,在后面我们进一步理解JS的事件机制会更加轻松,作者会持续更新这些大厂面试干货,不断输出优质内容!感兴趣的朋友可以点个关注,多谢!

20180123162016_FX8Zz.gif