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.
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.