在JavaScript的世界里,事件监听是我们与用户交互的基础。但你是否遇到过这样的困惑:为什么点击了子元素,父元素的点击事件也跟着触发了?或者,当列表里有1000个按钮时,如何优雅地处理点击而不让页面卡死?
今天,我们就从你提供的两段代码出发,深入剖析事件流、事件委托、stopPropagation,最后带你看看React是如何利用这些原理“秀操作”的。
一、事件的“旅行”:事件流与冒泡
首先,我们要建立一个核心概念:事件不仅仅是“发生”在某个元素上,它是一场“旅行”。
当一个点击事件发生时,浏览器内部会经历三个阶段,这就是事件流:
- 捕获阶段:事件从
document根节点出发,像水流一样层层向下渗透,直到目标元素。 - 目标阶段:事件到达了实际被点击的元素(
event.target)。 - 冒泡阶段:事件从目标元素出发,反向冒泡,一层层向上传播回
document。
看个例子(基于你的2.html):
想象一个红色的盒子(parent)里装着一个蓝色的盒子(child)。
document.getElementById('parent').addEventListener('click', function() {
console.log('parent click');
}, false) // 默认false,代表在冒泡阶段执行
document.getElementById('child').addEventListener('click', function() {
console.log('child click');
}, false)
当你点击蓝色的child时,控制台会依次输出:child click -> parent click。
这就是事件冒泡。事件首先在child上触发,然后“冒泡”到父级parent,甚至继续冒泡到body(你的代码里body上还有个alert('橘子'),所以最后还会弹窗)。
为什么要了解这个? 因为绝大多数时候,我们利用的就是这个“冒泡”机制。
二、性能救星:事件委托
回到你的1.html,假设你有一个包含100个<li>的列表。
❌ 传统做法(笨重):
const lis = document.querySelectorAll('#list li');
for (let i = 0; i < lis.length; i++) {
lis[i].addEventListener('click', function() { ... })
}
这种做法的问题在于内存开销。100个监听器就是100份内存消耗。如果列表是动态生成的,你还得不断地去绑定新元素的事件,非常麻烦。
✅ 事件委托(优雅):
利用冒泡原理,我们只需要在父元素<ul>上绑定一个监听器,就能管理所有子元素!
document.getElementById('list').addEventListener('click', function(event) {
// event.target 指向实际被点击的那个 li
console.log(event.target, event.target.innerHTML);
});
这就像什么?
就像小区的门卫。你不需要给每家每户(li)都配一个保安,只需要在小区大门口(ul)安排一个保安。谁进来了(事件冒泡上来了),保安看一眼event.target(身份证),就知道是谁。
这样做的好处:
- 减少内存消耗:不管有多少个
li,只需要一个监听器。 - 自动支持动态元素:如果你后来用JS往列表里加了一个新的
<li>,它不需要重新绑定事件,点击它依然会冒泡到ul被处理。
三、掌控雷电:stopPropagation
有时候,我们不希望事件冒泡。比如在做一个模态框,点击遮罩层关闭,但点击内容区不想关闭。
这时就需要用到e.stopPropagation()。
document.getElementById('child').addEventListener('click', function(event) {
event.stopPropagation(); // 关键代码:在这里“截断”事件
console.log('child click');
}, false)
加上这行代码后,点击child,事件处理完就结束了,不会继续向上传递给parent,也就不会触发parent的点击事件,更不会出现body上的alert('橘子')。
注意: 还有一种情况是useCapture(捕获)。addEventListener的第三个参数默认为false(冒泡)。如果设为true,事件就会在捕获阶段(从上往下)被触发。这在某些特殊场景(如想要最早拦截事件)非常有用。
四、最佳实践:就近原则
在使用事件委托时,有一个“就近原则”。
虽然我们可以把事件委托给document(在根节点监听所有点击),但不建议这么做。
为什么?
如果委托给document,每次点击页面任何地方,事件都要冒泡到最顶层,浏览器需要遍历的路径最长,增加了判断成本。
建议:
委托给距离目标元素最近的父级。比如在ul上代理li,而不是在document上代理li。这样既享受了委托的性能红利,又控制了事件传播的范围。
五、进阶引申:React的合成事件
如果你学过React,你会发现React的事件系统正是基于这些原理构建的。
React并没有给每个DOM节点绑定原生的addEventListener。相反,React实现了一套**合成事件(SyntheticEvent)**系统。
它的核心原理就是:
- 全局委托:React 17及以后,将所有事件统一委托到了挂载容器的根节点(React 16及以前是
document)。 - 统一分发:当原生事件冒泡到根节点时,React会捕获它,然后根据组件树的结构,手动分发给对应的组件事件处理函数。
这样做的好处:
- 性能极致:无论你的应用有多少个按钮,原生监听器只有一个。
- 跨浏览器兼容:React抹平了不同浏览器(如Chrome和Firefox)对事件对象实现的差异,让你在任何浏览器拿到的
e对象都是一样的。
总结
- 事件流:捕获 -> 目标 -> 冒泡。理解它是理解一切的基础。
- 事件委托:利用冒泡,将监听器绑定在父元素上,通过
event.target识别目标。省内存、支持动态DOM。 - stopPropagation:阻止事件继续冒泡,防止父级元素“误触”。
- React启示:现代框架的高性能,往往就建立在这些基础原理的巧妙运用之上。
下次再写列表循环绑定时,记得停下来想一想:能不能用事件委托优化一下?