RxJS и Angular: декларативный If/Else

Исходный код: Github 🚀 | Интерактивные примеры и фрагменты кода🚀

Цель статьи 🎯

Задействование оператора фильтра RxJS. Использование последовательной парадигмы декларативного программирования для потенциального улучшения ясности кода и удобства для восприятия человеком.

Ключевые понятия 📝

  • Поток управления  —  это последовательность выполнения или вычисления отдельных операторов, инструкций или вызовов функций императивной программы.
  • Ветвление, при котором выполняются различные вычисления или действия в зависимости от того, принимает ли указанное программистом логическое условие значение true или false.
  • Декларативное программирование  —  это парадигма программирования (стиль построения структуры и элементов компьютерных программ), которая выражает логику без описания потока управления.
  • Реактивное программирование —  это парадигма декларативного программирования, связанная с потоками данных и распространением изменений. RxJS является библиотекой для реактивного программирования, т. е. RxJS относится к парадигме декларативного программирования.
  • Оператор фильтра RxJS, при котором фильтруются элементы, выдаваемые исходным Observable (выдаются только те, которые удовлетворяют заданному предикату).

Объяснение и получаемые результаты 🧪

Акцент на явно определенном потоке управления  —  вот что отличает императивное программирование от декларативного.

Большинство из нас (в том числе и я) комбинирует RxJS с императивным программированием. Например, при написании условных операторов (if/else):

Императивное

isAuthenticated$ = selectAuthentication().pipe(
 tap(auth => {
  if (isAuthenticated) {
   router.navigate('/home');
  } else {
   router.navigate('/login');
  } 
 })
);

const isAuthenticatedSub = isAuthenticated$.subscribe();

С использованием потоков (Observables), композиции (merge) и оператора фильтра создается тот же результат без написания if / else.

💡 Обратите внимание: императивное программирование  —  это не что-то плохое или антишаблон, а просто еще один способ достижения того же результата.

Так:

