EventEmitter w TypeScript - eventy, generyki i decoupling

EventEmitter to jeden z tych konceptów, które na początku mogą wydawać się bardziej skomplikowane, niż są w rzeczywistości.

TypeScript

JavaScript

EventDrivenArchitecture

Decoupling

SoftwareArchitecture

Frontend

Mamy:

  • eventy,
  • kanały,
  • listenery,
  • subskrypcję,
  • emitowanie wiadomości,
  • unsubscribe.

Ale podstawowa idea jest bardzo prosta:

Jedna część aplikacji mówi „coś się wydarzyło”, a inne części mogą zdecydować, że chcą o tym wiedzieć.

W TypeScript możemy dodatkowo zadbać o to, żeby dane przekazywane wraz z eventem były odpowiednio typowane.

Zacznijmy od JavaScriptu

Wyobraźmy sobie aplikację sklepu internetowego.

Po utworzeniu zamówienia chcemy:

  • wyświetlić komunikat,
  • wysłać e-mail,
  • zaktualizować analitykę.

Bez EventEmittera moglibyśmy zrobić:

function createOrder(order) {
  saveOrder(order);
  showNotification(order);
  sendEmail(order);
  updateAnalytics(order);
}


Problem polega na tym, że createOrder() musi znać wszystkie rzeczy, które mają wydarzyć się po utworzeniu zamówienia.

Możemy zamiast tego powiedzieć:

ee.trigger("orderCreated", order);

A inne części aplikacji mogą się zainteresować tym eventem:

ee.on("orderCreated", showNotification);
ee.on("orderCreated", sendEmail);
ee.on("orderCreated", updateAnalytics);


Wtedy createOrder() nie musi wiedzieć, kto nasłuchuje.

To jest przykład decouplingu, czyli zmniejszania zależności pomiędzy częściami aplikacji.

EventEmitter jako publish-subscribe

EventEmitter można potraktować jako prosty mechanizm publish-subscribe.

Mamy:

Publisher

│ trigger("orderCreated", order)

EventEmitter

├──→ Listener 1
├──→ Listener 2
└──→ Listener 3

Jedna część systemu publikuje informację.

Inne części systemu mogą ją subskrybować.

Trzy podstawowe operacje

W naszym ćwiczeniu API wygląda tak:

ee.on(CHANNEL, LISTENER);
ee.trigger(CHANNEL, ITEM);
ee.off(CHANNEL, LISTENER);


Możemy to zapamiętać:

on() → subscribe
trigger() → emit
off() → unsubscribe

Czym jest listener?

Listener jest po prostu funkcją.

Możemy stworzyć jego typ:

type Listener<T = unknown> = (item: T) => void;

Rozłóżmy to:

(item: T) => void

oznacza:

funkcja przyjmuje jeden argument typu T i nic nie zwraca.

Na przykład:

const listener: Listener<string> = (message) => {
  console.log(message);
};

TypeScript wie tutaj, że:

message

jest typu:

string

Dlatego możemy zrobić:

const listener: Listener<string> = (message) => {
  console.log(message.toUpperCase());
};

ale:

message.toFixed();

spowoduje błąd TypeScriptu, ponieważ string nie posiada toFixed().

Po co tutaj generyk T?

To:

type Listener<T = unknown> = (item: T) => void;


jest generycznym typem funkcji.

Możemy więc mieć:

Listener<string>


albo:

Listener<number>


albo:

Listener<User>

Na przykład:

type User = {
id: number;
name: string;
};

const listener: Listener<User> = (user) => {
console.log(user.name);
};

TypeScript pilnuje wtedy, żeby listener otrzymał User.

Jak przechowywać listenery?

W naszym zadaniu mamy wiele kanałów:

"orderCreated"
"userLoggedIn"
"paymentCompleted"

Każdy kanał może mieć wielu listenerów.

Potrzebujemy więc:

Map<string, Listener[]>

Można to przeczytać:

Map, w którym kluczem jest string, a wartością jest tablica listenerów.

Czyli:

"orderCreated" → [listener1, listener2]
"userLoggedIn" → [listener3]
"paymentComplete" → [listener4, listener5]

W kodzie:

class EventEmitter {
private channels = new Map<string, Listener[]>();
}

