面试官:说说事件冒泡与委托?这是我见过最透彻的回答

10 阅读5分钟

在JavaScript的世界里,事件监听是我们与用户交互的基础。但你是否遇到过这样的困惑:为什么点击了子元素,父元素的点击事件也跟着触发了?或者,当列表里有1000个按钮时,如何优雅地处理点击而不让页面卡死?

今天,我们就从你提供的两段代码出发,深入剖析事件流、事件委托、stopPropagation,最后带你看看React是如何利用这些原理“秀操作”的。


一、事件的“旅行”:事件流与冒泡

首先,我们要建立一个核心概念:事件不仅仅是“发生”在某个元素上,它是一场“旅行”。

当一个点击事件发生时,浏览器内部会经历三个阶段,这就是事件流

  1. 捕获阶段:事件从document根节点出发,像水流一样层层向下渗透,直到目标元素。
  2. 目标阶段:事件到达了实际被点击的元素(event.target)。
  3. 冒泡阶段:事件从目标元素出发,反向冒泡,一层层向上传播回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(身份证),就知道是谁。

这样做的好处:

  1. 减少内存消耗:不管有多少个li,只需要一个监听器。
  2. 自动支持动态元素:如果你后来用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)**系统。

它的核心原理就是:

  1. 全局委托:React 17及以后,将所有事件统一委托到了挂载容器的根节点(React 16及以前是document)。
  2. 统一分发:当原生事件冒泡到根节点时,React会捕获它,然后根据组件树的结构,手动分发给对应的组件事件处理函数。

这样做的好处:

  • 性能极致:无论你的应用有多少个按钮,原生监听器只有一个。
  • 跨浏览器兼容:React抹平了不同浏览器(如Chrome和Firefox)对事件对象实现的差异,让你在任何浏览器拿到的e对象都是一样的。

总结

  • 事件流:捕获 -> 目标 -> 冒泡。理解它是理解一切的基础。
  • 事件委托:利用冒泡,将监听器绑定在父元素上,通过event.target识别目标。省内存、支持动态DOM。
  • stopPropagation:阻止事件继续冒泡,防止父级元素“误触”。
  • React启示:现代框架的高性能,往往就建立在这些基础原理的巧妙运用之上。

下次再写列表循环绑定时,记得停下来想一想:能不能用事件委托优化一下?