const isAuthenticated$ = selectAuthentication().pipe(filter(auth => auth), tap(_ => router.navigate('/home'));
const isNotAuthenticated$ = selectAuthentication().pipe(filter(auth => !auth), tap(_ => router.navigate('/login'));
const authSub = merge(isAuthenticated$, isNotAuthenticated$).subscribe();

Причем здесь вместо filter(auth => auth) задействуют также и filter(Boolean).

Или даже вот так (декларативное):

```typescript

const [isAuthenticated$, isNotAuthenticated$] = partition(selectAuthentication(), x	=> !!x);

const authSub = merge(isAuthenticated$, isNotAuthenticated$).subscribe();

```

Главное преимущество  —  в поддержании одной парадигмы программирования (декларативной). По-моему, это улучшает ясность кода и удобство его восприятия человеком в следующих ниже примерах.

Как работает фильтр

Фильтр предотвращает выдачу Observable с ложным предикатом. Используем merge для объединения обоих observables. А затем получаем выдаваемые из observable значения с помощью передающего предиката/условия:

В нашем примере, если пользователь не выполнит вход, будет выдано isNotAuthenticated$. После чего он будет перенаправлен к экрану авторизации. isAuthenticated$ не будет выдано, поэтому его перенаправление не выполнится:

Примеры 🎨

С использованием Angular, Akita и Angular In-Memory API

Простой пример

Переключение побочных эффектов на основе перечисления статуса.

Возьмем более практический пример. Имеется таблица «Рабочие заказы». Когда тот или иной рабочий заказ активируется, мы запускаем логику, основанную на его статусе.

fbListener$: Observable<WorkOrder> = this.workOrderState$.pipe(
  take(1),
  tap((wo: WorkOrder) => this.activeWoForm = this.buildForm(wo))
);
isOpen$ = this.fbListener$.pipe(filter((wo) => WoStatus.OPEN === wo.status));
isClosed$ = this.fbListener$.pipe(filter((wo) => WoStatus.CLOSED === wo.status));
isInProgress$ = this.fbListener$.pipe(filter((wo) => WoStatus.IN_PROGRESS === wo.status));
isOnHold$ = this.fbListener$.pipe(
  filter((wo) => WoStatus.ON_HOLD === wo.status),
  tap((_) => this.alertUserOnHold()),
  tap((_) => this.disableFieldsOnHold())
);
statusSideEffectsListener$ = merge(
  this.isOpen$,
  this.isClosed$,
  this.isOnHold$,
  this.isInProgress$
);

Полный исходный код | Примечание: для краткости побочные эффекты помещены только в ON_HOLD.

Подписавшись на statusSideEffectsListener$, мы затем с помощью передающего предиката/условия получаем выдаваемые из внутреннего observable значения.

Так, если у нас статус рабочего заказа ON_HOLD, выполняться будет только логика в isOnHold$.

Для удаления повторяющегося кода создадим вот такие удобные методы:

export function filterByStatus(status) {
  return filter((wo: WorkOrder) => status === wo.status)
}
isOpen$ = this.formBuilderListener$.pipe(filterByStatus(WorkOrderStatus.OPEN));
isClosed$ = this.formBuilderListener$.pipe(filterByStatus(WorkOrderStatus.CLOSED));
isInProgress$ = this.formBuilderListener$.pipe(filterByStatus(WorkOrderStatus.IN_PROGRESS));
isOnHold$ = this.formBuilderListener$.pipe(
  filterByStatus(WorkOrderStatus.ON_HOLD),
  // ...
);

Пример с вложенными условиями

С выбором имеющегося или созданием нового рабочего заказа.

Создадим эквивалент вложенных условий с помощью switchMap.

Немного разовьем исходный пример. Добавим логику активации / побочные эффекты, используя тот же шаблон. Но логику statusSideEffectsListener$ надо запускать, только если у нас есть активный рабочий заказ.

futureWoDispatcher = new Subject<ID | null>();
  dispatchWo = (id: ID | null) => this.futureWoDispatcher.next(id);
  createNewWoListener$: Observable<void> = this.futureWoDispatcher.pipe(
    filter((id) => id && id === NEW_WO),
    map((id) => createWorkOrder({ id: guid() })),
    switchMap((wo) => this.add(wo)),
    tap((wo: WorkOrder) => this.setActive(wo.id)),
    tap((wo: WorkOrder) => this.toast.success("Created WO - " + wo.id)),
    switchMap(_ => this.statusSideEffectsListener$),
  );
  loadExistingWoListener$: Observable<void> = this.futureWoDispatcher.pipe(
    filter((id) => id && id !== NEW_WO),
    tap((id: ID) => this.setActive(id)),
    tap((id: ID) =>  this.toast.success("Loaded WO - " + id)),
    switchMap(_ => this.statusSideEffectsListener$)
  );
  deactivateWoListener$: Observable<void> = this.futureWoDispatcher.pipe(
    filter((wo) => !wo),
    map((_) => this.setActive(null))
  );
    
  activateWoListener$: Observable<void> = merge(
    this.createNewWoListener$,
    this.loadExistingWoListener$,
    this.deactivateWoListener$
  );

Здесь тоже, как и во втором примере этой статьи, возможны варианты: вместо switchMap(_ => this.statusSideEffectsListener$) некоторые используют switchMapTo(this.statusSideEffectsListener$). Но в этом случае надо быть осторожнее.

Полный исходный код | Использование

💡 Совет: при возвращенииObservable<void>лучше указать, что результатObservableбесполезен, так как его цель  —  запустить побочные эффекты.

При использовании того же шаблона, что и statusSideEffectsListener$:

  • с помощью createNewWoListener$ создается новый рабочий заказ;
  • с помощью loadExistingWoListener$ выбирается уже имеющийся рабочий заказ;
  • с помощью deactivateWoListener$ выбор уже имеющегося рабочего заказа отменяется.

Обратите внимание, что подписки на statusSideEffectsListener$ в этом контексте не происходит, так как switchMap для создания эквивалента вложенных условий в нем не используется.

Важно: для завершения после одной выдачи значений statusSideEffectsListener$ использует take(1). Таким образом предотвращается их выдача при каждом изменении активного рабочего заказа.

Спасибо за внимание!

Читайте также:

Читайте нас в Telegram, VK и Яндекс.Дзен


Перевод статьи Erxk: RxJS & Angular: Declarative If / Else

Предыдущая статьяМашинное обучение без данных
Следующая статьяЦепь Маркова