Event-driven архитектура на TypeScript: когда код «слышит» друг друга
Представьте, что вы пишете сложное веб-приложение: мессенджер, торгового бота или систему мониторинга серверов. В какой-то момент вы понимаете: просто вызывать функции по цепочке — это ад. Один модуль тянет за собой другой, третий завязан на четвёртый, а при изменении логики всё рассыпается как карточный домик.
Знакомо? Тогда вам нужна event-driven архитектура на TypeScript. Это не просто модный термин, а реальный способ сделать код гибким, расширяемым и понятным. В этой статье мы разберём, как работает событийно-ориентированное программирование, напишем свою шину событий на TypeScript и посмотрим, где это реально выручает.
Что такое Event-driven архитектура простыми словами
Event-driven архитектура (событийно-ориентированная архитектура) — это подход, при котором компоненты системы общаются через события. Один модуль генерирует событие (например, «пользователь зарегистрировался»), а другие подписываются на него и реагируют. Между собой они напрямую не связаны.
Звучит абстрактно? Давайте на примере из жизни. Представьте офис:
- Традиционный подход: начальник лично подходит к каждому сотруднику и говорит: «Сделай отчёт».
- Event-driven: начальник кричит: «Нужен отчёт!» — и все, кто умеет его делать, сами берутся за работу.
Во втором случае начальнику не нужно знать, кто именно будет делать отчёт. Он просто отправляет событие. А сотрудники могут подключаться и отключаться без изменения логики начальника. Это и есть суть событийно-ориентированного программирования.
Почему TypeScript идеален для событийной архитектуры
JavaScript сам по себе событийно-ориентирован (вспомните addEventListener), но в больших проектах без типов начинается хаос. TypeScript события решают эту проблему: вы точно знаете, какие данные приходят в событии, и компилятор поймает ошибку на этапе разработки, а не в рантайме.
Кроме того, TypeScript поддерживает дженерики, перегрузки и строгую типизацию колбэков — всё, что нужно для создания надёжной шины событий.
Пишем Event Bus на TypeScript с нуля
Начнём с простого, но рабочего примера. Создадим класс EventBus — центральную шину событий, через которую будут общаться все модули.
// event-bus.ts
type EventHandler<T = any> = (data: T) => void;
class EventBus {
private handlers: Map<string, EventHandler[]> = new Map();
// Подписка на событие
on<T>(event: string, handler: EventHandler<T>): void {
if (!this.handlers.has(event)) {
this.handlers.set(event, []);
}
this.handlers.get(event)!.push(handler as EventHandler);
}
// Отписка от события
off(event: string, handler: EventHandler): void {
const handlers = this.handlers.get(event);
if (handlers) {
this.handlers.set(event, handlers.filter(h => h !== handler));
}
}
// Эмиссия события
emit<T>(event: string, data: T): void {
const handlers = this.handlers.get(event);
if (handlers) {
handlers.forEach(handler => handler(data));
}
}
}
export const globalEventBus = new EventBus();
Вот мы и написали простую event-driven архитектуру на TypeScript. Теперь любой модуль может импортировать globalEventBus и либо слушать события, либо генерировать их.
Пример использования: уведомления и логирование
Представим, что у нас есть модуль аутентификации и модуль уведомлений. Они не должны знать друг о друге напрямую.
// auth-module.ts
import { globalEventBus } from './event-bus';
export function loginUser(email: string): void {
// ... логика входа
console.log(`Пользователь ${email} вошёл в систему`);
// Генерируем событие
globalEventBus.emit('user:login', { email, timestamp: Date.now() });
}
// notification-module.ts
import { globalEventBus } from './event-bus';
export function initNotifications(): void {
globalEventBus.on('user:login', (data: { email: string; timestamp: number }) => {
console.log(`📧 Отправляем приветственное письмо на ${data.email}`);
// Здесь мог быть вызов API
});
globalEventBus.on('user:logout', () => {
console.log('👋 Пока-пока!');
});
}
Обратите внимание: модуль аутентификации ничего не знает про уведомления. Он просто «кричит» о событии. А модуль уведомлений подписывается на нужные ему события. Это и есть событийно-ориентированное программирование в действии.
Продвинутая типизация: делаем события типобезопасными
В примере выше мы использовали any в EventHandler, но это не совсем по-тайпскриптовски. Давайте сделаем строгую типизацию, чтобы компилятор контролировал все типы данных для каждого события.
// typed-event-bus.ts
// Сначала определим все возможные события и их типы данных
interface AppEvents {
'user:login': { email: string; timestamp: number };
'user:logout': { userId: string };
'order:created': { orderId: string; amount: number };
'error:occurred': { message: string; code: number };
}
type EventKey = keyof AppEvents;
class TypedEventBus {
private handlers: Map<EventKey, Function[]> = new Map();
on<E extends EventKey>(event: E, handler: (data: AppEvents[E]) => void): void {
if (!this.handlers.has(event)) {
this.handlers.set(event, []);
}
this.handlers.get(event)!.push(handler);
}
emit<E extends EventKey>(event: E, data: AppEvents[E]): void {
const handlers = this.handlers.get(event);
if (handlers) {
handlers.forEach(handler => handler(data));
}
}
}
export const typedBus = new TypedEventBus();
// Пример строгой типизации
typedBus.on('user:login', (data) => {
// data.email — строка, data.timestamp — число. Всё под контролем!
console.log(data.email);
});
// А вот так уже не скомпилируется:
// typedBus.emit('user:login', { email: 123 }); // Ошибка! email должен быть строкой
Теперь наша шина событий полностью типобезопасна. Если вы ошиблись с типом данных при эмиссии — TypeScript укажет на ошибку ещё до запуска.
Когда использовать Event-driven архитектуру, а когда — нет
Event-driven архитектура на TypeScript — мощный инструмент, но не панацея. Давайте честно разберём плюсы и минусы.
Плюсы:
- Слабая связанность (loose coupling