Skip to content

自定义事件:打造属于你的消息系统

浏览器事件的局限

浏览器为我们提供了丰富的内置事件:clickinputsubmitscroll 等等。这些事件涵盖了用户与页面交互的大部分场景。但在实际开发中,我们常常需要处理更复杂的业务逻辑。

比如,当用户完成了一个多步骤的表单流程,我们希望通知其他组件更新用户的积分显示;当购物车中的商品数量发生变化时,我们需要同时更新导航栏的购物车图标和侧边栏的总价。这些场景都涉及到不同组件之间的通信,而浏览器的内置事件无法直接满足这些需求。

这时候,自定义事件(Custom Events)就派上用场了。它让你能够创建自己的事件类型,定义事件携带的数据,构建起灵活的组件间通信机制。

什么是自定义事件

自定义事件是你自己定义的事件类型,不是浏览器预设的。它们使用与内置事件相同的机制进行触发和监听,具有同样的事件流(捕获和冒泡),可以携带自定义的数据。

自定义事件就像是你为应用程序创建的一套"暗号系统"。不同的模块之间通过这些暗号进行交流,当一个模块发出特定的暗号时,其他监听这个暗号的模块就会做出相应的反应。

javascript
// 创建一个自定义事件
const event = new CustomEvent("userLoggedIn", {
  detail: {
    username: "Sarah",
    userId: 12345,
    timestamp: Date.now(),
  },
});

// 监听这个自定义事件
document.addEventListener("userLoggedIn", (event) => {
  console.log(`User ${event.detail.username} logged in`);
  console.log("User ID:", event.detail.userId);
});

// 触发这个事件
document.dispatchEvent(event);

创建自定义事件

JavaScript 提供了 CustomEvent 构造函数来创建自定义事件。它接收两个参数:事件名称和配置对象。

基本语法

