Внедрение зависимостей и RxJS
Что это такое
Внедрение зависимостей — это приём, при котором класс просит то, что ему нужно, вместо того чтобы конструировать это сам: компонент объявляет зависимость от сервиса, а экземпляр поставляет фреймворк. RxJS — библиотека, из операторов которой собирают асинхронные потоки значений. Первое нужно Angular, чтобы связать приложение в одно целое. Второе — чтобы представить данные, приходящие во времени: ответы HTTP, ввод в форму, сообщения WebSocket.
Почему это важно
Непривычным в кодовой базе на Angular кажется многое, однако почти всё это сводится к двум идеям. Внедрение зависимостей делает сервисы тестируемыми — тест подставляет заглушку так, что класс об этом не знает, — и превращает разделение состояния между компонентами в поставку одного экземпляра в нужном месте. RxJS переводит неудобные части асинхронной работы — отмену, повтор, подавление дребезга, объединение двух источников — в операторы, из которых складывается конвейер, а не во вложенные колбэки. Вместе они объясняют форму кода, которую иначе трудно прочитать со стороны.
Ключевые идеи
Сервисы — это просто классы, где-то предоставленные
Сервис — обычный класс на TypeScript с декоратором @Injectable. Регистрация в корневом внедрителе создаёт один общий экземпляр на всё приложение, а регистрация на компоненте — отдельный экземпляр на каждый экземпляр компонента; так две таблицы держат независимое состояние из одного класса.
@Injectable({ providedIn: "root" })
export class OrdersService {
constructor(private readonly http: HttpClient) {}
list(): Observable<Order[]> {
return this.http.get<Order[]>("/api/orders");
}
}
Просите зависимость в конструкторе
Список параметров конструктора объявляет, что именно нужно классу, а внедритель разрешает каждую зависимость по типу. Так граф зависимостей оказывается виден вверху файла, и тест может подставить подделку, не меняя сам класс.
Observable — это поток, а не промис
Промис разрешается один раз, а observable может испустить ноль, одно или много значений и не делает ничего, пока кто-нибудь не подпишется. Поэтому HTTP-вызов — холодный observable: вызов метода сервиса не отправляет запрос, пока значение не запросит пайп async в шаблоне или ручная подписка.
@if (orders$ | async; as orders) {
<ul>
@for (order of orders; track order.id) { <li>{{ order.id }}</li> }
</ul>
}
Операторы описывают преобразование
map, filter, debounceTime, switchMap, catchError и retry — каждый из них описывает один шаг преобразования. Для поиска важнее всех switchMap: он отписывается от предыдущего внутреннего observable, а это ровно та отмена, которая нужна полю поиска.
readonly results$ = this.query$.pipe(
debounceTime(250),
distinctUntilChanged(),
switchMap((q) => this.http.get<Result[]>(`/api/search?q=${encodeURIComponent(q)}`)),
catchError(() => of([]))
);
Отписывайтесь сами или позвольте это фреймворку
Ручную подписку в компоненте нужно отменить в ngOnDestroy, иначе компонент живёт столько же, сколько поток. Пайп async и takeUntilDestroyed делают это за вас — поэтому голый subscribe им и уступает.
Держите состояние в потоке, где можете
Вывод представления из observable — объединяя их через combineLatest или отображая селектор хранилища — избавляет от дублированного состояния, которое появляется, когда те же данные ещё и скопированы в поле компонента. Там, где состояние всё же приходится держать, держите его в одном сервисе и выставляйте наружу как observable.
Практика
Соберите поиск с отменой
Создайте поле поиска, значение которого — observable, подавите дребезг и через switchMap отправьте HTTP-запрос. Печатайте быстро.
Готово, когда: на панели сети видно, что ранние запросы отменены, а список никогда не отрисовывает результаты запроса, который уже не текущий.
Разделите состояние через предоставленный сервис
Поместите счётчик и метод его увеличения в сервис, предоставленный в корне. Прочитайте его из двух соседних компонентов.
Готово, когда: оба компонента показывают одно значение и оба сразу обновляются, когда любой из них его увеличивает, без входов и выходов между ними.
Частые ошибки
- Подписка без отписки — компонент протекает и продолжает получать события после своего исчезновения; исправить: используйте пайп
asyncили отменяйте черезtakeUntilDestroyed. - Использование
mergeMapдля поиска — запрос на каждое нажатие сохраняется, и медленный ранний ответ может победить; исправить: используйтеswitchMap, чтобы новый запрос отменял предыдущий. - Предоставление сервиса на неверном уровне — либо каждый компонент получает свою копию, либо все дерутся за одну; исправить: решите для каждого сервиса, общий он или на компонент, и регистрируйте там.
- Копирование данных observable в поле компонента на каждой эмиссии — два источника правды расходятся; исправить: выводите представление из потока.
- Обращение с observable как с промисом — без подписки ничего не происходит, а холодный observable повторяет свою работу на каждого подписчика; исправить: подпишитесь один раз на краю или разделите поток.
Что читать дальше
- Компоненты и шаблоны (02-frontend/30-angular/01-components-and-templates) — компоненты, которые потребляют эти сервисы и потоки.
- Управление состоянием (02-frontend/23-state-management) — взгляд на ту же задачу без привязки к фреймворку, включая случаи, когда хранилище лучше сервиса.
- Асинхронный JavaScript (02-frontend/06-javascript/03-asynchronous-javascript) — промисы и цикл событий, на которых построены observable.