写前端的时候,我们天天在写“点击事件”。
可是你是不是也有过这些疑惑:
- 明明只点了里面那个小盒子,外面的也跟着触发了,这是怎么回事?
addEventListener第三个参数到底干嘛用的?为啥有时候true、有时候false?- 事件委托说是利用冒泡,那捕获阶段和目标阶段又有什么用?
如果连“捕获阶段 / 目标阶段 / 冒泡阶段”都没弄清楚,怎么敢说自己真正理解了 JS 事件机制呢?
这篇文章从最基础的示例出发,一步一步把整条事件流讲清楚,再顺着讲到事件委托。
一、事件不是“点一下就执行”,而是一条在 DOM 树里流动的“水”
浏览器里,页面结构是一棵 DOM 树:外面是大的容器,里面嵌套小的元素。
当你点击某个元素时,事件并不是“只在这个点上”发生,而是像水一样沿着 DOM 树流动。
这条“水流”一共分为三个阶段:
- 捕获阶段(capturing phase)
- 目标阶段(target phase)
- 冒泡阶段(bubbling phase)
不用急着记名词,我们先用一个简单的父子盒子例子,把这三个阶段走一遍。
二、父子盒子示例:一点击,事件其实走了三段路
假设页面上有这样一段结构:
<div id="parent">
<div id="child"></div>
</div>
你用 JavaScript 绑定事件:
const parent = document.getElementById('parent');
const child = document.getElementById('child');
parent.addEventListener('click', function () {
console.log('parent click');
}, false);
child.addEventListener('click', function () {
console.log('child click');
}, false);
点击里面那个小盒子,会发生什么?
1. 捕获阶段:从外往里“找目标”
当你点中小盒子时,浏览器内部会先从最外层往里“捕获”:
documenthtmlbodyparentchild(目标)
在这个过程中,如果某个节点在捕获阶段注册了监听(第三个参数是 true),就会在这一路上依次被触发。
也就是说,捕获阶段是从外往内的一路“下探” 。
2. 目标阶段:真正命中的那个元素
当捕获走到最里面的那个元素(你真正点到的那一个)时,就进入了 目标阶段。
- 此时无论是捕获监听还是冒泡监听,只要是绑在这个目标元素上的监听,都会被执行。
- 所以目标阶段是整个事件生命周期的“中点”。
3. 冒泡阶段:从里往外“冒回去”
目标阶段之后,事件并不会就此结束,而是从目标元素开始,沿着 DOM 树一路往外冒泡:
childparentbodyhtmldocument
在这段过程中,如果某个节点在冒泡阶段注册了监听(第三个参数是 false 或默认不传),就会按这个顺序被触发。
也就是说,冒泡阶段是从内往外的一路“上浮” 。
三、addEventListener 第三个参数,到底决定了什么?
关键方法的完整签名是:
element.addEventListener(type, callback, useCapture);
-
type:事件类型,比如'click' -
callback:事件触发时执行的函数 -
useCapture:false(默认):在冒泡阶段触发true:在捕获阶段触发
示例:同一个元素上,捕获监听和冒泡监听的顺序
const box = document.getElementById('box');
box.addEventListener('click', function () {
console.log('bubble listener');
}, false); // 冒泡阶段
box.addEventListener('click', function () {
console.log('capture listener');
}, true); // 捕获阶段
当你点击 box 时:
- 先走捕获阶段:执行
capture listener - 到达目标阶段,再走冒泡阶段:执行
bubble listener
如果你在父子元素上分别使用不同的 useCapture,整个输出顺序就会更复杂。
你如果连“它们到底在哪个阶段触发”都搞不清楚,遇到复杂嵌套时,谁先执行、谁后执行怎么排查?
四、事件对象:event.target 和 event.stopPropagation() 有多关键?
当你写监听函数时,经常会看到这样一个参数:
child.addEventListener('click', function (event) {
console.log(event);
}, false);
这个 event 就是事件对象,浏览器在事件触发时自动传入。
里面有两个特别关键的属性/方法:
1. event.target:真正被点击的那个元素
在父子嵌套结构中,如果你点击的是里面的小盒子:
- 无论监听绑定在小盒子还是外面的父盒子上
- 在监听函数里,
event.target都是“真正被点中的那个元素”
这在后面讲事件委托时非常重要。
2. event.stopPropagation():阻止事件继续传播
还记得之前父子盒子的例子吗?
如果你不想让父元素的监听被触发,就可以在子元素监听里阻止冒泡:
child.addEventListener('click', function (event) {
event.stopPropagation();
console.log('child click');
}, false);
- 这句
event.stopPropagation()会阻止事件继续往上冒泡 - 所以最终只会输出
child click,父元素的监听不会执行
如果你从来没用过 stopPropagation,是不是很多时候都是“被动接受”所有父辈都一并触发的结果?
五、为什么不能直接在“集合”上绑定事件?
很多时候你会这样选中一组元素:
const items = document.querySelectorAll('.item');
console.log(items); // 一个 NodeList 集合
如果你直接写:
// 这样是行不通的:集合本身不是 DOM 节点
items.addEventListener('click', function () {
console.log('click');
});
这是不生效的,因为:
items是一个 NodeList 集合,不是单个 DOM 元素- 事件只能绑在“单个节点”上,而不是绑在“节点集合”上
你可能会写循环,一个个去绑:
for (let i = 0; i < items.length; i++) {
items[i].addEventListener('click', function () {
console.log(this.innerHTML);
});
}
逻辑上没问题,但问题也很明显:
- 元素一多,就要绑很多监听,内存开销很大
- 后面如果动态添加新元素,还得再给新元素额外绑定
难道你愿意在一个上百行的列表里,为每一行都单独绑一个监听吗?
🎯六、事件委托:只绑一次监听,让子元素全部“搭车”
解决这个问题的经典办法,就是事件委托(事件代理) 。
核心思想其实就一句话:
既然事件会冒泡,为什么不只在“公共祖先元素”上绑一次监听,让所有子元素“冒泡上来再统一处理”呢?
列表示例:一次监听,全部生效
假设有这样一个列表:
<ul id="list">
<li>1</li>
<li>2</li>
<li>3</li>
</ul>
不用给每个 li 绑监听,而是只在 ul 上绑一次:
const list = document.getElementById('list');
list.addEventListener('click', function (event) {
console.log('------');
console.log(event.target, event.target.innerHTML);
});
逐行看它在干什么:
-
list.addEventListener('click', ...)
只在ul上注册一次监听 -
当你点击某个
li:- 事件在
li上触发 - 然后冒泡到
ul ul的监听被触发,回调里的event.target正是你点击的那个li
- 事件在
-
event.target.innerHTML
对于这个场景,就是你点击的li里显示的数字,比如'2'
这样一来:
- 列表里有多少个
li,都只用这一份监听 - 后面动态新增的
li,也天然会“搭上这班车” - 你不需要再额外给它们绑任何监听
利用的不就是你一开始没太在意的冒泡阶段吗?
如果要更严谨一点,还可以加判断,确保只处理 li:
list.addEventListener('click', function (event) {
if (event.target.tagName === 'LI') {
console.log(event.target.innerHTML);
}
});
七、最后总结:三阶段搞清楚,事件代码才算真正“上道”
现在回过头再看一眼那三个阶段:
- 捕获阶段
从外到内,一路“下探”到目标元素
addEventListener(..., true)时在这一阶段触发 - 目标阶段
真正被点中的那个元素
目标上的监听(无论捕获或冒泡)都会在这里被执行 - 冒泡阶段
从内到外,一路“冒回”到最外层
addEventListener(..., false)或不传第三个参数时,在这一阶段触发
事件委托正是利用了这一阶段的特性
配合事件对象:
- 用
event.target精确知道“是谁被点了” - 用
event.stopPropagation()决定事件是否还要继续传播
搞清楚这整条事件流之后,你再去写事件委托、再去排查“为什么父子都触发了”,是不是就不会再一头雾水了?