前两天同事问我:wheel 和 scroll 到底算不算一回事?他说页面上滚轮能触发 wheel,监听 scroll 也能收到,但挪到移动端手势滚动后,wheel 不触发了,他一下子懵了。这个问题看着基础,实际上恰好戳中了很多前端开发者用 addEventListener 时的通病——背过一串事件名,真到业务里却不知道每个事件适合挂在谁身上、在什么阶段触发、该怎么防默认行为。
我做了十多年前端,写过表格、画过图表、调过视频、搞过拖拽上传,可以说工作中几乎所有交互都绕不开事件监听器。这次把 addEventListener 场景里高频出现、以及容易踩坑的事件族完整盘一遍,从底层的事件流机制讲起,再按鼠标键盘、表单焦点、触摸指针、页面生命周期、自定义事件这几个维度逐类拆解。适合刚接触前端不久、靠复制粘贴写监听的入门者,也适合做了两三年项目但没时间系统性梳理事件模型的开发者。
1. 先搞懂事件流的走向,才知道监听器挂在哪里
很多人用 addEventListener 只是记住"点击就 listener 一下就完事",但事件真正到达你的监听器之前,会先经过一段固定路线。不理解这个路线,后面所有事件都可能挂错对象。
1.1 事件注册的三种姿势,为什么 addEventListener 是主力
给 DOM 元素挂事件,原始写法有很多种,至少包括 HTML 内联 onclick、元素属性的 element.onclick、以及 addEventListener。前两者写法更简洁,但有个致命缺陷:同一个事件只能挂一个处理函数,后挂的会把前面的覆盖掉。
code复制button.onclick = function () {
console.log('A');
};
button.onclick = function () {
console.log('B'); // 只有这里会执行
};
addEventListener 解决的第一个问题就是"同一元素、同一事件可以挂多个处理函数,并且按注册顺序依次触发"。它第二个价值是能显式控制捕获阶段还是冒泡阶段触发,第三个价值是配合 { once: true }、{ passive: true }、{ signal } 这些选项,这是一句 onclick 做不到的。
所以到了现代项目里,我几乎不会给元素直接赋值 element.onclick = fn,除非是极简单的临时按钮。团队代码审查时我也会明确要求统一走 addEventListener,这样后面移除监听也能保持对称。
1.2 捕获、目标和冒泡:事件传播的三段式
一个事件从发生到结束,会经历三个阶段:捕获阶段、目标阶段、冒泡阶段。
捕获阶段从 window 往下走,经过 document,一层层进到实际触发事件的目标元素。到达目标元素后,进入目标阶段,处理挂在目标上的监听器。接着开始冒泡,从目标元素往上,一层层回到 window。
addEventListener 第三个参数如果传 true,监听器会放在捕获阶段;传 false 或不传,会放在冒泡阶段。绝大多数场景我们都用默认的冒泡监听,因为冒泡阶段带来了一个杀手级技巧——事件委托。你可以只给父级挂一个监听器,接收所有子元素冒泡上来的事件,再用 target 区分实际操作的子项。
但要小心,并不是所有事件都冒泡。比如 mouseenter、focus、blur、scroll 都不会冒泡。focus 不冒泡却可以用 focusin,mouseover 和 mouseout 才会冒泡,这些细节后面都会用到。
1.3 event.target、event.currentTarget 和 relatedTarget
在事件处理函数里,最容易被新手弄混的就是 target 和 currentTarget。
target 是事件真正发生的元素,也就是用户实际点到的那个节点。currentTarget 是当前监听器所挂载的元素,在冒泡过程中它是不断变化的。对冒泡监听来说,target 可能在很深的子节点,而 currentTarget 一直是你绑监听的父节点。两者相同只在监听器恰好挂在目标元素自身时成立。
鼠标事件里还有个容易忽略的 relatedTarget。mouseover 和 mouseout 发生时,relatedTarget 代表了光标从哪来、到哪去。比如移入子元素时,mouseover 会在父元素和子元素之间连续触发,判断"这次移入是不是从外部来",就得比较 relatedTarget 是不是当前元素的内部节点。
记住这些底层概念之后,下面每一类事件才会真正变成你的工具箱,而不是一张死记硬背的单词表。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 鼠标、键盘、滚轮:PC 交互里的高频三件套
桌面端第一直觉是用鼠标点,所以鼠标事件家族的人数最多。键盘事件看着简单,实际牵扯到 key 和 code 的取舍。滚轮事件又和滚动条监听纠缠在一起。逐个说清楚。
2.1 鼠标进入和离开,mouseenter 与 mouseover 不能混搭
鼠标事件能列出一大串:mousedown、mouseup、click、dblclick、mousemove、mouseover、mouseout、mouseenter、mouseleave、contextmenu,还有较新的 auxclick。
最容易迷惑的是 mouseenter/mouseleave 和 mouseover/mouseout 的关系。我做了个对比表,直接背下来就可以。
| 事件名 | 是否冒泡 | 触发特点 | 使用场景 |
|---|---|---|---|
mouseover |
冒泡 | 移入元素以及移入其子元素时都会触发 | 需要感知元素树内部的移入移出 |
mouseout |
冒泡 | 移出元素以及移出其子元素时都会触发 | 和 mouseover 配对 |
mouseenter |
不冒泡 | 只有移入元素自身边界时触发一次 | hover 浮层显示 |
mouseleave |
不冒泡 | 只有移出元素自身边界时触发一次 | hover 浮层隐藏 |
实际开发里最常见的错误是:想在悬浮菜单上做一个延时隐藏,结果用 mouseover 监听菜单容器,鼠标移动到子菜单上也反复触发定时器清除逻辑,最后浮层莫名其妙一直不消失。直接用 mouseenter/mouseleave 就没有这个烦恼。反之,如果你需要在父元素上统一处理子项 hover 切换,利用 mouseover 的冒泡特性做事件委托反而更好。
click 事件本身也有一些顺序问题。用户快速连点两下,触发顺序是:mousedown、mouseup、click、mousedown、mouseup、click、dblclick。如果你既绑了 click 又绑了 dblclick,第一下单击触发的定时器很可能会在双击后误执行。常见的下拉菜单双击问题,根源就在这,处理方式一般是单击启一个 250 毫秒左右的延时,dblclick 触发后取消之前的定时器。
右键菜单事件 contextmenu 默认会弹出浏览器菜单,需要自定义右键菜单时,必须在监听器里调用 preventDefault()。注意部分 Linux 桌面环境下键盘菜单键也会触发 contextmenu,所以它不能简单当成纯鼠标事件处理。
2.2 鼠标按下、松开与移动的配合
实现拖拽、画板、画布框选这类交互时,光监听目标元素是远远不够的。通常的做法是:在目标上监听 mousedown 记录起点,然后在 `docum
