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 监听器管理器的设计

光有注册和移除还不够。真实项目里,监听器数量一多,管理就成了大问题。我个人习惯设计一个监听器管理器,专门负责这件事。

先看整体架构:

监听器管理器架构 ListenerManager 注册模块 add / once 移除模块 remove / clear 调度模块 dispatch / queue 监听器存储 Map<事件类型, Listener[]> 核心思路:用 Map 管理事件类型,每个类型对应一个监听器数组 注册、移除、调度三个模块各司其职,互不干扰

这个架构其实不复杂。说白了,就是用 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 实际项目中的最佳实践

说了这么多,最后给几个实际建议:

  1. 统一管理入口——所有监听器都通过管理器注册,不要直接调用原生 API
  2. 命名规范——回调函数用有意义的名称,方便调试
  3. 日志记录——在开发环境下记录监听器的注册和移除日志
  4. 定期检查——用 count() 方法检查是否有泄漏

我曾经在一个大型项目中,用这套方案把监听器数量从 2000+ 降到了 300 以内。页面性能提升很明显,从原来的 20fps 直接飙到 60fps。你想想看,这差距有多大?

监听器管理这件事,看似简单,但做不好就是灾难。希望这一章的内容能帮你少走些弯路。