To jest bardzo ważny fragment tego zadania.

Nie:

Map<string, Listener>

bo wtedy mielibyśmy tylko jednego listenera na kanał.

Potrzebujemy:

Map<string, Listener[]>

bo:

jeden kanał → wielu listenerów.

Implementacja on()

Chcemy móc zrobić:

ee.on("orderCreated", listener);

Najpierw sprawdzamy, czy kanał istnieje:

on(channel: string, listener: Listener) {
let listeners = this.channels.get(channel);

if (!listeners) {
listeners = [];
this.channels.set(channel, listeners);
}

listeners.push(listener);
}

Jeżeli "orderCreated" jeszcze nie istnieje, tworzymy:

[]

Następnie:

listeners.push(listener);

dodaje funkcję do tablicy.

Po:

ee.on("orderCreated", listener1);
ee.on("orderCreated", listener2);

mamy:

"orderCreated"

[listener1, listener2]

trigger() — wywołanie eventu

Teraz:

ee.trigger("orderCreated", order);

powinno wywołać wszystkich listenerów z tego kanału.

trigger(channel: string, item: unknown) {
const listeners = this.channels.get(channel);

listeners?.forEach(listener => {
listener(item);
});
}

Jeżeli mamy:

"orderCreated"

[listener1, listener2, listener3]

to:

trigger("orderCreated", order);

wykona:

listener1(order);
listener2(order);
listener3(order);

Co oznacza „synchronous”?

W zadaniu pojawia się:

synchronous listener invoking

Czyli listener ma być wywołany od razu.

Nie:

setTimeout(() => listener(item));

Nie:

Promise.resolve().then(() => listener(item));

Tylko:

listener(item);

Jeżeli napiszemy:

console.log("before");

ee.trigger("orderCreated", order);

console.log("after");

wynik będzie:

before
listener
after

off() — unsubscribe

Jeżeli mamy:

ee.on("orderCreated", sendEmail);
ee.on("orderCreated", updateAnalytics);

to:

"orderCreated"

[sendEmail, updateAnalytics]

Po:

ee.off("orderCreated", sendEmail);

powinno zostać:

"orderCreated"

[updateAnalytics]

Możemy wykorzystać filter():

off(channel: string, listener: Listener) {
const listeners = this.channels.get(channel);

if (!listeners) return;

const remainingListeners = listeners.filter(
currentListener => currentListener !== listener
);

this.channels.set(channel, remainingListeners);
}

A teraz TypeScript robi się naprawdę ciekawy

Powyższa wersja działa, ale:

trigger(channel: string, item: unknown)

nie daje nam dużej ochrony typów.

TypeScript wie tylko:

item jest czymś nieznanym.

Możemy zrobić lepszą wersję.

Wyobraźmy sobie, że mamy konkretne eventy:

type Order = {
id: number;
product: string;
};

type User = {
id: number;
name: string;
};

Chcemy:

orderCreated → Order
userLoggedIn → User

Możemy zdefiniować mapę eventów:

type Events = {
orderCreated: Order;
userLoggedIn: User;
};

Teraz Events opisuje nasz system:

event payload
────────────────────────────
orderCreated Order
userLoggedIn User

I możemy stworzyć typowany EventEmitter.

Typowany EventEmitter

type Listener<T> = (item: T) => void;

class TypedEventEmitter<Events extends Record<string, unknown>> {
private channels = new Map<
keyof Events,
Listener<any>[]
>();

on<K extends keyof Events>(
channel: K,
listener: Listener<Events[K]>
) {
let listeners = this.channels.get(channel);

if (!listeners) {
listeners = [];
this.channels.set(channel, listeners);
}

listeners.push(listener);
}

trigger<K extends keyof Events>(
channel: K,
item: Events[K]
) {
const listeners = this.channels.get(channel);

listeners?.forEach(listener => {
listener(item);
});
}

off<K extends keyof Events>(
channel: K,
listener: Listener<Events[K]>
) {
const listeners = this.channels.get(channel);

if (!listeners) return;

this.channels.set(
channel,
listeners.filter(
currentListener => currentListener !== listener
)
);
}
}

Teraz możemy stworzyć:

