事件绑定与解绑: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 时,浏览器会做三件事:

  1. 查找事件类型对应的列表——如果不存在,就新建一个空数组。
  2. 检查是否已存在相同的处理器——注意,这里的「相同」指的是同一个函数引用。如果已经注册过,直接跳过,不会重复添加。
  3. 将处理器和选项打包成对象,推入列表——等待事件触发时遍历执行。

来看个完整的绑定示例:

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 的列表是分开管理的。

底层原理:事件派发流程

为了让你更直观地理解,我画了一张流程图:

事件绑定与派发底层流程 用户操作(点击) 事件派发器(Event Dispatcher) 查找元素._listeners['click'] 遍历处理器列表 捕获阶段处理器 冒泡阶段处理器 执行 handler(event) 执行 handler(event) 注意 捕获阶段:从根节点 向下到目标元素 冒泡阶段:从目标元素 向上到根节点

事件触发时,浏览器会先走捕获阶段(从 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 就是按条件从列表里移除条目。理解了这个数据结构,你就能解释所有绑定解绑的「奇怪」行为了。