那些不冒泡的DOM事件,该如何优雅地监听它们?

37 阅读5分钟

先看一段简单的 React 代码:

useEffect(() => {
  document.body.addEventListener('focus', (e) => {
    console.log('body focus');
  }, { capture: false });

  document.body.addEventListener('click', (e) => {
    console.log('body click');
  }, { capture: false });
}, []);

// JSX
<input
  onFocus={(e) => console.log('input focus')}
  onClick={(e) => console.log('input click')}
/>

点击输入框让它获得焦点,控制台输出如下:

input focus
input click
body click

body click 出现了,body focus 却毫无踪影。很多人第一反应是:focus 事件难道不会冒泡吗?

答案是——没错,focus 事件天生就不会冒泡

Slide 4_3 - 17.png

为什么会这样?冒泡机制与 focus 的例外

DOM 事件的传播分为三个阶段:捕获阶段、目标阶段、冒泡阶段。我们最常用的事件监听(addEventListener 第三个参数为 false 或不传)就是在冒泡阶段接收事件。但这一切的前提是——该事件本身支持冒泡

focusblur 等少数事件在规范中就被定义为不冒泡。你给 body 绑定的 focus 监听永远等不到从 <input> 冒上来的事件,所以 body focus 永远不会打印。而 click 是冒泡的,从 input 一路往上冒到了 body,于是就看到了 body click

这就带来一个常见问题:我们没法用事件委托来统一监听所有子元素的聚焦事件。

那些“不冒泡”的DOM事件

除了 focus,还有许多 DOM 事件也拒绝冒泡。可分以下几类:

1. 焦点事件

不冒泡冒泡版本说明
focusfocusin元素获得焦点
blurfocusout元素失去焦点

React 用户注意:合成事件 onFocus / onBlur 同样映射到不冒泡的原生事件,因此也不会冒泡。想在父组件上捕获子组件的聚焦状态,必须另辟蹊径(后文会讲)。

2. 鼠标移入移出事件

不冒泡冒泡版本说明
mouseentermouseover鼠标进入元素,不冒泡且子元素不会反复触发
mouseleavemouseout鼠标离开元素,不冒泡且子元素不会反复触发

mouseenter / mouseleave 的不冒泡是特意设计的,目的是避免在多层嵌套时产生难以控制的重复触发。如果你需要在父容器上感知鼠标进出,可以使用 mouseover / mouseout,然后通过 event.relatedTarget 判断鼠标是否真的离开了容器。

3. 加载、错误与中断事件

  • load:资源(图片、脚本、iframe 等)加载完成。不冒泡
  • error:资源加载失败。不冒泡(注意:代码运行时错误会被 window.onerror 全局捕获,但那不是 DOM 冒泡)。
  • abort:资源加载中断。不冒泡

这意味着你无法通过给 document.body 添加 load 监听来捕捉所有图片的加载完成,必须直接监听每个 <img> 元素。

4. 滚动事件

  • scroll:元素内容滚动时触发。不冒泡(但 document 上的滚动会作为传统行为传递到 window)。
  • wheel / mousewheel:鼠标滚轮事件。会冒泡

这就是为什么事件委托对滚动监听无效:子元素滚动了,父元素什么也收不到。想统一监听滚轮行为可以用 wheel 事件;想监听滚动位置变化,只能分别给每个需要滚动的元素绑定 scroll

5. 媒体事件(几乎全军覆没)

以下媒体元素(<audio><video>)事件都是不冒泡的:

playpauseplayingwaitingseekingseekedendedvolumechangedurationchangeratechangecanplaycanplaythroughloadedmetadata 等。

如果你在开发音乐播放器或视频播放器,想要用一个父容器统一监听所有播放状态,那是不可能的。只能每个媒体元素单独绑定,或者使用捕获阶段。

6. 一些“现代”事件

  • toggle<details> 元素展开/收起时触发。不冒泡
  • scrollend:滚动停止时触发(较新)。不冒泡
  • beforematch:隐藏的文本即将显示时触发。不冒泡

7. 窗口/文档专属事件

这些事件只在 windowdocument 上触发,压根不存在冒泡的概念:

resizeDOMContentLoadedreadystatechangepageshowpagehidevisibilitychange 等。

三种实用监听方案

面对这些“顽固”事件,我们怎样才能在父元素上统一监听?有三种常用方案。

1. 使用冒泡版本的事件

如果标准提供了冒泡的替代事件,直接使用它是成本最低、最符合直觉的方式。

// 原始写法:focus 不冒泡,监听不到
document.body.addEventListener('focus', handler, false);

// 改用 focusin,完美冒泡
document.body.addEventListener('focusin', handler, false);

// 同理,mouseenter 替换为 mouseover
container.addEventListener('mouseover', handler, false);

在 React 中,你可以直接使用原生事件配合 useEffect 绑定,或者使用一些第三方库。因为 React 合成事件系统没有提供 onFocusin,但有 onFocusCapture

2. 捕获阶段拦截

所有事件(不管冒不冒泡)都会经过捕获阶段(从 window 向下传播到目标元素)。只需在绑定事件时开启捕获模式,就能在父元素上提前“截胡”。

// 将第三个参数设为 true,或传入 { capture: true }
document.body.addEventListener('focus', handler, true);

React 合成事件也提供了对应的捕获版本:

// React 组件中
<div onFocusCapture={handleFocus}>
  <input />
</div>

onFocusCapture 会在捕获阶段触发,此时事件还未到达 <input>,父组件能成功接收到。

3. 直接绑定目标元素,放弃委托

如果事件的产生元素数量有限,或者能够通过 ref 拿到每个目标,直接逐个绑定反而是最简单可靠的。

// 使用 ref 直接绑定
const inputRef = useRef(null);
useEffect(() => {
  inputRef.current?.addEventListener('focus', handler);
}, []);
<input ref={inputRef} />

对于动态列表,可以在创建元素时一并绑定事件,避免过度依赖委托。

总结

DOM 事件模型的灵活性让我们习惯于用事件委托处理一切,但不冒泡事件的存在打破了这一习惯。下次当你发现父元素上的监听器“失效”时,不妨检查一下:

  1. 这个事件本身冒泡吗?
  2. 有没有对应的冒泡版本可用?(如 focusin
  3. 能否换成捕获阶段监听?
  4. 是否真的需要委托,还是直接绑定更简单?

理解事件的传播特性,能帮你避免大量隐蔽的调试成本,写出更健壮的前端代码。希望这篇梳理能成为你开发中的一份实用参考。