const ee = new TypedEventEmitter<Events>();

I TypeScript zna nasze eventy.

TypeScript zaczyna pilnować danych

Możemy zrobić:

ee.on("orderCreated", (order) => {
console.log(order.id);
console.log(order.product);
});

TypeScript wie:

order: Order

Ponieważ:

Events["orderCreated"]

to:

Order

Możemy więc bezpiecznie zrobić:

order.id
order.product

Podobnie:

ee.on("userLoggedIn", (user) => {
console.log(user.name);
});

Tutaj:

user: User

I TypeScript złapie pomyłkę

To jest bardzo fajne:

ee.trigger("orderCreated", {
id: 123,
product: "Beer"
});

jest OK.

Ale:

ee.trigger("orderCreated", {
id: 123,
name: "Agnieszka"
});

TypeScript powinien zaprotestować, ponieważ orderCreated oczekuje Order, a nie User.

Podobnie:

ee.on("userLoggedIn", (user) => {
console.log(user.product);
});

jest błędem, ponieważ User nie posiada product.

To jest właśnie siła TypeScriptu

Sam EventEmitter jest mechanizmem runtime.

JavaScript wie tylko:

event → dane → listener

TypeScript może natomiast powiedzieć:

"orderCreated" → Order
"userLoggedIn" → User
"paymentCompleted" → Payment

i pilnować tego podczas pisania kodu.

To jest dużo bezpieczniejsze niż:

trigger(channel: string, item: unknown)

bo wtedy możesz przez przypadek przekazać niewłaściwe dane.

Możemy zobaczyć to w terminalu

Przykładowy program:

type Order = {
id: number;
product: string;
};

type User = {
id: number;
name: string;
};

type Events = {
orderCreated: Order;
userLoggedIn: User;
};

const ee = new TypedEventEmitter<Events>();

ee.on("orderCreated", (order) => {
console.log("🛒 Order created:", order.id);
console.log("Product:", order.product);
});

ee.on("userLoggedIn", (user) => {
console.log("👤 User logged in:", user.name);
});

ee.trigger("orderCreated", {
id: 123,
product: "Beer"
});

ee.trigger("userLoggedIn", {
id: 1,
name: "Agnieszka"
});

Terminal:

🛒 Order created: 123
Product: Beer

👤 User logged in: Agnieszka

A jeśli pomylimy dane:

ee.trigger("orderCreated", {
id: 1,
name: "Agnieszka"
});

TypeScript zgłosi błąd zanim uruchomimy program.

Co warto zapamiętać z tego zadania?

To ćwiczenie łączy kilka bardzo ważnych konceptów TypeScriptu:

type Listener<T>

type Listener<T> = (item: T) => void;

Czyli generyczny typ funkcji.

Map

Map<string, Listener[]>

Czyli:

channel → listeners

Generics

Listener<T>

pozwala określić typ danych przekazywanych do listenera.

keyof

W typowanej wersji:

K extends keyof Events

oznacza:

K może być tylko jednym z kluczy naszej mapy eventów.

Czyli jeśli mamy:

type Events = {
orderCreated: Order;
userLoggedIn: User;
};

to:

keyof Events

jest:

"orderCreated" | "userLoggedIn"

Indexed access type

To:

Events[K]

oznacza:

„jaki typ danych jest przypisany do eventu K?”

Dla:

K = "orderCreated"

otrzymujemy:

Events["orderCreated"] // Order

Dla:

K = "userLoggedIn"

otrzymujemy:

Events["userLoggedIn"] // User

Najważniejszy obraz w głowie

EventEmitter bez typowania:

event name

listeners

some data

EventEmitter z TypeScriptem:

"orderCreated"

Order

Listener<Order>


"userLoggedIn"

User

Listener<User>

Czyli TypeScript pozwala nam powiedzieć:

„Nie tylko wiem, że wydarzył się event. Wiem również, jakie dane ten konkretny event ze sobą niesie.”

I to jest właśnie moment, w którym zwykły JavaScriptowy wzorzec zaczyna pokazywać praktyczną wartość generyków, keyof i indexed access types..

W TypeScript możemy dodatkowo zadbać o to, żeby dane przekazywane wraz z eventem były odpowiednio typowane.