还在一个个绑事件监听?你真的搞懂 JS 事件机制和事件委托了吗?

103 阅读7分钟

写前端的时候,我们天天在写“点击事件”。
可是你是不是也有过这些疑惑:

  • 明明只点了里面那个小盒子,外面的也跟着触发了,这是怎么回事?
  • addEventListener 第三个参数到底干嘛用的?为啥有时候 true、有时候 false
  • 事件委托说是利用冒泡,那捕获阶段和目标阶段又有什么用?

如果连“捕获阶段 / 目标阶段 / 冒泡阶段”都没弄清楚,怎么敢说自己真正理解了 JS 事件机制呢?

这篇文章从最基础的示例出发,一步一步把整条事件流讲清楚,再顺着讲到事件委托。

一、事件不是“点一下就执行”,而是一条在 DOM 树里流动的“水”

浏览器里,页面结构是一棵 DOM 树:外面是大的容器,里面嵌套小的元素。
当你点击某个元素时,事件并不是“只在这个点上”发生,而是像水一样沿着 DOM 树流动

这条“水流”一共分为三个阶段:

  1. 捕获阶段(capturing phase)
  2. 目标阶段(target phase)
  3. 冒泡阶段(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. 捕获阶段:从外往里“找目标”

当你点中小盒子时,浏览器内部会先从最外层往里“捕获”:

  1. document
  2. html
  3. body
  4. parent
  5. child(目标)

在这个过程中,如果某个节点在捕获阶段注册了监听(第三个参数是 true),就会在这一路上依次被触发。

也就是说,捕获阶段是从外往内的一路“下探”

2. 目标阶段:真正命中的那个元素

当捕获走到最里面的那个元素(你真正点到的那一个)时,就进入了 目标阶段

  • 此时无论是捕获监听还是冒泡监听,只要是绑在这个目标元素上的监听,都会被执行。
  • 所以目标阶段是整个事件生命周期的“中点”。

3. 冒泡阶段:从里往外“冒回去”

目标阶段之后,事件并不会就此结束,而是从目标元素开始,沿着 DOM 树一路往外冒泡

  1. child
  2. parent
  3. body
  4. html
  5. document

在这段过程中,如果某个节点在冒泡阶段注册了监听(第三个参数是 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 时:

  1. 先走捕获阶段:执行 capture listener
  2. 到达目标阶段,再走冒泡阶段:执行 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

    1. 事件在 li 上触发
    2. 然后冒泡到 ul
    3. 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);
  }
});

七、最后总结:三阶段搞清楚,事件代码才算真正“上道”

现在回过头再看一眼那三个阶段:

  1. 捕获阶段
    从外到内,一路“下探”到目标元素
    addEventListener(..., true) 时在这一阶段触发
  2. 目标阶段
    真正被点中的那个元素
    目标上的监听(无论捕获或冒泡)都会在这里被执行
  3. 冒泡阶段
    从内到外,一路“冒回”到最外层
    addEventListener(..., false) 或不传第三个参数时,在这一阶段触发
    事件委托正是利用了这一阶段的特性

配合事件对象:

  • 用 event.target 精确知道“是谁被点了”
  • 用 event.stopPropagation() 决定事件是否还要继续传播

搞清楚这整条事件流之后,你再去写事件委托、再去排查“为什么父子都触发了”,是不是就不会再一头雾水了?