前两天同事问我: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 记录起点,然后在 document 或 window 上监听 mousemove 和 mouseup。
很多新手把 mousemove 绑在目标元素上,结果鼠标稍微移出元素边界,坐标就不再更新,拖动状态就不连续了。正确姿势是把 mousemove 和 mouseup 挂在 document 上,鼠标松开的瞬间再移除。这样即使光标移出浏览器窗口后再松开,多数桌面浏览器也能正确触发 mouseup。
mousemove 的触发频率非常高,远高于屏幕刷新率,直接在里面做重计算会导致卡顿。我的习惯是配合 requestAnimationFrame 节流,先记录最新坐标,下一帧再统一执行绘制逻辑。
js复制let rafId = null;
let latestPoint = { x: 0, y: 0 };
document.addEventListener('mousemove', (e) => {
latestPoint.x = e.clientX;
latestPoint.y = e.clientY;
if (rafId) return;
rafId = requestAnimationFrame(() => {
draw(latestPoint.x, latestPoint.y);
rafId = null;
});
});
2.3 wheel 和 scroll 的边界
回到开头的那个问题。wheel 是鼠标滚轮或触控板双指滑动产生的原始输入事件,而 scroll 是元素滚动位置变化后触发的事件。滚轮会驱动滚动条,所以一次真实的页面滚动过程里,往往是 wheel 先触发,随后 scroll 才触发。
但移动端手指滑动时,没有 wheel 事件,只有 scroll 和 touchmove。所以如果你的业务是"根据用户的滚轮输入做自定义缩放"或"按滚轮方向切换页面",应该监听 wheel;如果只是想响应页面滚动位置变化、做吸顶或懒加载,监听 scroll 就足够了。
需要注意 scroll 事件不冒泡。想在 document 上一网打尽所有子容器的滚动,要设置捕获监听:
js复制document.addEventListener('scroll', (e) => {
console.log(e.target); // 具体滚动的元素
}, true);
同时,scroll 监听器内部尽量不要去操作布局属性,比如读取 offsetTop、offsetHeight,否则会反复触发强制同步布局。用 requestAnimationFrame 合并更新,或者直接使用 IntersectionObserver 处理懒加载,是更省心的方案。
2.4 键盘事件:废弃的 keypress 和被你误用的 keyCode
键盘事件核心只有 keydown、keyup,keypress 在规范里已经废弃,不要去新项目里用。keydown 在按键按下瞬间触发,按住不放会连续触发;keyup 在松开时触发。
判断按键时不要再依赖 event.keyCode。这个字段已经废弃,新项目应该用 event.key 表示物理按键对应的可打印字符,用 event.code 表示物理按键位置。
key 和 code 的区别很具体:用户按键盘上的 W 键时,key 可能是 w,但如果当前输入法是中文状态,逻辑上按下的可能是 Process;而 code 始终是 KeyW,不会因为语言环境变化。判断快捷键组合时,我更推荐使用 event.code,这样不管键盘布局怎么换,按键物理位置是稳定的。不过要留意,在 Dvorak 这类非 QWERTY 布局里,code 仍然按 QWERTY 物理键位返回,对需要显示字符场景的用户来说可能不够直观。
实际业务里处理组合键还要关注修饰键状态:
js复制document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.code === 'KeyS') {
e.preventDefault();
saveDocument();
}
});
如果做的是全局快捷键,记得在不需要的判断里尽早返回,避免每次键盘输入都走一遍复杂逻辑。
3. 表单、焦点与输入法:最容易读错值的三个场景
表单页面是前端开发里改造最多的地方。input、change、submit、focus、blur、focusin、focusout,每个事件都有人写错过触发时机。这里还有一个隐藏的大坑:中文输入法组合输入会多次触发值变化,单靠 input 监听读到的可能是中间状态。
3.1 input 与 change:一个实时,一个离场
文本输入框场景里,input 事件在输入内容每次变化时都会触发,是最适合做搜索联想、字符计数的监听事件。change 事件则不同,它在输入框内容变化并失去焦点时,或者在下拉框、单选框选项选中时触发。
如果需要在用户每次输入后都自动保存,用 input;如果只关心用户最终提交结果,用 change。比如做表单里"未保存变化"提示,我一般监听 input 并设置脏标记,因为用户可能输入完不点击任何地方,从不触发 change。
submit 事件监听的是表单提交,绑定对象是 form 元素,不是提交按钮。需要在提交时拦截数据、做异步校验时,在 submit 里 preventDefault() 阻止默认跳转即可。表单里如果用了 required、pattern 等约束校验,点击提交后浏览器先做原生校验,校验不通过时会触发 invalid 事件,同时不会继续派发 submit。
3.2 focus 与 focusin:不冒泡与冒泡的差别
focus 和 blur 不冒泡,focusin 和 focusout 则是它们的冒泡版本。如果父容器想统一处理单元格获得焦点的高亮,监听 focusin 远比给每个输入框单独绑 focus 更高效。
js复制form.addEventListener('focusin', (e) => {
wrapField(e.target, 'active');
});
form.addEventListener('focusout', (e) => {
wrapField(e.target, '');
});
这里还有个小细节:用户点击输入框时,浏览器默认先派发 mousedown,之后才会派发 focus。如果 mousedown 里提前把输入框渲染成了禁用状态,焦点就被打断了,所以某些联动交互不要在 mousedown 阶段做破坏焦点目标的操作。
3.3 中文输入法带来的那些"半成品"输入
真正踩过坑的开发者都知道,中文输入法组合拼音时,input 事件会触发很多次,每次拿到的也可能是尚未确认的拼音字母。比如用户输入"学习",在拼音组合阶段就能收到 x、xu、xue 以及最终确认成"学习"的过程值。如果每次 input 都去请求远程搜索,接口会被打爆,搜索内容还会带出半截拼音。
解决方案至少有三个层面:
一是在 input 处理函数里先检查 event.isComposing,为 true 时直接忽略,不要在组合阶段提交业务请求。
二是监听 compositionstart、compositionupdate、compositionend,用这些事件判断当前是否处于输入法组合状态。组合结束时再读取输入框的最终值,执行搜索。
js复制let composing = false;
input.addEventListener('compositionstart', () => {
composing = true;
});
input.addEventListener('compositionend', (e) => {
composing = false;
handleSearch(e.target.value);
});
input.addEventListener('input', (e) => {
if (composing) return;
handleSearch(e.target.value);
});
三是如果连 isComposing 也不可靠,可以引入一个 200 毫秒的防抖,组合结束后稳定一段时间再请求。实测下来,compositionend 的触发时机在部分浏览器里会比最终 input 事件稍早或稍晚,所以最保险的做法还是"防抖 + isComposing/composition 状态"双保险。
4. 触摸、指针与拖拽:移动端交互的另一个世界
移动端浏览器和桌面端不一样,用户用的是触摸屏,事件里面有 touch 和 pointer 两个流派。拖拽上传这一块又有完整的拖拽事件族。设备还会倾斜、旋转,传感器事件也常被忽略。
4.1 touch 事件族:什么时候该放弃 click
触摸事件基础四个:touchstart、touchmove、touchend、touchcancel。事件对象里的 touches 表示当前屏幕上所有触摸点,targetTouches 表示当前目标上的触摸点,changedTouches 表示本次事件中变化的触摸点。touchend 触发时,touches 和 targetTouches 里往往已经没有对应的手指了,所以取最后一次坐标只能用 changedTouches。
传统桌面浏览器会在触摸后模拟触发 click,但同时会带来 300 毫秒的点击延迟。现代移动端浏览器在设置了 width=device-width 的视口后基本消除了这个延迟,但如果你使用 touchstart 调用了 preventDefault(),后续的 mouse 模拟事件和 click 都会被抑制。做手势库、轮播图时,我们经常主动 preventDefault() 来防止滚动和误点击,副作用就是点击事件不再触发,所以手势与点击并存的地方要格外小心。
touchmove 伴随页面滚动时,持续触发会很频繁。想要通过纯 JS 阻止页面滚动,在 touchmove 中 preventDefault() 有效,但移动端浏览器对 passive 有默认策略,如果你没显式传 { passive: false },某些浏览器会直接忽略 preventDefault()。
4.2 pointer 事件:鼠标、触摸、触控笔的统一抽象
Pointer Events 是一套更现代的方案,把鼠标、手指、触控笔统一成一种事件。常见事件包括 pointerover、pointerenter、pointerdown、pointermove、pointerup、pointercancel、pointerout、pointerleave,还包含 gotpointercapture 和 lostpointercapture。
其中每组事件在语义上和鼠标事件保持一致:pointerenter/pointerleave 不冒泡,pointerover/pointerout 冒泡。判断当前输入设备用 event.pointerType,值可能是 mouse、touch 或 pen。
做 canvas 手写板时经常需要防止手写笔抬起后丢失移动路径,可靠做法是主动捕获指针:
js复制canvas.addEventListener('pointerdown', function (e) {
canvas.setPointerCapture(e.pointerId);
});
canvas.addEventListener('pointermove', function (e) {
draw(e.clientX, e.clientY);
});
canvas.addEventListener('pointerup', function (e) {
canvas.releasePointerCapture(e.pointerId);
});
只要 setPointerCapture 成功,后续 pointermove 和 pointerup 就一直派发给 canvas,哪怕手指或笔已经移出 canvas 也能继续追踪,这比把 mousemove 绑到 document 上更干净。
不过浏览器对 Pointer Events 的支持也有历史断档,旧版 iOS Safari 不支持,而 touch 事件是老少通吃。实际项目如果不依赖复杂手势,或者目标用户含较多 iOS 旧版本,我会同时保留两套逻辑。
4.3 拖拽事件:dragover 必须阻止默认行为
HTML5 拖拽事件是另一套完整体系,包括 dragstart、drag、dragenter、dragover、dragleave、drop、dragend。实现拖拽上传时最容易犯的错是:准备了一个放置区,给放置区绑了 drop,结果拖文件上去只看到浏览器打开了文件页面,根本没有触发 drop。
根因是 HTML5 规范里,元素默认不允许成为放置目标。要让放置区生效,必须在 dragenter 和 dragover 里调用 preventDefault()。用事件冒泡来处理拖拽状态时,dragenter 和 dragleave 会因为经过子元素而反复触发,常见的解决办法是维护一个进入深度计数器。
拖拽数据的读取要特别注意:dataTransfer 中的数据在 dragover 阶段出于安全考虑是可读性非常有限的,真正执行读取要放到 drop 事件里。
js复制dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('drag-active');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('drag-active');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
const files = Array.from(e.dataTransfer.files);
uploadFiles(files);
});
如果业务目标是"不允许用户拖拽页面里的图片而是上传文件",通常要在 document 的 dragover 和 drop 上都调用 preventDefault(),否则用户把图片拖到非上传区域时,浏览器会直接打开这个图片。那种"怎么图片被拖拽到别处就自动打开"的问题,就是没做全局拖拽拦截。
4.4 设备传感器事件:手势背后的权限暗坑
deviceorientation 和 devicemotion 能拿到设备方向、加速度等信息,可以做摇一摇、横竖屏游戏控制。但它们至少要满足两个条件:网站必须是 HTTPS;iOS 13 之后系统要求用户授权。
code复制DeviceOrientationEvent.requestPermission()
这个 API 必须在用户手势事件里调用,比如点击"开启传感器"按钮,返回值有 granted、denied 等状态。前端拿到授权后,再向 window 注册 deviceorientation 或 devicemotion 监听。未做授权处理时,iOS 上监听不到任何数据,且控制台不会报错,排查起来很容易让人怀疑人生。
5. 资源加载、页面生命周期与网络状态:全局监听器才是难点
事件不只发生在按钮和输入框上。页面加载完成、用户切后台、网络断线、浏览器前进后退、跨标签页数据变更,这些场景的事件挂载位置更复杂,出问题也更隐蔽。
5.1 DOMContentLoaded、load 和 readystatechange
页面加载事件的几个阶段经常被混淆。DOMContentLoaded 在 HTML 解析完成、DOM 树构建完毕但外部资源如样式表、图片、iframe 还没完全加载时触发。load 要等到页面里所有资源都加载完成后才会触发。
通常业务代码初始化放在 DOMContentLoaded,启动较重的统计、上报、图片预加载模块放在 load。反过来,如果想知道页面加载到哪一步了,可以用 document.readyState 在 readystatechange 事件里判断,它的状态会从 loading 经过 interactive 到达 complete。
一个实际场景是:我给动态创建的 <script> 绑定 load 事件来做模块加载完成后的回调,给 <img> 绑定 load 和 error 来处理图片展示失败。需要注意,资源加载的错误事件和普通 DOM 错误事件冒泡行为并不一致。img 和 script 加载失败时不冒泡,window.addEventListener('error', handler) 默认捕获不到,必须加第三个参数 true 开启捕获阶段,或者直接用 element.addEventListener('error', handler) 绑定在具体资源元素上。
5.2 beforeunload、pagehide 与 visibilitychange
用户要关闭或刷新页面时,beforeunload 可以弹出确认提示。但现代浏览器为了防骚扰,对这里限制很多,Chrome 要求页面必须有过用户交互,且提示文案不能让开发者自定义。想可靠弹窗,必须在事件里同时调用 preventDefault(),旧一些的浏览器还要设置 returnValue。
还有一个更难发现的坑:在 beforeunload 里发送异步数据基本不靠谱,因为页面可能随时进入卸载流程,请求根本发不出去。统计上报类数据应该用 navigator.sendBeacon(),它不阻塞页面卸载,由浏览器接管发送。
移动端浏览器里,pagehide 比 beforeunload 更可靠,会在页面进入冻结或卸载前触发;visibilitychange 则能在用户切到后台、切回前台时响应。我做过一个在线草稿应用,用户切后台前自动保存、切回来时恢复焦点,核心就落在 visibilitychange 上:
js复制document.addEventListener('visibilitychange', () => {
if (document.hidden) {
saveDraft();
} else {
refreshEditingState();
}
});
5.3 网络状态、历史记录与跨标签页通信
online 和 offline 事件挂在 window 上。离线断网时触发 offline,网络恢复时触发 online。做同步类应用时,可以在 online 里触发一次数据重同步,但要注意 navigator.onLine 只能反映网络连通性,不能代表服务器真的可达,重同步时最好还是补一层接口探活。
浏览器前进后退、地址栏 hash 变化,对应的事件是 popstate 和 hashchange。单页应用用 history.pushState 改变地址时不会触发 popstate,所以路由库内部通常要自己补齐这套通知机制。如果你只想监听 hash 路由切换,hashchange 是最直观的选择。popstate 事件的 event.state 能拿到历史记录状态对象,在需要跨页面恢复滚动位置时用得很多。
跨标签页同步登录状态、本地缓存时,storage 事件会在其他标签页修改 localStorage 后触发。重要限制是:当前文档自己修改不会触发自身页面内的 storage 事件。比较稳妥的做法是自己在改完 localStorage 后手动派发一个自定义事件给当前页面的其他模块。
用 window.open 打开的子窗口之间、iframe 与父页之间会用到 message 事件。任何使用 postMessage 通信的地方都必须校验 event.origin,防止恶意页面伪造消息来源。对消息内容也要做白名单校验,不能直接信任 event.data。
5.4 窗口改变:resize 与适配方向
resize 在浏览器窗口大小变化、移动端地址栏收起弹出、设备横竖屏切换时触发。它天然高频,每次触发都做复杂布局计算会明显掉帧。建议用防抖或 requestAnimationFrame 合并,更建议把"尺寸驱动UI更新"改成 ResizeObserver 监听具体容器。
桌面端浏览器里监听 window 的 resize 就够了,移动端的软键盘弹出有时不触发 resize,需要额外关注 visualViewport 的 resize 事件。做聊天输入框固定在键盘上方时,visualViewport.resize 比传统 window.resize 可靠很多,避免了输入框被键盘挡住的问题。
6. 自定义事件与事件委托:把事件机制扩展成自己的系统
addEventListener 能监听的不只是浏览器内置事件,它也是我们手动制造事件的基础设施。无论是给已有 DOM 元素派发自定义事件,还是在完全无关的 JS 对象之间做消息通信,它都能胜任。
6.1 CustomEvent 的正确打开方式
自定义事件最少分三步:创建 CustomEvent、指定事件名和 detail 数据、用 dispatchEvent 派发到目标对象。
js复制const form = document.querySelector('#order-form');
function submitDone(orderId) {
form.dispatchEvent(
new CustomEvent('order:done', {
detail: { orderId },
})
);
}
form.addEventListener('order:done', (e) => {
console.log('订单完成:', e.detail.orderId);
});
使用自定义事件有几个细节。事件名建议用带命名空间的格式,比如 order:done、cart:updated,避免大团队协作时互相覆盖。detail 是传递附加数据的规范字段,可以放任意结构。想要事件在节点树里像普通事件一样可委托,需要把 bubbles 设为 true,否则监听父容器收不到。
code复制new CustomEvent('order:done', {
detail: data,
bubbles: true,
composed: true
})
composed: true 还要在事件穿过 shadow DOM 边界时使用。前端组件库基于 Shadow DOM 写内部结构时,如果内部派发的事件希望被组件外部捕获,必须设置 composed: true。
6.2 不用 DOM 也能事件化:EventTarget 通用对象
不少人以为只有 DOM 元素能加监听,其实原生提供的 EventTarget 类可以当作基础的发布订阅器,直接给任何对象挂上监听能力。
js复制class CartStore extends EventTarget {
constructor() {
super();
this.items = [];
}
add(item) {
this.items.push(item);
this.dispatchEvent(new CustomEvent('cart:add', { detail: item }));
}
}
const cart = new CartStore();
cart.addEventListener('cart:add', (e) => {
renderCart(e.detail);
});
在一个普通业务模块里,用这种方式做的内部模块通信,比全局事件总线更容易追溯来源,也比一根线传到所有组件里的回调函数更容易维护。
6.3 事件委托配合 closest:少挂监听器,多留性能余量
事件委托能处理大量列表项。与其给列表里每个按钮绑监听,不如在列表容器上只绑一次:
js复制list.addEventListener('click', (e) => {
const item = e.target.closest?.('[data-action]');
if (!item) return;
switch (item.dataset.action) {
case 'delete':
deleteItem(item.dataset.id);
break;
case 'edit':
editItem(item.dataset.id);
break;
}
});
因为 click 会冒泡,新的列表项动态加进来后不用额外绑定监听,天然生效。唯一的注意点是 e.target 可能是列表里的文字节点或者内层元素,先用 closest 向上找到包含 data-action 的按钮,找不到就提前退出。
6.4 用 AbortController 集中清理监听器
写了监听不清理,是页面卡顿和内存泄漏的主要来源。每次组件卸载时手动 removeEventListener 虽然可行,但监听一多就很容易漏。现代浏览器给 addEventListener 提供了一个 signal 选项,配合 AbortController 能做到一次性取消所有挂载的监听。
js复制class SearchBox {
constructor(input) {
this.ac = new AbortController();
input.addEventListener('input', this.onInput, {
signal: this.ac.signal,
});
document.addEventListener('click', this.onDocClick, {
signal: this.ac.signal,
});
}
destroy() {
this.ac.abort();
}
}
调用 abort() 之后,通过同一个 signal 注册的监听器全部移除。React 函数组件里很自然的用法是:
js复制useEffect(() => {
const ac = new AbortController();
ctx.canvas.addEventListener('pointermove', draw, { signal: ac.signal });
return () => ac.abort();
}, []);
AbortController 还能同时取消 fetch 请求,因此一处清理能一并把网络请求取消掉,这个特性非常值得投入学习成本。
7. 我整理的事件速查与选择经验
到这里每类事件基本都过了一遍。做一张浓缩速查表,对应实际业务诉求直接定位,能省下翻规范的时间。
| 业务诉求 | 推荐事件 | 挂载对象 | 关键注意点 |
|---|---|---|---|
| 检测普通点击 | click |
任意元素/委托父元素 | 移动端注意点击穿透 |
| 自定义高性能手势 | pointerdown / pointermove / pointerup |
目标元素 + setPointerCapture |
注意旧版 iOS |
| 阻止默认滚动手势 | touchmove |
滚动容器 | 需传 { passive: false } |
| 响应页面滚动 | scroll |
window 或具体容器 | 不冒泡,需要全容器监听用捕获 |
| 搜索框实时联想 | input |
输入框 | 中文输入必须考虑组合输入状态 |
| 校验最终提交字段 | change |
输入框/下拉框 | 失焦后才触发 |
| 表单提交前逻辑 | submit |
form 元素 | 配合 preventDefault |
| 下拉浮层展开 | mouseenter / mouseleave |
目标元素 | 不冒泡,避免子元素误触 |
| 拖拽上传文件 | dragenter / dragover / drop |
放置区 | 必须在 dragover 阻止默认行为 |
| 切后台自动保存 | visibilitychange |
document | 判断 document.hidden |
| 关闭页面发统计 | pagehide 或 beforeunload |
window | 用 navigator.sendBeacon 代替异步请求 |
| 跨标签页同步数据 | storage |
window | 当前页面自身不触发 |
| iframe/window 通信 | message |
window | 必须校验 event.origin |
| 监听元素尺寸变化 | ResizeObserver | 具体元素 | 不是事件,但性能更好 |
| 键盘快捷键 | keydown |
window/document | 用 e.code 判断物理键位 |
| 模块间解耦通信 | CustomEvent |
任意 EventTarget | 注意 bubbles / composed |
最后分享几条个人经验。事件名不是背下来的,是排查问题排出来的。当你需要处理某个交互时,先去 MDN 查对应事件对象和触发时机,把监听器挂在最贴近业务目标的那一层,再用 console.log(event.target, event.currentTarget, event.defaultPrevented) 观察一次真实触发链路,比任何口诀都管用。凡是高频事件,都要思考是否需要合并帧、防抖,是否要设置 passive: true 释放性能;凡是异步组件,都要保证监听器随着组件销毁一起移除。多用 AbortController,多写 CustomEvent 命名空间,你手里的 addEventListener 会从一个个孤立回调,变成一套可以长期演进的事件架构。
