前言
大家好,作者最近准备更新几篇文章来讲讲前端面试中的重难点之一--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作为单线程语言,其异步机制是实现非阻塞执行的核心特性。事件注册机制同样遵循这一异步原则,其执行流程具有以下特点:
- 事件驱动的异步模型:
- 事件监听器的注册操作本身是同步执行的
- 但监听器回调函数的执行是异步的,由特定事件触发
- 只有当目标元素发生指定事件(如click)时,才会将回调函数加入任务队列等待执行
- 与其它典型异步操作的对比:
- 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>
点击子元素以及父元素 查看输出结果
我们可以看到,点击子元素后,先输出了'子元素clicked',后输出了'父元素clicked',
接下来我们要分析的是:
- 从点击元素到输出的过程中浏览器做了什么?
- 点击子元素时 一定会按照这个输出顺序进行吗?
相信看完下面的事件流(Event Flow),你就能清楚这其中的奥秘了!
什么是事件流
当你在网页上点击一个按钮——这个简单的动作背后,浏览器其实在进行一场精心编排的"事件传递表演",这就是事件流。事件流描述了从事件发生到被处理的完整过程,包括三个阶段:捕获阶段、目标阶段和冒泡阶段。
捕获阶段
捕获阶段:事件从最外层的元素逐级向内部元素传播,直到到达事件的目标元素
就像侦察兵从山顶向下搜索:
- 事件从最外层的
document对象开始(有时候会从window对象开始) - 逐级向内传递,直到到达触发事件的目标元素
上述例子的分析:
对于我们上述例子,它的捕获阶段是这样的:
在点击红色部分子元素后 window → document → html → body → parent元素 → child元素
目标阶段
目标阶段:在这个阶段,事件已经到达了目标元素,即触发事件的DOM元素,这是大多数事件处理发生的地方
上述例子的分析:
对于我们上述例子,它的目标阶段是这样的: 当点击红色区域子元素时,即使捕获阶段会经过父元素和子元素,但是在目标阶段最终到达的一定是子元素!
此时我们输出
console.log(event.target),一定会是子元素的DOM元素输出!
冒泡阶段
冒泡阶段:事件从目标元素逐级向上地冒泡直至DOM树的根节点。
冒泡阶段是我们后面要讲的委托处理和事件传播的重要知识,现在你只需要了解它是从目标节点开始 逐级往上直至根节点即可
上述例子的分析:
对于我们上述例子,它的冒泡阶段是这样的:
此时已经经过了捕获阶段和目标阶段: child元素(目标元素) → parent元素 → body → html → document → window
输出顺序
知道了这三个阶段后,接下来我为你解答:在点击红色部分子元素时,浏览器到底为什么先输出先输出'子元素clicked',后输出'父元素clicked'。
这是因为事件默认会按照冒泡阶段的顺序执行:
在冒泡阶段
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>
此时我们再点击红色部分子元素,注意到控制台会先输出父元素再输出子元素了
实际上更改第三个参数为true,就是在告诉浏览器:"请在捕获阶段调用这个事件监听器"。所以自然就会按照捕获阶段的顺序进行事件的执行了!
window → document → html → body → parent → child
浏览器做了什么?
所以我们来总结一下,当我们点击网页上的元素时,浏览器到底做了什么?
- 命中目标
👆 浏览器找到你点击的具体元素(比如按钮) - 向下捕获
⬇️ 从window开始向下检查,执行所有设置了addEventListener(..., true)的监听器 - 命中执行
💥 在目标元素上按顺序执行所有监听器(不管捕获/冒泡设置) - 向上冒泡
⬆️ 从目标元素向上检查,执行所有默认的(addEventListener(..., false))监听器 - 最终动作
🚀 如果没有被阻止,执行默认行为(如提交表单/跳转链接)
这里的中间三个步骤我们在上面详细进行讲解了:
对于第一个步骤和最后一个步骤不是我们今天讲解的重点,就简要聊聊:
- 命中目标:你在网页上点击一个按钮时,实际上你只是在该由像素点构成的图层中点击了一下鼠标左键按钮,浏览器并不知道你的目标元素到底是什么,它需要经过一系列分析:结合布局树、分层等计算,将物理像素坐标转换成对应的的具体的DOM元素,从而才能进行下面的捕获、执行、冒泡等操作。
5.最终动作:当事件完成捕获、目标和冒泡阶段后,如果事件未被显式阻止(event.preventDefault()),浏览器会执行该元素的原生默认行为。比如:表单提交、链接跳转等,在早期,这一机制保证了基础交互功能无需手动实现,但如今,开发者可通过阻止默认行为覆盖原生逻辑
总结
好了,通过阅读完这篇文章,现在我相信你对JS事件机制有了基本了解,对于事件流有了详细的认识。后面我还会更新文章,讲解事件机制的更多内容,包含更多进阶的知识!有了这篇文章的铺垫,在后面我们进一步理解JS的事件机制会更加轻松,作者会持续更新这些大厂面试干货,不断输出优质内容!感兴趣的朋友可以点个关注,多谢!