javascript
const event = new CustomEvent(eventType, eventOptions);
  • eventType:事件的名称(字符串),比如 'dataLoaded''cartUpdated''formValidated'
  • eventOptions:可选的配置对象,包含以下属性:
    • detail:任意数据,用于传递事件的额外信息
    • bubbles:布尔值,指定事件是否冒泡(默认 false
    • cancelable:布尔值,指定事件是否可以被取消(默认 false
    • composed:布尔值,指定事件是否会穿过 Shadow DOM 边界(默认 false

简单示例

javascript
// 创建一个不冒泡、不携带数据的事件
const simpleEvent = new CustomEvent("taskCompleted");

// 创建一个冒泡的、携带数据的事件
const dataEvent = new CustomEvent("productAdded", {
  bubbles: true,
  detail: {
    productId: "abc123",
    productName: "Wireless Mouse",
    quantity: 2,
    price: 29.99,
  },
});

// 创建一个可取消的事件
const cancelableEvent = new CustomEvent("beforeSave", {
  bubbles: true,
  cancelable: true,
  detail: {
    data: {
      /* ... */
    },
  },
});

触发自定义事件

创建事件后,需要使用 dispatchEvent() 方法来触发它。这个方法可以在任何 DOM 元素或 windowdocument 对象上调用。

javascript
// 在 document 上触发
document.dispatchEvent(event);

// 在特定元素上触发
const button = document.getElementById("submit-btn");
button.dispatchEvent(event);

// 在 window 上触发
window.dispatchEvent(event);

dispatchEvent() 会返回一个布尔值:

  • 如果事件是可取消的,且有监听器调用了 event.preventDefault(),返回 false
  • 否则返回 true
javascript
const event = new CustomEvent("beforeDelete", {
  cancelable: true,
  detail: { itemId: 123 },
});

element.addEventListener("beforeDelete", (e) => {
  if (!confirm("Are you sure?")) {
    e.preventDefault(); // 阻止默认行为
  }
});

const wasNotCancelled = element.dispatchEvent(event);
if (wasNotCancelled) {
  // 事件没有被取消,执行删除操作
  deleteItem(123);
} else {
  // 事件被取消,不执行删除
  console.log("Delete cancelled");
}

监听自定义事件

监听自定义事件与监听内置事件完全一样,使用 addEventListener() 方法。

javascript
// 监听自定义事件
element.addEventListener("customEventName", (event) => {
  // 访问事件携带的数据
  console.log(event.detail);
});

// 也可以使用第三个参数控制捕获/冒泡
element.addEventListener("customEventName", handler, {
  capture: true, // 在捕获阶段监听
  once: true, // 只监听一次
  passive: true, // 被动监听器
});

传递数据:detail 属性

detail 属性是自定义事件最强大的特性之一。它可以携带任何类型的数据:对象、数组、字符串、数字,甚至函数。

javascript
// 传递对象
const event1 = new CustomEvent("userUpdated", {
  detail: {
    userId: 123,
    changes: {
      email: "[email protected]",
      phone: "+1-555-0123",
    },
    timestamp: new Date(),
  },
});

// 传递数组
const event2 = new CustomEvent("itemsSelected", {
  detail: {
    items: [1, 5, 8, 12],
    selectAll: false,
  },
});

// 传递复杂数据结构
const event3 = new CustomEvent("chartDataReady", {
  detail: {
    datasets: [
      { label: "Revenue", data: [10, 20, 30, 40] },
      { label: "Expenses", data: [5, 15, 25, 35] },
    ],
    labels: ["Jan", "Feb", "Mar", "Apr"],
    options: {
      responsive: true,
      maintainAspectRatio: false,
    },
  },
});

// 监听并使用数据
document.addEventListener("chartDataReady", (event) => {
  const { datasets, labels, options } = event.detail;
  renderChart(datasets, labels, options);
});

实际应用场景

场景 1:购物车系统

在电商网站中,购物车的变化需要通知多个组件更新。

javascript
// 购物车模块
class ShoppingCart {
  constructor() {
    this.items = [];
  }

  addItem(product, quantity) {
    this.items.push({ product, quantity });

    // 触发自定义事件
    const event = new CustomEvent("cartUpdated", {
      bubbles: true,
      detail: {
        action: "add",
        product: product,
        quantity: quantity,
        totalItems: this.getTotalItems(),
        totalPrice: this.getTotalPrice(),
      },
    });

    document.dispatchEvent(event);
  }

  removeItem(productId) {
    const index = this.items.findIndex((item) => item.product.id === productId);
    if (index > -1) {
      const removed = this.items.splice(index, 1)[0];

      const event = new CustomEvent("cartUpdated", {
        bubbles: true,
        detail: {
          action: "remove",
          product: removed.product,
          totalItems: this.getTotalItems(),
          totalPrice: this.getTotalPrice(),
        },
      });

      document.dispatchEvent(event);
    }
  }

  getTotalItems() {
    return this.items.reduce((sum, item) => sum + item.quantity, 0);
  }

  getTotalPrice() {
    return this.items.reduce(
      (sum, item) => sum + item.product.price * item.quantity,
      0
    );
  }
}

// 导航栏购物车图标组件
class CartIcon {
  constructor() {
    this.badge = document.getElementById("cart-badge");
    this.setupEventListeners();
  }

  setupEventListeners() {
    document.addEventListener("cartUpdated", (event) => {
      this.updateBadge(event.detail.totalItems);
    });
  }

  updateBadge(count) {
    this.badge.textContent = count;
    this.badge.style.display = count > 0 ? "block" : "none";
  }
}

// 购物车侧边栏组件
class CartSidebar {
  constructor() {
    this.sidebar = document.getElementById("cart-sidebar");
    this.totalElement = document.getElementById("cart-total");
    this.setupEventListeners();
  }

  setupEventListeners() {
    document.addEventListener("cartUpdated", (event) => {
      const { action, product, totalPrice } = event.detail;

      if (action === "add") {
        this.addItemToList(product);
      } else if (action === "remove") {
        this.removeItemFromList(product.id);
      }

      this.updateTotal(totalPrice);
    });
  }

  addItemToList(product) {
    // 添加商品到侧边栏列表
    const item = document.createElement("div");
    item.className = "cart-item";
    item.dataset.productId = product.id;
    item.innerHTML = `
      <span>${product.name}</span>
      <span>$${product.price}</span>
    `;
    this.sidebar.appendChild(item);
  }

  removeItemFromList(productId) {
    const item = this.sidebar.querySelector(`[data-product-id="${productId}"]`);
    if (item) item.remove();
  }

  updateTotal(total) {
    this.totalElement.textContent = `$${total.toFixed(2)}`;
  }
}

// 使用
const cart = new ShoppingCart();
const cartIcon = new CartIcon();
const cartSidebar = new CartSidebar();

// 添加商品会自动更新所有相关组件
cart.addItem({ id: 1, name: "Laptop", price: 999.99 }, 1);
cart.addItem({ id: 2, name: "Mouse", price: 29.99 }, 2);

场景 2:表单验证系统

多步骤表单中,每一步完成时触发事件,协调整体流程。

javascript
// 表单步骤管理器
class FormWizard {
  constructor() {
    this.currentStep = 0;
    this.steps = document.querySelectorAll(".form-step");
    this.setupEventListeners();
  }

  setupEventListeners() {
    // 监听步骤完成事件
    document.addEventListener("stepCompleted", (event) => {
      const { stepNumber, isValid, data } = event.detail;

      if (isValid) {
        this.saveStepData(stepNumber, data);
        this.goToNextStep();
      }
    });

    // 监听整个表单完成事件
    document.addEventListener("formCompleted", (event) => {
      this.submitForm(event.detail.allData);
    });
  }

  goToNextStep() {
    this.steps[this.currentStep].classList.remove("active");
    this.currentStep++;

    if (this.currentStep < this.steps.length) {
      this.steps[this.currentStep].classList.add("active");
    } else {
      // 所有步骤完成
      const event = new CustomEvent("formCompleted", {
        detail: {
          allData: this.getAllData(),
        },
      });
      document.dispatchEvent(event);
    }
  }

  saveStepData(stepNumber, data) {
    sessionStorage.setItem(`step_${stepNumber}`, JSON.stringify(data));
  }

  getAllData() {
    const allData = {};
    for (let i = 0; i < this.steps.length; i++) {
      const stepData = sessionStorage.getItem(`step_${i}`);
      if (stepData) {
        Object.assign(allData, JSON.parse(stepData));
      }
    }
    return allData;
  }

  submitForm(data) {
    console.log("Submitting form:", data);
    // 发送到服务器
  }
}

// 单个表单步骤
class FormStep {
  constructor(element, stepNumber) {
    this.element = element;
    this.stepNumber = stepNumber;
    this.form = element.querySelector("form");
    this.setupEventListeners();
  }

  setupEventListeners() {
    this.form.addEventListener("submit", (e) => {
      e.preventDefault();
      this.validateAndComplete();
    });
  }

  validateAndComplete() {
    const formData = new FormData(this.form);
    const data = Object.fromEntries(formData);

    // 验证数据
    const isValid = this.validate(data);

    // 触发步骤完成事件
    const event = new CustomEvent("stepCompleted", {
      bubbles: true,
      detail: {
        stepNumber: this.stepNumber,
        isValid: isValid,
        data: data,
      },
    });

    this.element.dispatchEvent(event);
  }

  validate(data) {
    // 验证逻辑
    return true;
  }
}

// 进度指示器
class ProgressIndicator {
  constructor() {
    this.indicator = document.getElementById("progress-indicator");
    this.setupEventListeners();
  }

  setupEventListeners() {
    document.addEventListener("stepCompleted", (event) => {
      if (event.detail.isValid) {
        this.updateProgress(event.detail.stepNumber + 1);
      }
    });
  }

  updateProgress(completedSteps) {
    const totalSteps = 4;
    const percentage = (completedSteps / totalSteps) * 100;
    this.indicator.style.width = `${percentage}%`;
  }
}

场景 3:实时通知系统

javascript
// 通知管理器
class NotificationManager {
  constructor() {
    this.container = document.getElementById("notifications");
    this.setupEventListeners();
  }

  setupEventListeners() {
    // 监听各种通知事件
    document.addEventListener("notification:success", (e) => {
      this.show(e.detail.message, "success");
    });

    document.addEventListener("notification:error", (e) => {
      this.show(e.detail.message, "error");
    });

    document.addEventListener("notification:warning", (e) => {
      this.show(e.detail.message, "warning");
    });

    document.addEventListener("notification:info", (e) => {
      this.show(e.detail.message, "info");
    });
  }

  show(message, type) {
    const notification = document.createElement("div");
    notification.className = `notification notification-${type}`;
    notification.textContent = message;

    this.container.appendChild(notification);

    // 动画显示
    setTimeout(() => notification.classList.add("show"), 10);

    // 自动移除
    setTimeout(() => {
      notification.classList.remove("show");
      setTimeout(() => notification.remove(), 300);
    }, 3000);
  }
}

// 工具函数:触发通知
function showNotification(message, type = "info") {
  const event = new CustomEvent(`notification:${type}`, {
    detail: { message },
  });
  document.dispatchEvent(event);
}

// 在应用的任何地方使用
function saveUserProfile(data) {
  fetch("/api/profile", {
    method: "POST",
    body: JSON.stringify(data),
  })
    .then((response) => {
      if (response.ok) {
        showNotification("Profile saved successfully!", "success");
      } else {
        showNotification("Failed to save profile", "error");
      }
    })
    .catch(() => {
      showNotification("Network error occurred", "error");
    });
}

场景 4:插件系统

自定义事件可以用来构建插件系统,让第三方代码能够扩展应用功能。

javascript
// 主应用
class Application {
  constructor() {
    this.plugins = [];
  }

  registerPlugin(plugin) {
    this.plugins.push(plugin);
    plugin.init(this);
  }

  render() {
    // 渲染前触发事件
    const beforeRenderEvent = new CustomEvent("app:beforeRender", {
      cancelable: true,
      detail: { app: this },
    });

    const shouldContinue = document.dispatchEvent(beforeRenderEvent);
    if (!shouldContinue) return;

    // 执行渲染
    this.doRender();

    // 渲染后触发事件
    const afterRenderEvent = new CustomEvent("app:afterRender", {
      detail: { app: this },
    });
    document.dispatchEvent(afterRenderEvent);
  }

  doRender() {
    console.log("Rendering application...");
  }
}

// 分析插件
class AnalyticsPlugin {
  init(app) {
    // 监听应用事件
    document.addEventListener("app:afterRender", () => {
      this.trackPageView();
    });

    document.addEventListener("userLoggedIn", (e) => {
      this.trackUser(e.detail.userId);
    });
  }

  trackPageView() {
    console.log("Analytics: Page view tracked");
  }

  trackUser(userId) {
    console.log(`Analytics: User ${userId} tracked`);
  }
}

// 日志插件
class LoggerPlugin {
  init(app) {
    document.addEventListener("app:beforeRender", (e) => {
      console.log("[Logger] App is about to render");
    });

    document.addEventListener("app:afterRender", (e) => {
      console.log("[Logger] App has finished rendering");
    });
  }
}

// 使用
const app = new Application();
app.registerPlugin(new AnalyticsPlugin());
app.registerPlugin(new LoggerPlugin());
app.render();

自定义事件 vs 回调函数

你可能会问:为什么要使用自定义事件?直接传递回调函数不是更简单吗?

javascript
// 使用回调函数
function saveData(data, onSuccess, onError) {
  fetch("/api/save", { method: "POST", body: JSON.stringify(data) })
    .then(() => onSuccess())
    .catch(() => onError());
}

// 使用自定义事件
function saveData(data) {
  fetch("/api/save", { method: "POST", body: JSON.stringify(data) })
    .then(() => {
      document.dispatchEvent(
        new CustomEvent("dataSaved", { detail: { data } })
      );
    })
    .catch(() => {
      document.dispatchEvent(
        new CustomEvent("saveFailed", { detail: { data } })
      );
    });
}

自定义事件的优势在于:

1. 解耦性更强

使用回调函数时,调用者和被调用者之间有明确的依赖关系。而自定义事件完全解耦,触发事件的代码不需要知道谁在监听,监听者也不需要知道谁触发了事件。

javascript
// 回调:紧耦合
class DataService {
  constructor(ui, logger, analytics) {
    this.ui = ui;
    this.logger = logger;
    this.analytics = analytics;
  }

  save(data) {
    // 需要知道所有依赖
    this.ui.showLoading();
    this.logger.log("Saving...");
    this.analytics.track("save_started");

    // ...保存逻辑
  }
}

// 事件:完全解耦
class DataService {
  save(data) {
    document.dispatchEvent(
      new CustomEvent("saveStarted", { detail: { data } })
    );
    // 不需要知道谁在监听
    // ...保存逻辑
  }
}

// 各个模块独立监听
document.addEventListener("saveStarted", (e) => ui.showLoading());
document.addEventListener("saveStarted", (e) => logger.log("Saving..."));
document.addEventListener("saveStarted", (e) =>
  analytics.track("save_started")
);

2. 多个监听者

一个事件可以有多个监听者,而回调函数只能调用一次。

javascript
// 回调:只能通知一个对象
button.onclick = handleClick; // 只有一个处理函数

// 事件:可以有多个监听者
button.addEventListener("click", handleClick1);
button.addEventListener("click", handleClick2);
button.addEventListener("click", handleClick3);

3. 利用事件冒泡

自定义事件可以利用 DOM 的事件冒泡机制,实现事件委托。

javascript
// 在父容器上监听所有子元素的自定义事件
const container = document.getElementById("container");
container.addEventListener("itemSelected", (e) => {
  console.log("Item selected:", e.detail.itemId);
});

// 任何子元素都可以触发,事件会冒泡到容器
const item = document.getElementById("item-123");
item.dispatchEvent(
  new CustomEvent("itemSelected", {
    bubbles: true,
    detail: { itemId: 123 },
  })
);

##常见问题与最佳实践

1. 事件命名规范

使用清晰、描述性的名称,采用一致的命名约定。

javascript
// ✅ 好的命名
new CustomEvent("userLoggedIn");
new CustomEvent("cart:itemAdded");
new CustomEvent("form:validated");
new CustomEvent("data:loaded");

// ❌ 不好的命名
new CustomEvent("event1");
new CustomEvent("update");
new CustomEvent("done");

推荐使用命名空间(如 cart:itemAdded)来避免命名冲突。

2. 何时使用 bubbles

如果你希望事件能够被父元素捕获(比如使用事件委托),设置 bubbles: true

javascript
// 需要冒泡
const event = new CustomEvent("notification:show", {
  bubbles: true, // 允许事件冒泡
  detail: { message: "Hello" },
});

element.dispatchEvent(event);

3. 避免在 detail 中传递 DOM 元素

detail 中的数据应该是可序列化的,避免传递 DOM 元素或函数。

javascript
// ❌ 避免这样做
new CustomEvent("itemClicked", {
  detail: {
    element: document.getElementById("item"), // DOM 元素
    callback: () => console.log("clicked"), // 函数
  },
});

// ✅ 推荐做法
new CustomEvent("itemClicked", {
  detail: {
    elementId: "item", // 传递 ID
    itemData: {
      /* ... */
    }, // 传递数据
  },
});

4. 清理事件监听器

记得在不需要时移除事件监听器,避免内存泄漏。

javascript
class Component {
  constructor() {
    this.handleDataUpdate = this.handleDataUpdate.bind(this);
  }

  init() {
    document.addEventListener("dataUpdated", this.handleDataUpdate);
  }

  destroy() {
    // 组件销毁时移除监听器
    document.removeEventListener("dataUpdated", this.handleDataUpdate);
  }

  handleDataUpdate(event) {
    console.log("Data updated:", event.detail);
  }
}

5. 使用类型检查(TypeScript)

如果使用 TypeScript,可以为自定义事件定义类型。

typescript
// 定义事件详情类型
interface CartUpdatedDetail {
  action: "add" | "remove";
  productId: string;
  totalItems: number;
  totalPrice: number;
}

// 创建类型安全的自定义事件
const event = new CustomEvent<CartUpdatedDetail>("cartUpdated", {
  detail: {
    action: "add",
    productId: "abc123",
    totalItems: 5,
    totalPrice: 99.99,
  },
});

// 监听器也有类型提示
document.addEventListener(
  "cartUpdated",
  (e: CustomEvent<CartUpdatedDetail>) => {
    console.log(e.detail.productId); // TypeScript 知道这个属性存在
  }
);

总结

自定义事件是构建模块化、可扩展应用的强大工具。它们让你能够:

  1. 解耦组件:模块之间不需要直接引用,通过事件进行松耦合的通信
  2. 扩展功能:轻松添加新功能,只需监听相应的事件
  3. 构建插件系统:允许第三方代码通过事件钩子扩展应用
  4. 统一通信模式:使用与内置事件相同的 API,保持代码一致性

关键要点:

  • 使用 CustomEvent 构造函数创建事件
  • 通过 detail 属性传递数据
  • 使用 dispatchEvent() 触发事件
  • 设置 bubbles: true 让事件能够冒泡
  • 使用清晰的命名约定
  • 及时清理不再需要的监听器