Внедрение зависимостей и 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 повторяет свою работу на каждого подписчика; исправить: подпишитесь один раз на краю или разделите поток.

Что читать дальше