类型安全的 CustomEvents:使用原生 API 实现更好的消息传递

发布: (2026年3月1日 GMT+8 05:49)
4 分钟阅读
原文: Dev.to

Source: Dev.to

Cover image for Type‑Safe CustomEvents: Better Messaging with Native APIs

原生的 EventTarget 是内部消息传递的隐藏宝石。它是内置的、快速的,并且不会妨碍你的代码。然而,标准的 CustomEvent 接口对 TypeScript 来说有点像“黑盒”。通常,你每次想访问数据时都要把 e as CustomEvent 进行类型断言。

我们可以做得更好。通过包装目标和事件创建,我们可以拥有一个完全类型安全的事件总线,且没有任何运行时开销。

问题

当你分派一个 CustomEvent 时,数据被隐藏在 detail 属性中。默认情况下,addEventListener 并不知道该 detail 包含什么,这迫使你必须手动为监听器编写类型。

解决方案:TypedEventTarget

我们可以使用一个通用映射将事件名称与其特定的 CustomEvent 负载关联起来。

type EventListener = (evt: E) => void;

interface EventListenerObject {
  handleEvent(evt: CustomEvent): void;
}

// The type of our listener receives the CustomEvent with our specific data
type TEL = EventListener> | EventListenerObject>;

export class TypedEventTarget> {
  private readonly target = new EventTarget();

  addEventListener(
    type: K & string,
    listener: TEL,
    options?: boolean | AddEventListenerOptions,
  ) {
    // We cast to EventListenerOrEventListenerObject because the browser expects the base Event type
    this.target.addEventListener(type, listener as EventListenerOrEventListenerObject, options);
  }

  removeEventListener(
    type: K & string,
    listener: TEL,
    options?: boolean | EventListenerOptions,
  ) {
    this.target.removeEventListener(type, listener as EventListenerOrEventListenerObject, options);
  }

  // A helper to ensure we always dispatch a properly formatted CustomEvent
  dispatchEvent(type: K, ...args: M[K] extends void ? [detail?: undefined] : [detail: M[K]]) {
    const [detail] = args;
    return this.target.dispatchEvent(new CustomEvent(String(type), { detail }));
  }
}

当然,这只是一个基类;我们需要对其进行扩展,以便在自己的数据中使用它。

实际使用

假设我们想要跟踪一个在线购物车。我们希望能够在站点的任何位置对购物车进行修改,但又不想让 UI 或总价不同步。

import { TypedEventTarget } from './TypedEventTarget';

export interface CartItem {
  id: string;
  name: string;
  price: number;
}

// This map defines exactly what data each event emits
type ShoppingCartEvents = {
  'item-added': CartItem;
  'item-removed': { id: string };
  'cart-cleared': void;
};

export default class ShoppingCart extends TypedEventTarget {
  private _items: CartItem[] = [];

  addItem(item: CartItem) {
    this._items.push(item);
    this.dispatchEvent('item-added', item);
  }

  removeItem(id: CartItem['id']) {
    this._items = this._items.filter(item => item.id !== id);
    this.dispatchEvent('item-removed', { id });
  }

  clear() {
    this._items = [];
    this.dispatchEvent('cart-cleared');
  }

  get items() {
    return _items;
  }

  get total() {
    return this.items.reduce((sum, item) => sum + item.price, 0);
  }
}

现在我们有了一个用于存储数据的类,你可以看到我们已经添加了会自动分发事件的方法。由于这些事件是严格类型化的,在监听它们时我们可以获得完整的自动补全和安全性:

shoppingCartSingleton.addEventListener('item-added', event => {
  const item = event.detail; // TypeScript knows this is a CartItem
  console.log(item);
});

演示

以下是使用上述类的一个简单演示。

(演示代码已省略。)

结束语

我只是触及了这项技术可能性的表面。我们生活在一个拥有众多主流框架的世界里,每个框架都有各自的实现方式。我认为我们正站在通用单例回归的拐点上:一个原生的 JS/TS 类可以连接到你选择的任意框架。但这是另一天、另一篇文章再谈的观点。

感谢阅读!如果你想联系我,这里是我的 BlueSkyLinkedIn 个人主页。来打个招呼吧 😊

0 浏览
Back to Blog

相关文章

阅读更多 »

冲刺

!Sprint: Express 无需重复代码 https://dev-to-uploads.s3.amazonaws.com/uploads/articles/9mcbu1c3wuvlq0tiuup0.png 介绍 Sprint:停止重复代码