事件绑定与解绑:addEventListener 与 removeEventListener 的底层原理
事件绑定,说白了就是告诉浏览器:「嘿,当用户在这个元素上干了某件事,你帮我跑一段代码。」
我刚入行那会儿,觉得这事特简单——直接用 onclick 不就完了?后来在一个大型后台项目里,我被坑得够呛。一个按钮绑了三次点击事件,结果只有最后一次生效。嗯,从那时起我才真正去研究 addEventListener 到底是怎么工作的。
从「onclick」到「addEventListener」的进化
先看个最原始的写法:
const btn = document.getElementById('myBtn');
btn.onclick = function() {
console.log('第一次绑定');
};
btn.onclick = function() {
console.log('第二次绑定');
};
// 只会输出「第二次绑定」
为什么会这样?因为 onclick 是 DOM 元素的属性,属性只能存一个值。第二次赋值直接把第一次覆盖了。你想想看,如果我想在一个按钮上同时处理「记录点击」「发送统计」「更新UI」三个逻辑,用 onclick 就得手动拼函数,维护起来简直噩梦。
addEventListener 的出现,就是为了解决这个痛点。它允许你在同一个元素上注册多个相同类型的事件处理器,彼此互不干扰。
核心区别一句话:onclick 是属性赋值,addEventListener 是事件注册。前者是「替换」,后者是「追加」。
addEventListener 的底层机制
我个人习惯把事件绑定理解成「订阅模式」。每个 DOM 元素内部维护了一张事件监听器列表,结构大概长这样:
// 伪代码:浏览器内部的数据结构
element._listeners = {
'click': [
{ handler: fn1, options: { capture: false, once: false, passive: false } },
{ handler: fn2, options: { capture: true, once: false, passive: false } }
],
'mouseover': [
{ handler: fn3, options: { capture: false, once: true, passive: false } }
]
};
当你调用 addEventListener 时,浏览器会做三件事:
- 查找事件类型对应的列表——如果不存在,就新建一个空数组。
- 检查是否已存在相同的处理器——注意,这里的「相同」指的是同一个函数引用。如果已经注册过,直接跳过,不会重复添加。
- 将处理器和选项打包成对象,推入列表——等待事件触发时遍历执行。
来看个完整的绑定示例:
const btn = document.getElementById('myBtn');
function handleClick(event) {
console.log('按钮被点击了', event);
}
// 绑定
btn.addEventListener('click', handleClick, false);
// 再绑一次同样的函数——不会生效
btn.addEventListener('click', handleClick, false);
// 绑一个匿名函数——可以生效,因为引用不同
btn.addEventListener('click', function(event) {
console.log('另一个处理器');
}, false);
这里有个坑:匿名函数无法解绑。因为 removeEventListener 需要传入同一个函数引用才能移除。我曾经在项目里看到同事用匿名函数绑了一堆事件,结果想解绑时发现根本做不到,最后只能把整个元素替换掉。
避坑指南:如果你后续需要解绑事件,一定要用具名函数。匿名函数绑上去就「失联」了。
removeEventListener 的匹配规则
解绑不是随便传个名字就能成功的。浏览器会严格匹配三个条件:
| 条件 | 说明 | 常见坑点 |
|---|---|---|
| 事件类型 | 必须与绑定时完全一致 | 大小写敏感,'click' 和 'Click' 不同 |
| 函数引用 | 必须是同一个函数对象 | 匿名函数、箭头函数每次创建都是新引用 |
| capture 选项 | 必须与绑定时一致 | 默认 false,解绑时没传 capture 也是 false |
看个实际例子:
const btn = document.getElementById('myBtn');
function handler() {
console.log('执行');
}
// 绑定(捕获阶段)
btn.addEventListener('click', handler, true);
// 尝试解绑(冒泡阶段)—— 失败!
btn.removeEventListener('click', handler, false);
// 正确解绑
btn.removeEventListener('click', handler, true);
我记得有一次排查线上 bug,发现事件解绑后还在触发。查了半天,原来是绑定的时候传了 true,解绑的时候没传参数(默认 false)。这种细节,你不踩一次坑真的记不住。
事件处理器的存储与执行顺序
绑定多个处理器时,执行顺序是怎样的?答案是:按注册顺序执行。
btn.addEventListener('click', () => console.log('A'));
btn.addEventListener('click', () => console.log('B'));
btn.addEventListener('click', () => console.log('C'));
// 输出:A → B → C
但如果混用了 addEventListener 和 onclick 呢?
btn.addEventListener('click', () => console.log('A'));
btn.onclick = () => console.log('B');
btn.addEventListener('click', () => console.log('C'));
// 输出:A → C(B 不会输出,因为 onclick 被 addEventListener 覆盖了?)
等等,这里有个容易混淆的点。实际上 onclick 和 addEventListener 是两套独立的机制。onclick 赋值不会覆盖 addEventListener 注册的处理器,但 onclick 本身只能存一个值。上面的例子中,btn.onclick 确实会被执行,但前提是它没有被后续的 addEventListener 影响——实际上它们互不干扰。输出应该是 A → B → C。
我建议你亲自跑一下这段代码,结果可能会让你意外。浏览器实现上,onclick 的触发是在事件派发的某个特定阶段,与 addEventListener 的列表是分开管理的。
底层原理:事件派发流程
为了让你更直观地理解,我画了一张流程图:
事件触发时,浏览器会先走捕获阶段(从 document 向下到目标元素),再走目标阶段,最后走冒泡阶段(从目标元素向上到 document)。addEventListener 的第三个参数 capture 决定了处理器是在捕获阶段还是冒泡阶段执行。
options 对象:现代浏览器的增强能力
除了布尔值的 capture,现代浏览器还支持传入一个 options 对象:
element.addEventListener('click', handler, {
capture: false, // 是否在捕获阶段触发
once: true, // 只执行一次,自动解绑
passive: false // 是否不调用 preventDefault
});
这三个选项里,once 和 passive 是我用得最多的。
- once: true——适合「一次性」操作,比如弹窗只关闭一次。省得你手动
removeEventListener。 - passive: true——告诉浏览器「我不会阻止默认行为」,浏览器可以放心地做滚动优化。移动端滚动卡顿,很多时候就是忘了加这个。
小技巧:如果你在移动端做滚动相关的事件监听,强烈建议加上 { passive: true }。我曾经在一个 H5 项目里,因为没加这个选项,导致 iOS 上的滚动延迟了 200ms 以上。
removeEventListener 的注意事项
解绑时,options 的匹配规则比想象中严格。看个例子:
const handler = () => {};
// 绑定方式一
el.addEventListener('click', handler, { capture: true });
// 解绑方式一 —— 成功
el.removeEventListener('click', handler, { capture: true });
// 绑定方式二
el.addEventListener('click', handler, { once: true });
// 解绑方式二 —— 失败!因为 once 在绑定后已被消耗
// 实际上 once 绑定后执行一次就自动移除了,不需要手动解绑
这里有个细节:once 绑定的事件,执行一次后浏览器会自动从列表中移除。如果你手动调用 removeEventListener,传入的 once 选项会被忽略——因为绑定时存储的 once 状态已经变了。
我个人建议,解绑时只传 capture 选项,其他选项不用管。因为 capture 是影响事件流阶段的,而 once 和 passive 只是行为修饰符,不影响匹配。
性能与内存泄漏
事件绑定最容易被忽视的问题就是内存泄漏。尤其是单页应用里,页面切换时旧 DOM 被移除,但事件处理器还挂在上面——不对,DOM 都被移除了,事件处理器也会被垃圾回收。真正的问题出在另一种情况:
class MyComponent {
constructor() {
this.handleClick = () => {
// 这里引用了 this,导致整个组件无法被回收
console.log(this.data);
};
document.addEventListener('click', this.handleClick);
}
destroy() {
// 忘记解绑!
}
}
上面的代码里,document 上的事件处理器引用了组件实例,即使组件被销毁,只要事件还在,组件就无法被垃圾回收。这就是典型的内存泄漏。
避坑指南:绑定在 document、window 等全局对象上的事件,一定要在组件销毁时手动解绑。否则你会看到内存占用只增不减。
好了,事件绑定与解绑的底层原理就聊到这儿。说白了,addEventListener 就是往一个列表里追加条目,removeEventListener 就是按条件从列表里移除条目。理解了这个数据结构,你就能解释所有绑定解绑的「奇怪」行为了。