3、事件监听器:addEventListener与removeEventListener的底层实现
事件监听器,说白了就是让 DOM 元素「听到」用户动作的那套机制。你写 el.addEventListener('click', handler),浏览器底层到底干了什么?我当年刚入行时也以为只是简单存了个回调函数,直到有一次排查内存泄漏,才被迫把源码翻了个底朝天。
今天我们就来拆解这两个 API 的底层逻辑。不扯虚的,直接看引擎层怎么干活。
3.1 从调用栈看 addEventListener 的完整链路
当你调用 addEventListener,浏览器内部至少走了四步:
- 参数校验——检查 type 是不是合法字符串,listener 是不是函数或对象。
- 查找目标元素的事件表——每个 DOM 节点内部维护了一个事件监听器列表。
- 去重检查——如果同一类型、同一回调、同一 capture 标志已经注册过,直接跳过。
- 插入链表——把新监听器挂到该类型的事件链表尾部。
核心数据结构:每个 EventTarget 内部都有一个 [[eventListeners]] 映射表。键是事件类型(如 'click'),值是一个双向链表,节点里存了回调函数、useCapture、once、passive 等标志位。
我习惯把这个结构想象成「每个 DOM 元素都有一本电话簿,按事件类型分页,每页按注册顺序排了一串号码」。你注册监听器,就是在对应页上写一个新号码。
// 伪代码:浏览器内部的大致实现
class EventTarget {
constructor() {
this._listeners = new Map(); // 类型 → 链表头
}
addEventListener(type, callback, options) {
const capture = typeof options === 'boolean' ? options : (options?.capture ?? false);
const once = options?.once ?? false;
const passive = options?.passive ?? false;
// 1. 参数校验
if (typeof callback !== 'function' && typeof callback !== 'object') return;
if (typeof type !== 'string' || type === '') return;
// 2. 获取该类型的链表
if (!this._listeners.has(type)) {
this._listeners.set(type, null);
}
// 3. 去重检查:遍历链表,看是否已有完全相同的条目
let node = this._listeners.get(type);
while (node) {
if (node.callback === callback && node.capture === capture) {
return; // 已存在,直接忽略
}
node = node.next;
}
// 4. 创建新节点,插入链表尾部
const entry = {
callback,
capture,
once,
passive,
next: null,
prev: null
};
const head = this._listeners.get(type);
if (!head) {
this._listeners.set(type, entry);
} else {
let tail = head;
while (tail.next) tail = tail.next;
tail.next = entry;
entry.prev = tail;
}
}
}
一个小细节:options 参数可以是布尔值(true 表示 capture),也可以是对象。我个人建议始终用对象写法,可读性高很多,而且后面加 passive 或 once 时不用改结构。
3.2 removeEventListener 的删除逻辑
删除比添加更讲究。你想想看,如果链表里删错了节点,整个事件分发就可能断掉。浏览器的做法很保守:
- 必须 type、callback、capture 三者完全匹配才能删除。
- 删除时直接操作链表指针,把前后节点接上。
- 如果删的是头节点,要更新链表头指针。
removeEventListener(type, callback, options) {
const capture = typeof options === 'boolean' ? options : (options?.capture ?? false);
if (!this._listeners.has(type)) return;
let node = this._listeners.get(type);
while (node) {
if (node.callback === callback && node.capture === capture) {
// 找到了,开始摘除
if (node.prev) {
node.prev.next = node.next;
} else {
// 删除的是头节点
this._listeners.set(type, node.next);
}
if (node.next) {
node.next.prev = node.prev;
}
// 断开引用,帮助 GC
node.prev = null;
node.next = null;
return;
}
node = node.next;
}
}
我曾经踩过一个坑:用匿名函数注册监听器,然后想用同样的匿名函数移除。结果永远删不掉,因为两个匿名函数在内存里是两个不同的对象。记住,removeEventListener 比较的是引用,不是代码长得像不像。
3.3 事件分发时的链表遍历
事件触发时,浏览器会从链表头开始逐个执行回调。这里有个关键点:遍历期间如果链表被修改了怎么办?
比如一个回调里调用了 removeEventListener 删除了自己,甚至删除了后面的节点。如果直接用原始链表遍历,很可能出现空指针或漏执行。
浏览器的解决方案很巧妙——先克隆链表再遍历。分发事件前,把当前链表复制一份快照,然后遍历快照。这样原始链表怎么改都不影响本次分发。
性能取舍:克隆链表有额外开销,但保证了事件分发的稳定性和可预测性。Chrome 和 Firefox 都采用这个策略,只是具体实现细节略有差异。
3.4 once 与 passive 的底层处理
这两个选项在底层有特殊待遇:
| 选项 | 底层行为 | 我见过的典型问题 |
|---|---|---|
| once | 回调执行后,自动调用 removeEventListener 把自己删掉。注意是在回调执行之后才删除,所以回调里还能访问到自身引用。 |
有人手动在回调里又调了一次 removeEventListener,结果重复删除,虽然不会报错但逻辑混乱。 |
| passive | 设置一个标志位,告诉浏览器「这个监听器不会调用 preventDefault」。浏览器拿到这个标志后,可以提前做滚动优化,不用等回调执行完再决定是否滚动。 | 我在移动端项目里遇到过:没加 passive 的 touch 事件导致滚动卡顿,加上后流畅度明显提升。 |
3.5 一张图看懂整体架构
下面这张 SVG 图把 addEventListener 到事件分发的完整链路画了出来。你可以对照着看,每一步对应什么数据结构。
3.6 内存泄漏与 GC 回收
底层实现里还有一个容易被忽略的点:链表节点对回调函数的引用。如果你忘记 removeEventListener,这个引用会一直存在,导致回调函数里捕获的变量无法被 GC 回收。
我遇到过最典型的场景:单页应用里反复创建和销毁组件,每次创建都 addEventListener,销毁时忘了 remove。结果事件链表越来越长,回调里还引用了已经销毁的 DOM 节点,内存只增不减。
我的习惯:在组件销毁的生命周期里,统一调用 removeEventListener。如果回调是绑定 this 的,记得保存引用,否则移除时找不到同一个函数对象。
3.7 小结
addEventListener 和 removeEventListener 的底层,本质上就是一张映射表加双向链表。添加是尾部插入,删除是链表摘除,分发是快照遍历。理解了这三个操作,你就能解释很多奇怪的现象:
- 为什么匿名函数删不掉?——引用不同。
- 为什么 once 只执行一次?——执行后自动摘除。
- 为什么 passive 能优化滚动?——浏览器提前知道你不会 preventDefault。
嗯,这些底层细节平时写业务可能用不上,但一旦遇到性能问题或内存泄漏,它们就是你排查的底气。