Фронтенд — новый легаси: Как мы проспали event-driven революцию
Введение: Архитектурное дежавю
Вы когда-нибудь замечали, как цифровой мир движется по спирали? В 2018 году я, размахивая Dockerfile и Helm-чартами, внедрял микросервисы на C# с RabbitMQ — всё ради священной цели «низкой связанности». А через три года, переключившись на Angular, с ужасом осознал: фронтенд-компоненты общались через цепочки Input/Output, словно это 2005-й, а мы пишем WinForms.
Это как собрать космический корабль, но управлять им через телеграф. На бэкенде мы гордо декларируем event-driven architecture, а на фронтенде компоненты перешёптываются через пропсы, будто подростки на школьной дискотеке. Ирония? Чем сложнее становились наши системы, тем больше они напоминали те самые монолиты, от которых мы бежали в мире backend.
Мой момент истины наступил, когда пришлось вносить изменения в какое-то диалоговое окно и чтобы добавить кнопку «Отмена», мне пришлось:
Обновить родительский компонент
Переписать сервис-посредник
Подправить три дочерних модуля
Всего 12 файлов для одной кнопки! В мире микросервисов это выглядело бы как правка пяти разных репозиториев, чтобы поменять цвет лейбла. И тогда я задался вопросом: «Почему фронтенд, при всей своей прогрессивности, до сих пор не усвоил уроки распределённых систем?»
Мы освоили WebAssembly, внедрили GraphQL, разорвали CSS на модули, но коммуникация между компонентами застряла в эпохе jQuery. Как будто архитекторы забыли, что React, Angular и Vue — это не просто про рендеринг, а про взаимодействие независимых агентов.
Но что, если я скажу, что решение давно существует? Что паттерн, который десятилетиями работает в RabbitMQ, Kafka и AWS SQS, может стать спасением для фронтенда? И что для этого не нужны тяжеловесные библиотеки — достаточно 15 КБ кода и принципиально иного взгляда на компоненты.
Погружаемся в кроличью нору событийно-ориентированного подхода. Возможно, после этой статьи ваши компоненты перестанут перешептываться и начнут вести светские беседы как взрослые независимые модули.
Часть 1: Исторический экскурс — 40 лет эволюции Pub/Sub
В 1983 году, когда программисты вручную переключали джамперы на материнских платах, Адель Голдберг из Xerox PARC писала код для Smalltalk-80. В её лаборатории родилась идея, которая переживет персональные компьютеры, веб-революцию и мобильную эпоху: «Объекты должны общаться через сообщения, а не вызовы методов». Это был первый вздох Pub/Sub
— паттерна, который стал цифровым эквивалентом эсперанто для независимых модулей.
Эволюция в трёх эпохах
1. Эра племён (1980–2000) Smalltalk-объекты обменивались сообщениями как первобытные люди — напрямую, без посредников. Проблема? Чтобы отправить «письмо», нужно было знать точное местоположение «племени»-получателя.
"Племя А отправляет сообщение племени Б"
Б примитив: 'Огонь погас!'
Эра империй (2000–2010) CORBA и Enterprise Service Bus (ESB) превратили сообщения в бюрократию. Нужно было:
Знать WSDL-контракты
Регистрировать конечные точки
Выстраивать XML-схемы
На проекте 2010 года мы три недели интегрировали SAP с .NET через ESB. Когда спросили архитектора: «Почему нельзя проще?», он ответил: «Это enterprise — здесь так принято».
Эра глобализации (2010–н.в.) Kafka, RabbitMQ и облачные очереди превратили Pub/Sub
в лингва-франка микросервисов. Правила упростились:
Формат сообщения = единственный контракт
Издатель не знает подписчиков
Брокер гарантирует доставку
Философский поворот: От приказов к договорам
Pub/Sub
— это цифровая версия социального договора Руссо. Когда модуль публикует PriceChangedEvent
, он как бы заявляет:
«Я не знаю, кому это нужно»
«Но если хотите — слушайте»
«Обещаю формат: {itemId: string, newPrice: number}»
Это напоминает TCP/IP для людей: как пакеты данных не заботятся о том, браузер вы или почтовый клиент, так и сообщениям всё равно, Angular вы или React.
Почему Pub/Sub пережил 40 лет технологических революций?
Антихрупкость: Системы учатся жить с ошибками (вспомним принцип Dead Letter Queues)
Языковая агностичность: Сообщениям всё равно, на чём вы пишете — они как эсперанто для микросервисов
Эволюционность: Можно начинать с простой шины и расти до распределённого стриминга
Как-то раз на митапе я услышал фразу: «Kafka — это Smalltalk для больших данных». Возможно, в этом есть доля правды — оба подхода учат системы вежливому общению без лишних вопросов.
В следующей части мы возьмём этот 40-летний опыт и посмотрим, как применить его к Angular-компонентам — чтобы они перестали тыкать друг друга локтями через Input/Output и заговорили на языке независимых сообщений.
Фронтенд: Застрявший в прошлом?
Пока бэкенд в 2010-х переходил от SOAP к событиям, фронтенд изобретал… @Output()
. В Angular-компонентах застряли пережитки эры империй:
Жёсткая иерархия вызовов
Сервисы как ESB-монстры
События через 5 уровней — как бюрократическая почта
Однажды, чтобы добавить аналитику для кнопки в дочернем компоненте, нам пришлось:
Добавить
@Output() analyticsEvent
в компонент DПробросить его через B → C → A
Подписаться в корневом компоненте
Вагон работы ради строки analytics.track('click')
. Это как доставлять письмо соседу через три почтовых отделения.
Уроки, которые фронтенд пропустил
1. События ≠ цепочки вызовов
Бэкенд давно понял: если микросервис A вызывает B, а B вызывает C — это антипаттерн. Но во фронтенде @Output() → сервис → @Input() считается нормой.
2. Брокер ≠ точка отказа
RabbitMQ выдерживает миллионы сообщений. А типичный Angular-сервис с Subject-ами падает при 1000 событий в секунду.
3. Формат > реализация
На бэкенде OpenAPI/Swagger описывают контракты. Во фронтенде до сих пор работают с any в событиях.
Заря надежды: Web Components
По иронии, будущее событийного фронтенда началось в 2011 году с идеи Web Components. Их Custom Events
ближе к духу Pub/Sub, чем Angular-подход:
// Компонент А
dispatchEvent(new CustomEvent('price-changed', {
detail: {itemId: '45', price: 20}
}));
// Компонент Б
window.addEventListener('price-changed', (e) => {
console.log(e.detail.price);
});
Это напоминает ранний Smalltalk, но с HTML5-синтаксисом. Жаль, что фреймворки не пошли этим путём дальше.
Исторический парадокс: Технологии фронтенда обновляются каждые 2 года, но архитектурные паттерны застряли в 2000-х. Возможно, пора перестать изобретать велосипеды и подсмотреть решения у… 40-летнего Smalltalk.
В следующей части разберём, как эти принципы применяются в современных Angular-приложениях — и почему @Input/@Output
иногда опаснее, чем кажется. Спойлер: это как строить замковую стену, на которую забраться изнутри сложнее, чем снаружи.
Часть 2: Фронтенд-дилемма — Когда компоненты начинают болтать
Вы знаете этот момент, когда открываете код коллеги и видите компонент, который знает слишком много? Как тот сосед, который следит за всеми через камеры видеонаблюдения. В Angular-мире это часто начинается с невинного @Input()
и @Output()
, но быстро превращается в паутину зависимостей. Давайте разберемся, почему традиционные подходы иногда напоминают игру в «испорченный телефон».
Проблема 1: Input/Output как цепные реакции
Представьте компонент ProductCard
, который должен показывать модальное окно при клике. Классический подход:
// product-card.component.ts
@Output() openModal = new EventEmitter<string>();
onClick() {
this.openModal.emit('product-details');
}
// parent.component.html
<product-card (openModal)="handleModal($event)"></product-card>
<modal [type]="modalType"></modal>
Что не так:
ProductCard
знает, что где-то есть модалкаРодительский компонент становится курьером между несвязанными частями
Изменение типа модалки требует правки нескольких файлов
Это как если бы каждый сотрудник офиса передавал документы лично в руки, вместо того чтобы использовать общий ящик для писем.
Проблема 2: Сервисы-посредники как новый монолит
Когда Output-ов становится много, мы создаем ModalService
:
// modal.service.ts
private modalSubject = new Subject<string>();
modal$ = this.modalSubject.asObservable();
open(type: string) {
this.modalSubject.next(type);
}
// product-card.component.ts
constructor(private modal: ModalService) {}
onClick() {
this.modal.open('product-details');
}
Кажется лучше, но:
Сервис превращается в «божественный объект», знающий обо всех модалках
Компоненты жестко привязаны к API сервиса
Тестирование требует мокинга целого сервиса для одной кнопки
На одном из проектов наш SharedService
разросся до 1200 строк — он управлял модалками, тултипами, уведомлениями и анимациями. Мы шутили, что это он теперь управляет проектом, а не мы, но смех был нервным.
Кейс: Модальное окно-шпион
Несколько лет назад нам нужно было добавить аналитику для модалки обратной связи. Проблема — она открывалась из 17 мест в приложении. По старой схеме пришлось:
Добавить
@Output() registerClick
в 5 компонентовПропихнуть событие через 3 уровня родительских компонентов
Обновить
AnalyticsService
, добавив отслеживаниеНаписать 23 теста для проверки проброса событий
На это ушло 2 рабочих дня. С postboy решение заняло 20 минут:
// Открытие модалки с аналитикой
postboy.fire(new OpenModalEvent({
type: 'signup',
source: 'navbar' // Контекст для аналитики
}));
// Глобальная подписка на все открытия модалок
postboy.sub(OpenModalEvent).subscribe(event => {
analytics.track('modal-open', event.type, event.source);
});
Никаких правок в компонентах — просто добавили подписку в корневом модуле.
Почему это архитектурная ловушка?
Хрупкость: Изменение одного компонента вызывает волну правок в других
Тестируемость: Чтобы проверить кнопку, нужно мокать цепочку сервисов
Масштабируемость: Новые фичи увеличивают сложность экспоненциально
Это напоминает город без генерального плана: сначала строят дома как попало, а потом годами расчищают кривые переулки.
А ещё до четверти обсуждений код-ревью приходится на обсуждение «как правильно пробросить событие через компонент C».
Промежуточный итог: Angular-компоненты похожи на жителей мегаполиса — они могут быть независимы, но им нужна «центральная почта» для общения. В следующей части разберём, как реализовать такую почту: через самописное решение, NgRx или легкие библиотеки. Спойлер: иногда лучший фреймворк — это несколько десятков строк кода.
Часть 3: Бэкенд-уроки — Что фронтенд может украсть у RabbitMQ
Если бы компоненты умели разговаривать, они должны были бы попросить у бэкенда совета. RabbitMQ, Kafka и другие брокеры десятилетиями решают те же проблемы, что терзают фронтенд. Давайте «позаимствуем» четыре принципа, чтобы перестать изобретать велосипеды.
Принцип 1: Издатели не знают подписчиков
Как это работает в RabbitMQ:
Продавец (издатель) кладёт товар на склад (брокер). Ему всё равно, кто заберёт товар — курьер, клиент или вор (шутка).
Фронтенд-аналог:
Компонент публикует событие «Пользователь вошёл», не зная:
Кто обновит хедер
Кто отправит аналитику
Кто покажет приветственный тултип
// плохо
this.authService.login().pipe(
tap(() => {
this.header.refresh();
this.analytics.trackLogin();
this.tourService.start();
})
);
// Как должно быть
this.authService.login().subscribe(() => {
eventBus.publish(new UserLoggedInEvent());
});
Принцип 2: Сообщения — документация системы
Бэкенд-практика:
В RabbitMQ схемы сообщений (например, через Avro) — это живая документация API.
Фронтенд-реализация:
Каждое событие — класс с типизацией:
class PasswordChangedEvent {
constructor(
public readonly userId: string,
public readonly method: 'email' | 'sms'
) {
}
}
Теперь любой разработчик видит:
Какие данные содержит событие
Возможные значения полей
Где используется (через поиск по проекту)
Принцип 3: Очереди как буфер против хаоса
Паттерн бэкенда:
Если сервис-потребитель упал, RabbitMQ сохранит сообщения в очереди, пока он не оживёт.
Фронтенд-адаптация:
Для критичных событий (например, аналитики) реализуем повторную отправку:
class AnalyticsService {
private failedEvents: AnalyticEvent[] = [];
constructor() {
eventBus.subscribe(AnalyticEvent).subscribe(event => {
try {
this.sendToServer(event);
} catch {
this.failedEvents.push(event); // Сохраняем для повтора
}
});
}
}
Принцип 4: Типизация как контракт
Бэкенд-пример:
В Kafka схемы регистрируются в Confluent Schema Registry. Несовместимые версии блокируются.
Фронтенд-реализация:
Используем TypeScript для защиты от ошибок:
// V1: Устаревшая версия
class ProductAddedEvent {
constructor(public productId: string) {
}
}
// V2: Новая версия
class ProductAddedEventV2 {
constructor(
public productId: string,
public categoryId: string
) {
}
}
// Подписчик ловит только свою версию
eventBus.subscribe(ProductAddedEventV2).subscribe(/* ... */);
Как это выглядит в идеальном мире
Представьте компоненты как независимые микросервисы:
Модуль A публикует
CartUpdatedEvent
с типом{ cartId: string, items: CartItem[] }
Модуль B подписывается и обновляет бейдж корзины
Модуль C слушает то же событие для расчёта доставки
Модуль D пишет в LocalStorage
Никто не знает о существовании других. Изменили формат корзины? Просто создайте CartUpdatedEventV2
— старые подписчики останутся работать с V1.
Почему фронтенд отстаёт?
Синхронность мышления: «Нажали кнопку → вызвали метод → получили результат» — это legacy-подход.
Страх асинхронности: Разработчики боятся «плавающих» событий, хотя в бэкенде это норма.
Культура контрактов: Фронтенд-команды редко документируют форматы событий, превращая их в магические строки.
Истории из жизни:
Когда мы внедрили типизированные события, новый разработчик смог за день подключить фичу «история заказов», просто изучив существующие классы сообщений. Раньше это требовало недели согласований.
Итог:
Бэкенд-архитекторы десятилетиями шлифовали event-driven подходы. Фронтенду пора перестать вариться в собственном соку и начать «красть» проверенные решения. В следующей части реализуем эти принципы на практике — от самописной шины до готовых решений.
Часть 4: Реализация на практике — От самописных решений до библиотек
Ответ на вопрос «Писать свой EventBus или использовать готовое решение?» зависит от масштаба. Давайте реализуем три варианта и разберём, когда какой подходит.
Вариант 1: Самописная шина на RxJS за 15 минут
Простейший EventBus на RxJS — это 20 строк кода:
import {filter, map, Subject} from 'rxjs';
type EventPayload<T> = { type: string; data?: T };
class EventBus {
private _events$ = new Subject<EventPayload<unknown>>();
publish<T>(type: string, data?: T): void {
this._events$.next({type, data});
}
subscribe<T>(type: string) {
return this._events$.pipe(
filter(event => event.type === type),
map(event => event.data as T)
);
}
}
// Использование
const bus = new EventBus();
bus.subscribe<string>('GREETING').subscribe(msg => console.log(msg));
bus.publish('GREETING', 'Hello from 1983!');
Плюсы:
Полный контроль
0 зависимостей
Подходит для малых проектов
Минусы:
Нет типизации данных
Ручное управление подписками
Нет поддержки жизненного цикла Angular
Когда использовать:
Прототипы
Мини-приложения (<15 компонентов)
Обучение принципам Pub/Sub
Вариант 2: NgRx как хранилище событий
NgRx — это «тяжёлая артиллерия» с полным набором инструментов:
// actions/events.actions.ts
export const showModal = createAction(
'[UI] Show Modal',
props<{ type: string; context?: unknown }>()
);
// effects/events.effects.ts
showModal$ = createEffect(() =>
this.actions$.pipe(
ofType(showModal),
tap(({type}) => console.log(`Modal ${type} opened`))
), {dispatch: false}
);
// component.ts
this.store.dispatch(showModal({type: 'confirm'}));
Плюсы:
DevTools с историей событий
Интеграция с состоянием приложения
Поддержка сложных сценариев (CQRS, саги)
Минусы:
Оверкилл для простых задач
Кривая обучения
Размер бандла
Проблемы типизации
Когда использовать:
Enterprise-приложения
Когда уже используется NgRx
Сложная бизнес-логика с отслеживанием состояния
Вариант 3: Специализированные библиотеки
Пример postboy:
// Определение события
class ApiErrorEvent extends PostboyGenericMessage {
constructor(public readonly error: Error) {
super();
}
}
// Публикация
postboy.fire(new ApiErrorEvent(err));
// Подписка
postboy.sub(ApiErrorEvent).subscribe(event => {
alert(`Ошибка ${event.error.message}`);
});
Когда использовать:
Средние проекты и Enterprise-приложения
Микрофронтенды
Постепенная миграция с legacy-кода
Когда события вредны: 3 опасных кейса
Как выбрать инструмент?
1. Карта решений:
< 15 компонентов: RxJS Subject
15+ компонентов (до бесконечности): postboy или аналог
50+ компонентов (до бесконечности): NgRx + дополнительные брокеры
2. Правило 48 часов:
Если за два дня не смогли внедрить Pub/Sub — ваш подход слишком сложен.
3. Тест на масштабируемость:
Попробуйте добавить реакцию на событие из совершенно нового модуля. Если это требует изменений в 3+ местах — архитектура не event-driven.
История из практики:
На проекте с 70 компонентами мы начали с самописного решения, а через полгода перешли на postboy. Это было как менять двигатель на летящем самолёте, но событийная архитектура позволила делать миграцию постепенно.
Итог:
Pub/Sub — не серебряная пуля. Это как молоток: им можно забить гвоздь, а можно разбить экран. Выбирайте инструмент под размер «гвоздя» и помните: лучшая архитектура та, которая позволяет спать по ночам, а не хвастаться на митапах.
Часть 5: Когда события вредны — Опасные сценарии Pub/Sub
Pub/Sub — это как огонь: греет, когда под контролем, и сжигает всё, когда вырывается наружу. Давайте разберём три сценария, когда событийная модель превращается из лекарства в яд.
1. Циклические зависимости: Бесконечный круговорот
Проблема:
Событие A
вызывает B
, B
вызывает C
, а C
снова вызывает A
.
// Компонент А
postboy.subscribe(EventC).subscribe(() => {
postboy.publish(new EventA()); // Зацикливание
});
// Компонент B
postboy.subscribe(EventA).subscribe(() => {
postboy.publish(new EventB());
});
// Компонент C
postboy.subscribe(EventB).subscribe(() => {
postboy.publish(new EventC());
});
Чем опасно:
Бесконечный цикл событий → 100% загрузка CPU
Невозможность отладки через DevTools
Решение:
Добавить debounce (RxJS-оператор):
postboy.subscribe(EventA).pipe(
debounceTime(100)
).subscribe(/* ... */);
Использовать флаги-ингибиторы:
let isProcessing = false;
postboy.subscribe(EventA).subscribe(() => {
if (!isProcessing) {
isProcessing = true;
// Логика...
isProcessing = false;
}
});
Навести порядок в логике вызовов, ибо это вообще не нормально
2. Утечки памяти: Призрачные подписки
Проблема:
Неотписанные подписки в сервисах накапливаются при горячем обновлении модулей.
@Injectable()
export class AnalyticsService {
constructor() {
// Подписка никогда не отписывается!
eventBus.subscribe(TrackingEvent).subscribe(/* ... */);
}
}
Чем опасно:
Утечка памяти → падение производительности
«Зомби-обработчики» реагируют на события после уничтожения компонента
Решение для Angular:
Использовать takeUntilDestroyed:
private destroyRef = inject(DestroyRef);
eventBus.subscribe(Event)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(/* ... */);
Для сервисов — ручное управление:
private sub?: Subscription;
ngOnInit()
{
this.sub = eventBus.subscribe(Event).subscribe(/* ... */);
}
ngOnDestroy()
{
this.sub?.unsubscribe();
}
3. Слепые зоны типизации: Ошибки в темноте
Проблема:
Нетипизированные события превращаются в мины замедленного действия.
// Плохо: данные без контракта
eventBus.publish('user_updated', {id: 123, name: 'Alice'});
// Где-то в другом модуле
eventBus.subscribe('user_updated').subscribe(data => {
console.log((data as any).age); // undefined → падение в рантайме
});
Чем опасно:
Ошибки обнаруживаются только в рантайме
Рефакторинг становится игрой в рулетку
Решение:
Использовать классы-сообщения:
class UserModel {id: string; name: string}
class UserUpdatedEvent extends PostboyGenericMessage {
constructor(data: UserModel) {
super();
}
}
// Подписка с гарантией типов
postboy.subscribe(UserUpdatedEvent).subscribe(data => {
console.log(data.name); // Тип string известен
});
Когда НЕ использовать Pub/Sub
Правило безопасности:
Прежде чем публиковать событие, задайте три вопроса:
Есть ли подписчики кроме меня?
Может ли это событие вызвать неожиданные эффекты?
Можно ли решить задачу проще через Input/Output?
Итог:
Pub/Sub требует дисциплины. Это как ядерная энергия: при правильном обращении даёт свет, при ошибках — разрушение.
Часть 6: Заключение — Выращивая архитектуру
Архитектура программных систем — это не чертёж, высеченный в камне. Это живой сад, где компоненты, как растения, растут, переплетаются и иногда требуют обрезки. Pub/Sub — не волшебный посох, а инструмент садовника, который помогает управлять этим хаосом, не подавляя его.
Три правила эволюции
1. Начинайте с малого
Не пытайтесь внедрить событийную модель сразу во всём приложении. Начните с одного модуля, где связи уже напоминают паутину. Замените самый проблемный @Output()
на событие — и наблюдайте, как архитектура начнёт меняться сама.
2. Рефакторите только при возникновении проблем
Если компоненты общаются через два уровня инициализации — не трогайте их. Как говорил Кент Бек: «Не решайте проблемы, которых у вас нет». Pub/Sub — лекарство от сложности, а не витамин для профилактики.
3. Выбирайте инструмент под масштаб
Самописная шина из 20 строк кода может быть лучше NgRx или postboy для проекта на 15 компонентов. Но когда система разрастается до 200+ акторов — ищите решения с типизацией.
Как начать завтра
1. Найдите «подозрительный» компонент
Тот, который знает о пяти других модулях. Замените один вызов метода на событие.
2. Документируйте контракты
Создайте папку events
с классами сообщений. Даже если используете строковые типы — опишите их в JSDoc.
3. Устройте «день тишины»
Запретите команде использовать @Output()
и сервисы-посредники неделю. Вы удивитесь, как быстро найдут event-driven альтернативы.
Эпилог для скептиков
«Но ведь события усложняют отладку!» — скажете вы. Отвечу историей: когда в нашем проекте внедрили событийную модель, новый разработчик за день подключил фичу, которую раньше делали бы неделю. Он просто нашёл нужное событие в документации и подписался.
Да, события — это ответственность. Да, они требуют дисциплины. Но они же дают свободу, сравнимую с переходом от монархии к демократии. Вы перестаётся быть богом-архитектором и становитесь садовником, который лишь задаёт правила роста.
postboy
— один из инструментов в вашем сарае. Вы можете выбрать NgRx, самописную шину или что-то ещё. Суть не в библиотеке, а в смене парадигмы: перестать связывать компоненты и начать описывать их взаимодействие как договор равных.
Последний совет. Когда в следующий раз увидите цепочку из трёх @Output()
— представьте, что это сорняк. Выдерните его, посадите событие и наблюдайте, как архитектура расцветёт.
P.S. Документация Postboy — здесь. Но если предпочитаете свой EventBus, я надеюсь, что этот опус сподвигнет вас на этот подвиг. Happy coding!