Skip to content

事件监听器:灵活的事件处理机制

从观察者模式说起

想象你订阅了一个邮件列表。每当有新消息发布,所有订阅者都会收到通知。你可以随时订阅,也可以随时取消订阅。这就是观察者模式(Observer Pattern)的基本思想,而 JavaScript 的事件监听器正是这一模式的具体实现。

事件监听器(Event Listener)不同于简单的事件处理器,它提供了一套完整的机制来"监听"事件的发生。你可以为同一个事件注册多个监听器,可以精确控制监听器的行为,可以在不需要时优雅地移除它们。这种灵活性使得事件监听器成为现代 Web 开发的标准做法。

addEventListener 的深入理解

addEventListener 方法是 DOM 提供的标准接口,用于给元素添加事件监听器:

javascript
element.addEventListener(type, listener, options);

三个参数分别是:

  1. type:事件类型字符串(如 'click''input''scroll'
  2. listener:监听器函数,事件触发时执行
  3. options:可选参数,用于配置监听器行为

基础用法

最简单的用法只需要前两个参数:

javascript
const button = document.querySelector("#myButton");

function handleClick(event) {
  console.log("按钮被点击了");
  console.log("点击位置:", event.clientX, event.clientY);
}

button.addEventListener("click", handleClick);

这段代码告诉浏览器:"当 button 被点击时,请调用 handleClick 函数。" 监听器会一直保持活动状态,直到被明确移除。

监听器函数的特点

监听器函数会接收一个事件对象作为参数,这个对象包含了事件的所有信息:

javascript
button.addEventListener("click", function (event) {
  // event 对象包含丰富的信息
  console.log("事件类型:", event.type);
  console.log("触发元素:", event.target);
  console.log("当前元素:", event.currentTarget);
  console.log("事件时间戳:", event.timeStamp);
  console.log("是否冒泡:", event.bubbles);
});

在普通函数中,this 关键字会指向当前元素(即 event.currentTarget):

javascript
button.addEventListener("click", function (event) {
  console.log(this === button); // true
  console.log(this === event.currentTarget); // true

  this.style.backgroundColor = "blue"; // 修改按钮背景色
});

但在箭头函数中,this 继承外层作用域:

javascript
const component = {
  color: "red",

  init() {
    button.addEventListener("click", (event) => {
      console.log(this.color); // 'red',this 指向 component
      console.log(event.currentTarget); // button 元素
    });
  },
};

多个监听器的执行顺序

同一个元素的同一个事件可以添加多个监听器,它们会按照添加的顺序依次执行:

javascript
button.addEventListener("click", () => {
  console.log("第一个监听器");
});

button.addEventListener("click", () => {
  console.log("第二个监听器");
});

button.addEventListener("click", () => {
  console.log("第三个监听器");
});

// 点击按钮输出:
// 第一个监听器
// 第二个监听器
// 第三个监听器

这种机制让不同的代码模块可以独立地添加自己的监听器,而不用担心覆盖别人的代码:

javascript
// 分析模块添加的监听器
button.addEventListener("click", () => {
  trackButtonClick("submit-button");
});

// 表单验证模块添加的监听器
button.addEventListener("click", () => {
  validateFormBeforeSubmit();
});

// UI 反馈模块添加的监听器
button.addEventListener("click", () => {
  showLoadingSpinner();
});

// 三个监听器互不干扰,按顺序执行

防止重复添加同一监听器

如果多次添加同一个函数引用,只会注册一次:

javascript
function handleClick() {
  console.log("点击");
}

button.addEventListener("click", handleClick);
button.addEventListener("click", handleClick); // 不会重复添加
button.addEventListener("click", handleClick); // 不会重复添加

// 点击按钮只输出一次:点击

但如果每次都传入新的匿名函数,就会重复添加:

javascript
button.addEventListener("click", () => console.log("点击"));
button.addEventListener("click", () => console.log("点击")); // 会添加
button.addEventListener("click", () => console.log("点击")); // 会添加

// 点击按钮输出三次:点击

这是因为每个箭头函数都是新的对象,即使代码看起来一样,它们在内存中是不同的引用。

options 参数详解

第三个参数 options 可以是布尔值或对象,用于精细控制监听器的行为。

capture 选项:控制事件流阶段

javascript
// 布尔值形式(传统方式)
element.addEventListener("click", handler, true); // 捕获阶段
element.addEventListener("click", handler, false); // 冒泡阶段(默认)

// 对象形式(推荐)
element.addEventListener("click", handler, { capture: true }); // 捕获阶段
element.addEventListener("click", handler, { capture: false }); // 冒泡阶段

捕获阶段的监听器会在事件向下传播时执行,冒泡阶段的监听器会在事件向上传播时执行:

html
<div id="outer">
  <div id="inner">
    <button id="btn">点击</button>
  </div>
</div>
javascript
const outer = document.getElementById("outer");
const inner = document.getElementById("inner");
const btn = document.getElementById("btn");

// 捕获阶段:从外到内
outer.addEventListener("click", () => console.log("outer 捕获"), {
  capture: true,
});
inner.addEventListener("click", () => console.log("inner 捕获"), {
  capture: true,
});
btn.addEventListener("click", () => console.log("btn 捕获"), { capture: true });

// 冒泡阶段:从内到外
outer.addEventListener("click", () => console.log("outer 冒泡"));
inner.addEventListener("click", () => console.log("inner 冒泡"));
btn.addEventListener("click", () => console.log("btn 冒泡"));

// 点击按钮输出:
// outer 捕获
// inner 捕获
// btn 捕获
// btn 冒泡
// inner 冒泡
// outer 冒泡

once 选项:一次性监听器

设置 once: true 后,监听器在执行一次后会自动移除:

javascript
button.addEventListener(
  "click",
  () => {
    console.log("这个消息只会出现一次");
    console.log("即使你再次点击,也不会触发");
  },
  { once: true }
);

// 第一次点击:输出消息
// 第二次点击:无反应
// 第三次点击:无反应

这在处理一次性操作时非常有用:

javascript
// 欢迎提示,只显示一次
const welcomeModal = document.querySelector("#welcome");
const closeButton = welcomeModal.querySelector(".close");

closeButton.addEventListener(
  "click",
  () => {
    welcomeModal.style.display = "none";
    localStorage.setItem("welcomeSeen", "true");
  },
  { once: true }
);

// 首次访问优惠,点击后永久失效
const couponButton = document.querySelector("#claim-coupon");

couponButton.addEventListener(
  "click",
  async () => {
    await claimCoupon();
    couponButton.disabled = true;
    couponButton.textContent = "已领取";
  },
  { once: true }
);

passive 选项:性能优化

passive: true 表示监听器永远不会调用 preventDefault(),这让浏览器可以立即执行默认行为,而不必等待监听器执行完成:

javascript
document.addEventListener(
  "scroll",
  (event) => {
    // 只读取滚动位置,不阻止默认滚动
    const scrollY = window.scrollY;
    updateScrollIndicator(scrollY);
  },
  { passive: true }
);

这对滚动和触摸事件特别重要。没有 passive 选项时,浏览器必须等待监听器执行完才能滚动页面,可能导致卡顿。设置 passive: true 后,浏览器可以立即开始滚动,监听器的执行不会阻塞滚动。

javascript
// ❌ 可能导致滚动卡顿
document.addEventListener("touchstart", (event) => {
  doSomethingExpensive();
});

// ✅ 不阻塞滚动,流畅
document.addEventListener(
  "touchstart",
  (event) => {
    doSomethingExpensive();
  },
  { passive: true }
);

如果在 passive: true 的监听器中调用 preventDefault(),浏览器会忽略它并在控制台发出警告:

javascript
document.addEventListener(
  "scroll",
  (event) => {
    event.preventDefault(); // ⚠️ 被忽略,控制台会有警告
  },
  { passive: true }
);

signal 选项:使用 AbortController 管理监听器

signal 选项接受一个 AbortSignal 对象,可以一次性移除多个关联的监听器:

javascript
const controller = new AbortController();
const signal = controller.signal;

// 添加多个监听器,都关联到同一个 signal
button.addEventListener("click", handleClick, { signal });
button.addEventListener("mouseenter", handleMouseEnter, { signal });
button.addEventListener("mouseleave", handleMouseLeave, { signal });

window.addEventListener("resize", handleResize, { signal });
document.addEventListener("keydown", handleKeyDown, { signal });

// 一次性移除所有监听器
controller.abort();

这在组件化开发中特别有用,可以在组件销毁时清理所有相关的监听器:

javascript
class SearchWidget {
  constructor(element) {
    this.element = element;
    this.input = element.querySelector("input");
    this.results = element.querySelector(".results");
    this.controller = new AbortController();

    this.init();
  }

  init() {
    const signal = this.controller.signal;

    // 所有监听器都关联到同一个 signal
    this.input.addEventListener("input", this.handleInput.bind(this), {
      signal,
    });
    this.input.addEventListener("focus", this.handleFocus.bind(this), {
      signal,
    });
    this.input.addEventListener("blur", this.handleBlur.bind(this), { signal });
    this.results.addEventListener("click", this.handleSelect.bind(this), {
      signal,
    });

    document.addEventListener("keydown", this.handleKeyDown.bind(this), {
      signal,
    });
    window.addEventListener("resize", this.handleResize.bind(this), { signal });
  }

  handleInput(event) {
    const query = event.target.value;
    this.search(query);
  }

  handleFocus() {
    this.results.style.display = "block";
  }

  handleBlur() {
    setTimeout(() => {
      this.results.style.display = "none";
    }, 200);
  }

  handleSelect(event) {
    if (event.target.matches(".result-item")) {
      this.selectResult(event.target);
    }
  }

  handleKeyDown(event) {
    if (event.key === "Escape") {
      this.close();
    }
  }

  handleResize() {
    this.updatePosition();
  }

  destroy() {
    // 一行代码移除所有监听器
    this.controller.abort();
    this.element.remove();
  }
}

// 使用
const widget = new SearchWidget(document.querySelector(".search"));

// 销毁组件
widget.destroy(); // 自动清理所有事件监听器

组合多个选项

可以同时使用多个选项:

javascript
element.addEventListener("scroll", handleScroll, {
  capture: false, // 冒泡阶段
  once: false, // 可以多次执行
  passive: true, // 不调用 preventDefault
  signal: controller.signal, // 可通过 signal 移除
});

element.addEventListener("click", handleClick, {
  capture: true, // 捕获阶段
  once: true, // 只执行一次
  signal: signal, // 可通过 signal 移除
});

removeEventListener 移除监听器

使用 removeEventListener 方法移除之前添加的监听器:

javascript
function handleClick(event) {
  console.log("点击");
}

// 添加监听器
button.addEventListener("click", handleClick);

// 移除监听器
button.removeEventListener("click", handleClick);

移除的关键要点

移除监听器时,所有参数必须与添加时完全一致:

javascript
// ✅ 可以移除
function handler() {
  console.log("click");
}
button.addEventListener("click", handler);
button.removeEventListener("click", handler);

// ❌ 无法移除:函数是新的引用
button.addEventListener("click", () => console.log("click"));
button.removeEventListener("click", () => console.log("click"));

// ❌ 无法移除:capture 选项不一致
button.addEventListener("click", handler, { capture: true });
button.removeEventListener("click", handler, { capture: false });

// ✅ 可以移除:capture 选项一致
button.addEventListener("click", handler, { capture: true });
button.removeEventListener("click", handler, { capture: true });

只有 capture 选项需要一致,其他选项(oncepassivesignal)不影响移除:

javascript
// ✅ 可以移除,其他选项不需要匹配
button.addEventListener("click", handler, {
  capture: false,
  once: true,
  passive: true,
});

button.removeEventListener("click", handler, { capture: false });
// 或者简写
button.removeEventListener("click", handler);

在监听器内部移除自己

javascript
function handleClick(event) {
  console.log("这是最后一次点击响应");

  // 移除自己
  event.currentTarget.removeEventListener("click", handleClick);
}

button.addEventListener("click", handleClick);

// 效果等同于 { once: true }

条件性移除

javascript
let clickCount = 0;

function handleClick(event) {
  clickCount++;
  console.log(`第 ${clickCount} 次点击`);

  if (clickCount >= 5) {
    console.log("已达到最大点击次数,移除监听器");
    event.currentTarget.removeEventListener("click", handleClick);
  }
}

button.addEventListener("click", handleClick);

监听器的生命周期管理

在复杂应用中,正确管理监听器的生命周期非常重要,以避免内存泄漏和意外行为。

组件模式

javascript
class Dropdown {
  constructor(element) {
    this.element = element;
    this.button = element.querySelector(".dropdown-button");
    this.menu = element.querySelector(".dropdown-menu");
    this.isOpen = false;

    this.handleButtonClick = this.handleButtonClick.bind(this);
    this.handleDocumentClick = this.handleDocumentClick.bind(this);

    this.init();
  }

  init() {
    // 组件初始化时添加监听器
    this.button.addEventListener("click", this.handleButtonClick);
  }

  handleButtonClick(event) {
    event.stopPropagation();
    this.toggle();
  }

  toggle() {
    this.isOpen = !this.isOpen;
    this.menu.style.display = this.isOpen ? "block" : "none";

    if (this.isOpen) {
      //打开时监听文档点击
      document.addEventListener("click", this.handleDocumentClick);
    } else {
      // 关闭时移除监听
      document.removeEventListener("click", this.handleDocumentClick);
    }
  }

  handleDocumentClick() {
    // 点击其他地方关闭下拉菜单
    this.isOpen = false;
    this.menu.style.display = "none";
    document.removeEventListener("click", this.handleDocumentClick);
  }

  destroy() {
    // 组件销毁时清理所有监听器
    this.button.removeEventListener("click", this.handleButtonClick);
    document.removeEventListener("click", this.handleDocumentClick);
    this.element.remove();
  }
}

// 使用
const dropdown = new Dropdown(document.querySelector(".dropdown"));

// 销毁组件
dropdown.destroy();

使用 AbortController 简化生命周期

javascript
class Modal {
  constructor(element) {
    this.element = element;
    this.closeButton = element.querySelector(".close");
    this.overlay = element.querySelector(".overlay");
    this.controller = new AbortController();

    this.init();
  }

  init() {
    const signal = this.controller.signal;

    // 所有监听器都使用同一个 signal
    this.closeButton.addEventListener("click", () => this.close(), { signal });
    this.overlay.addEventListener("click", () => this.close(), { signal });

    document.addEventListener(
      "keydown",
      (event) => {
        if (event.key === "Escape") {
          this.close();
        }
      },
      { signal }
    );
  }

  open() {
    this.element.style.display = "flex";
    document.body.style.overflow = "hidden";
  }

  close() {
    this.element.style.display = "none";
    document.body.style.overflow = "";
  }

  destroy() {
    // 一行代码移除所有监听器
    this.controller.abort();
    this.element.remove();
  }
}

实际应用场景

防抖搜索

javascript
class SearchInput {
  constructor(input, onSearch) {
    this.input = input;
    this.onSearch = onSearch;
    this.debounceTimer = null;
    this.controller = new AbortController();

    this.init();
  }

  init() {
    this.input.addEventListener(
      "input",
      (event) => {
        clearTimeout(this.debounceTimer);

        this.debounceTimer = setTimeout(() => {
          const query = event.target.value.trim();
          if (query) {
            this.onSearch(query);
          }
        }, 300);
      },
      { signal: this.controller.signal }
    );
  }

  destroy() {
    clearTimeout(this.debounceTimer);
    this.controller.abort();
  }
}

// 使用
const search = new SearchInput(document.querySelector("#search"), (query) => {
  console.log("搜索:", query);
  performSearch(query);
});

拖拽功能

javascript
class Draggable {
  constructor(element) {
    this.element = element;
    this.isDragging = false;
    this.startX = 0;
    this.startY = 0;
    this.offsetX = 0;
    this.offsetY = 0;

    this.handleMouseDown = this.handleMouseDown.bind(this);
    this.handleMouseMove = this.handleMouseMove.bind(this);
    this.handleMouseUp = this.handleMouseUp.bind(this);

    this.init();
  }

  init() {
    this.element.addEventListener("mousedown", this.handleMouseDown);
  }

  handleMouseDown(event) {
    this.isDragging = true;
    this.startX = event.clientX - this.offsetX;
    this.startY = event.clientY - this.offsetY;

    // 开始拖拽时添加文档级监听器
    document.addEventListener("mousemove", this.handleMouseMove);
    document.addEventListener("mouseup", this.handleMouseUp);

    this.element.style.cursor = "grabbing";
  }

  handleMouseMove(event) {
    if (!this.isDragging) return;

    this.offsetX = event.clientX - this.startX;
    this.offsetY = event.clientY - this.startY;

    this.element.style.transform = `translate(${this.offsetX}px, ${this.offsetY}px)`;
  }

  handleMouseUp() {
    this.isDragging = false;

    // 停止拖拽时移除文档级监听器
    document.removeEventListener("mousemove", this.handleMouseMove);
    document.removeEventListener("mouseup", this.handleMouseUp);

    this.element.style.cursor = "grab";
  }

  destroy() {
    this.element.removeEventListener("mousedown", this.handleMouseDown);
    document.removeEventListener("mousemove", this.handleMouseMove);
    document.removeEventListener("mouseup", this.handleMouseUp);
  }
}

// 使用
const draggable = new Draggable(document.querySelector(".draggable"));

滚动加载更多

javascript
class InfiniteScroll {
  constructor(container, onLoadMore) {
    this.container = container;
    this.onLoadMore = onLoadMore;
    this.isLoading = false;
    this.hasMore = true;
    this.controller = new AbortController();

    this.handleScroll = this.handleScroll.bind(this);
    this.init();
  }

  init() {
    window.addEventListener("scroll", this.handleScroll, {
      passive: true,
      signal: this.controller.signal,
    });
  }

  handleScroll() {
    if (this.isLoading || !this.hasMore) return;

    const scrollTop = window.scrollY;
    const windowHeight = window.innerHeight;
    const documentHeight = document.documentElement.scrollHeight;

    // 距离底部不到 200px 时加载
    if (scrollTop + windowHeight >= documentHeight - 200) {
      this.loadMore();
    }
  }

  async loadMore() {
    this.isLoading = true;

    try {
      const items = await this.onLoadMore();

      if (items.length === 0) {
        this.hasMore = false;
        this.controller.abort(); // 没有更多内容,移除监听器
      }
    } catch (error) {
      console.error("加载失败:", error);
    } finally {
      this.isLoading = false;
    }
  }

  destroy() {
    this.controller.abort();
  }
}

// 使用
const infiniteScroll = new InfiniteScroll(
  document.querySelector(".content"),
  async () => {
    const response = await fetch("/api/items?page=" + currentPage++);
    const items = await response.json();
    renderItems(items);
    return items;
  }
);

常见陷阱与最佳实践

避免在循环中创建监听器

javascript
// ❌ 每次循环都创建新的监听器
const buttons = document.querySelectorAll(".item-button");
for (let i = 0; i < buttons.length; i++) {
  buttons[i].addEventListener("click", function () {
    alert("点击了第 " + i + " 个按钮");
  });
}

// ✅ 使用事件委托,只创建一个监听器
const container = document.querySelector(".items-container");
container.addEventListener("click", function (event) {
  if (event.target.matches(".item-button")) {
    const buttons = [...container.querySelectorAll(".item-button")];
    const index = buttons.indexOf(event.target);
    alert("点击了第 " + index + " 个按钮");
  }
});

正确处理异步操作

javascript
button.addEventListener("click", async function (event) {
  // 立即禁用按钮,防止重复点击
  event.currentTarget.disabled = true;

  try {
    const result = await fetch("/api/submit", {
      method: "POST",
      body: JSON.stringify(formData),
    });

    if (result.ok) {
      showSuccess("提交成功!");
    } else {
      showError("提交失败,请重试");
      event.currentTarget.disabled = false; // 失败时重新启用
    }
  } catch (error) {
    showError("网络错误");
    event.currentTarget.disabled = false;
  }
});

及时清理 DOM 引用

javascript
// ❌ 可能导致内存泄漏
function setupWidget() {
  const element = document.createElement("div");
  element.addEventListener("click", function (event) {
    // 闭包引用了 element
    console.log(element);
  });
  document.body.appendChild(element);

  // 移除元素但监听器仍然存在
  setTimeout(() => {
    element.remove();
  }, 5000);
}

// ✅ 正确清理
function setupWidget() {
  const element = document.createElement("div");
  const controller = new AbortController();

  element.addEventListener(
    "click",
    function (event) {
      console.log(element);
    },
    { signal: controller.signal }
  );

  document.body.appendChild(element);

  return {
    element,
    destroy() {
      controller.abort(); // 清理监听器
      element.remove(); // 移除元素
    },
  };
}

const widget = setupWidget();
setTimeout(() => widget.destroy(), 5000);

总结

事件监听器是现代 Web 开发中处理事件的标准方式。通过本章学习,你应该掌握:

  1. addEventListener 的灵活性:可以添加多个监听器,精确控制行为
  2. 选项配置captureoncepassivesignal 的作用和使用场景
  3. 生命周期管理:正确添加和移除监听器,避免内存泄漏
  4. AbortController 模式:优雅地管理多个监听器
  5. 实际应用:防抖、拖拽、无限滚动等常见场景的实现