3、事件监听器注册:addEventListener与removeEventListener的实现,监听器管理器的设计
事件监听器的注册与移除,说白了就是让 UI 组件知道「什么时候该干活,什么时候该歇着」。我见过不少项目,监听器注册得满天飞,但从来没人管移除的事。结果呢?页面越跑越慢,最后卡得跟幻灯片似的。
这一章,咱们就聊聊怎么把监听器管起来。我会从最基础的 API 实现讲起,再到监听器管理器的设计。嗯,这里面的坑,我踩过不少。
3.1 从原生 API 说起
浏览器给我们提供了 addEventListener 和 removeEventListener。但你知道吗?这两个方法其实没那么简单。
先看一个基础实现:
class EventTarget {
constructor() {
this._listeners = new Map();
}
addEventListener(type, callback, options) {
if (!this._listeners.has(type)) {
this._listeners.set(type, []);
}
const listener = {
callback: callback,
options: options || {}
};
this._listeners.get(type).push(listener);
}
removeEventListener(type, callback) {
const listeners = this._listeners.get(type);
if (!listeners) return;
const index = listeners.findIndex(l => l.callback === callback);
if (index !== -1) {
listeners.splice(index, 1);
}
}
dispatchEvent(event) {
const listeners = this._listeners.get(event.type);
if (!listeners) return true;
// 注意:这里要复制一份,防止遍历时被修改
const copy = [...listeners];
for (const listener of copy) {
listener.callback.call(this, event);
}
return !event.defaultPrevented;
}
}
这段代码看起来挺简单,对吧?但实际项目中,问题往往出在细节上。
用 findIndex 比较回调函数时,如果同一个函数被注册了多次,只会移除第一个。更坑的是,如果你用了匿名函数,那就永远别想移除了——因为每次都是新引用。
3.2 options 参数的那些门道
原生 addEventListener 的第三个参数,其实是个对象。我习惯把它拆成三个独立选项:
| 选项 | 类型 | 说明 | 我常用的场景 |
|---|---|---|---|
capture |
boolean | 是否在捕获阶段触发 | 事件代理、阻止冒泡 |
once |
boolean | 是否只执行一次 | 初始化、一次性操作 |
passive |
boolean | 是否不调用 preventDefault | 滚动优化、触摸事件 |
你想想看,这三个选项组合起来,能玩出多少花样?
// 只执行一次的监听器
element.addEventListener('click', handler, { once: true });
// 被动监听器,提升滚动性能
element.addEventListener('touchstart', handler, { passive: true });
// 捕获阶段的监听器
element.addEventListener('click', handler, { capture: true });
我的建议:在移动端处理 touch 事件时,一定要加上 passive: true。我之前有个项目,页面滚动卡得不行,查了半天才发现是 touch 事件监听器没加 passive 选项。加上之后,流畅度直接翻倍。
3.3 监听器管理器的设计
光有注册和移除还不够。真实项目里,监听器数量一多,管理就成了大问题。我个人习惯设计一个监听器管理器,专门负责这件事。
先看整体架构:
这个架构其实不复杂。说白了,就是用 Map 来管理事件类型,每个类型对应一个监听器数组。注册、移除、调度三个模块各管各的事。
3.4 完整实现
下面是我在实际项目中用过的监听器管理器实现:
class ListenerManager {
constructor() {
this._listeners = new Map();
this._idCounter = 0;
}
// 注册监听器
add(type, callback, options = {}) {
if (!this._listeners.has(type)) {
this._listeners.set(type, []);
}
const id = ++this._idCounter;
const listener = {
id,
callback,
options,
isRemoved: false
};
this._listeners.get(type).push(listener);
return id; // 返回 ID,方便后续移除
}
// 注册一次性监听器
once(type, callback, options = {}) {
return this.add(type, callback, { ...options, once: true });
}
// 移除指定监听器
remove(type, callbackOrId) {
const listeners = this._listeners.get(type);
if (!listeners) return false;
const index = listeners.findIndex(l => {
if (typeof callbackOrId === 'number') {
return l.id === callbackOrId;
}
return l.callback === callbackOrId;
});
if (index !== -1) {
listeners[index].isRemoved = true;
listeners.splice(index, 1);
return true;
}
return false;
}
// 清除某类型的所有监听器
clear(type) {
if (type) {
this._listeners.delete(type);
} else {
this._listeners.clear();
}
}
// 调度事件
dispatch(type, event) {
const listeners = this._listeners.get(type);
if (!listeners || listeners.length === 0) return;
// 复制一份,防止遍历时被修改
const copy = [...listeners];
for (const listener of copy) {
if (listener.isRemoved) continue;
listener.callback(event);
// 如果是一次性监听器,自动移除
if (listener.options.once) {
this.remove(type, listener.id);
}
}
}
// 获取某类型的监听器数量
count(type) {
const listeners = this._listeners.get(type);
return listeners ? listeners.length : 0;
}
}
设计要点:
- 每个监听器分配唯一 ID,支持通过 ID 或回调函数移除
- 用
isRemoved标记防止重复移除 - 调度时复制数组,避免遍历过程中被修改
- 一次性监听器在调度后自动清理
3.5 性能优化:节流与防抖
在实际项目中,有些事件触发频率特别高,比如 scroll、resize、mousemove。如果不做处理,监听器会被频繁调用,导致性能问题。
我习惯在管理器层面提供节流和防抖的支持:
class ThrottledListenerManager extends ListenerManager {
addThrottled(type, callback, delay = 100) {
let lastCall = 0;
const throttled = (event) => {
const now = Date.now();
if (now - lastCall >= delay) {
lastCall = now;
callback(event);
}
};
return this.add(type, throttled);
}
addDebounced(type, callback, delay = 100) {
let timer = null;
const debounced = (event) => {
clearTimeout(timer);
timer = setTimeout(() => {
callback(event);
}, delay);
};
return this.add(type, debounced);
}
}
我的经验:滚动事件的节流间隔,我一般设 100ms。太短了没效果,太长了感觉卡顿。resize 事件用防抖,300ms 比较合适——等用户拖拽完了再执行。
3.6 内存泄漏的防范
这是个大问题。我见过太多项目因为监听器没清理,导致内存泄漏。说白了,就是 DOM 元素都被删了,但监听器还挂在上面。
我总结了几条铁律:
- 组件销毁时,必须清理所有监听器——用
clear()方法 - 避免使用匿名函数——除非你确定只用一次
- 使用 WeakRef 或 FinalizationRegistry——在目标对象被回收时自动清理
class SafeListenerManager {
constructor() {
this._listeners = new Map();
this._registry = new FinalizationRegistry((heldValue) => {
// 当目标对象被回收时,自动清理相关监听器
this._listeners.delete(heldValue);
});
}
addTo(target, type, callback, options) {
if (!this._listeners.has(target)) {
this._listeners.set(target, new Map());
// 注册清理回调
this._registry.register(target, target);
}
const targetListeners = this._listeners.get(target);
if (!targetListeners.has(type)) {
targetListeners.set(type, []);
}
targetListeners.get(type).push({ callback, options });
}
}
注意:FinalizationRegistry 的触发时机是不确定的。不要完全依赖它来清理资源。我习惯把它作为兜底方案,主动清理才是正道。
3.7 实际项目中的最佳实践
说了这么多,最后给几个实际建议:
- 统一管理入口——所有监听器都通过管理器注册,不要直接调用原生 API
- 命名规范——回调函数用有意义的名称,方便调试
- 日志记录——在开发环境下记录监听器的注册和移除日志
- 定期检查——用
count()方法检查是否有泄漏
我曾经在一个大型项目中,用这套方案把监听器数量从 2000+ 降到了 300 以内。页面性能提升很明显,从原来的 20fps 直接飙到 60fps。你想想看,这差距有多大?
监听器管理这件事,看似简单,但做不好就是灾难。希望这一章的内容能帮你少走些弯路。