3、事件监听器:addEventListener与removeEventListener的底层实现

事件监听器,说白了就是让 DOM 元素「听到」用户动作的那套机制。你写 el.addEventListener('click', handler),浏览器底层到底干了什么?我当年刚入行时也以为只是简单存了个回调函数,直到有一次排查内存泄漏,才被迫把源码翻了个底朝天。

今天我们就来拆解这两个 API 的底层逻辑。不扯虚的,直接看引擎层怎么干活。

3.1 从调用栈看 addEventListener 的完整链路

当你调用 addEventListener,浏览器内部至少走了四步:

  1. 参数校验——检查 type 是不是合法字符串,listener 是不是函数或对象。
  2. 查找目标元素的事件表——每个 DOM 节点内部维护了一个事件监听器列表。
  3. 去重检查——如果同一类型、同一回调、同一 capture 标志已经注册过,直接跳过。
  4. 插入链表——把新监听器挂到该类型的事件链表尾部。

核心数据结构:每个 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 到事件分发的完整链路画了出来。你可以对照着看,每一步对应什么数据结构。

事件监听器底层架构图 addEventListener → 事件分发 底层链路 用户调用 addEventListener 参数校验 + 去重检查 插入事件链表尾部 [[eventListeners]] 映射表(Map) type: 'click' type: 'scroll' type: 'touch' type: 'resize' 'click' 类型对应的双向链表 handlerA (capture:false) handlerB (once:true) handlerC (passive:true) handlerD 事件触发时,浏览器先克隆链表快照,再遍历执行,避免修改影响分发

3.6 内存泄漏与 GC 回收

底层实现里还有一个容易被忽略的点:链表节点对回调函数的引用。如果你忘记 removeEventListener,这个引用会一直存在,导致回调函数里捕获的变量无法被 GC 回收。

我遇到过最典型的场景:单页应用里反复创建和销毁组件,每次创建都 addEventListener,销毁时忘了 remove。结果事件链表越来越长,回调里还引用了已经销毁的 DOM 节点,内存只增不减。

我的习惯:在组件销毁的生命周期里,统一调用 removeEventListener。如果回调是绑定 this 的,记得保存引用,否则移除时找不到同一个函数对象。

3.7 小结

addEventListener 和 removeEventListener 的底层,本质上就是一张映射表加双向链表。添加是尾部插入,删除是链表摘除,分发是快照遍历。理解了这三个操作,你就能解释很多奇怪的现象:

  • 为什么匿名函数删不掉?——引用不同。
  • 为什么 once 只执行一次?——执行后自动摘除。
  • 为什么 passive 能优化滚动?——浏览器提前知道你不会 preventDefault。

嗯,这些底层细节平时写业务可能用不上,但一旦遇到性能问题或内存泄漏,它们就是你排查的底气。