Прекратите использовать Ngrx/effects для этого

Автор оригинала: Michael Pearson
  • Перевод
перебор с эффектами

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

Примечания переводчика

Статья была написана в 2017 году, но актуальна и по сей день. Нацелена на людей опытных в RxJS и Ngrx, либо желающих попробовать Redux в Angular.


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

Ngrx/store — это библиотека Angular, которая помогает сдерживать сложность отдельных функций. Одна из причин заключается в том, что ngrx/store охватывает функциональное программирование, которое ограничивает то, что может быть сделано внутри функции, для достижения большей разумности вне ее. В ngrx/store такие вещи как reducers (далее редюсеры), selectors (далее селекторы) и операторы RxJS являются чистыми функциями.


Чистые функции проще тестировать, отлаживать, анализировать, распараллеливать и комбинировать. Функция чиста, если:


  • при одинаковых входных данных она всегда возвращает одни и те же выходные данные;
  • не имеет побочных эффектов.

Побочных эффектов невозможно избежать, но они изолированы в ngrx/store, так что остальная часть приложения может состоять из чистых функций.


Побочные эффекты


Когда пользователь отправляет форму, нам нужно внести изменения на сервере. Изменение на сервере и ответ клиенту является побочным эффектом. Это может быть обработано в компоненте:


this.store.dispatch({
  type: 'SAVE_DATA',
  payload: data,
});
this.saveData(data) // POST запрос к серверу
  .pipe(map(res => this.store.dispatch({ type: 'DATA_SAVED' })))
  .subscribe();

Было бы хорошо, если бы мы могли просто отправлять (dispatch) action (далее действие) внутри компонента, когда пользователь отправляет форму, и обрабатывать побочный эффект в другом месте.


Ngrx/effects — это middleware для обработки побочных эффектов в ngrx/store. Он прослушивает отправленные действия в потоке observable, выполняет побочные эффекты и возвращает новые действия немедленно или асинхронно. Возвращенные действия передаются в reducer.


Возможность обрабатывать побочные эффекты RxJS-способом делает код чище. После отправки начального действия SAVE_DATA из компонента вы создаете класс эффектов для обработки остальных:


@Effect()
saveData$ = this.actions$.pipe(
  ofType('SAVE_DATA'),
  pluck('payload'),
  switchMap(data => this.saveData(data)),
  map(res => ({ type: 'DATA_SAVED' })),
);

Это упрощает работу компонента лишь до отправки действий и подписки на observable.


Легко злоупотребить Ngrx/effects


Ngrx/effects — очень мощное решение, поэтому им легко злоупотребить. Вот некоторые распространенные анти-паттерны ngrx/store, которые Ngrx/effects упрощает:


1. Дублированное состояние


Допустим, вы работаете над каким-то мультимедийным приложением, и у вас есть следующие свойства в дереве состояний:


export interface State {
  mediaPlaying: boolean;
  audioPlaying: boolean;
  videoPlaying: boolean;
}

Поскольку аудио является типом мультимедиа, всякий раз, когда audioPlaying имеет значение true, mediaPlaying также должно иметь значение true. Итак, вот вопрос: «Как мне убедиться, что mediaPlaying обновляется при обновлении audioPlaying


Неверный ответ: используйте Ngrx/effects!


@Effect()
playMediaWithAudio$ = this.actions$.pipe(
  ofType('PLAY_AUDIO'),
  map(() => ({ type: 'PLAY_MEDIA' })),
);

Правильный ответ: если состояние mediaPlaying полностью предсказывается другой частью дерева состояний, то это не истинное состояние. Это производное состояние. Это принадлежит селектору, а не store.


audioPlaying$ = this.store.select('audioPlaying');
videoPlaying$ = this.store.select('videoPlaying');
mediaPlaying$ = combineLatest(this.audioPlaying$, this.videoPlaying$).pipe(
  map(([audioPlaying, videoPlaying]) => audioPlaying || videoPlaying),
);

Теперь наше состояние может оставаться чистым и нормализованным, и мы не используем Ngrx/effects для чего-то, что не является побочным эффектом.


2. Сцепление действий c reducer


Представьте, что у вас есть эти свойства в вашем дереве состояний:


export interface State {
  items: { [index: number]: Item };
  favoriteItems: number[];
}

Затем пользователь удаляет элемент. Когда запрос на удаление возвращается, действие DELETE_ITEM_SUCCESS отправляется, чтобы обновить состояние нашего приложения. В редюсере items отдельный Item удаляется из объекта items. Но если этот идентификатор элемента был в массиве favoriteItems, элемент, на который он ссылается, будет отсутствовать. Итак, вопрос в том, как я могу убедиться, что идентификатор удален из favoriteItems при отправке действия DELETE_ITEM_SUCCESS?


Неверный ответ: используйте Ngrx/effects!


@Effect()
removeFavoriteItemId$ = this.actions$.pipe(
  ofType('DELETE_ITEM_SUCCESS'),
  map(() => ({ type: 'REMOVE_FAVORITE_ITEM_ID' })),
);

Итак, теперь у нас будет два действия, отправляемых друг за другом, и два редюсера, возвращающие новые состояния друг за другом.


Правильный ответ: DELETE_ITEM_SUCCESS может обрабатываться как редюсером items, так и редюсером favoriteItems.


export function favoriteItemsReducer(state = initialState, action: Action) {
  switch (action.type) {
    case 'REMOVE_FAVORITE_ITEM':
    case 'DELETE_ITEM_SUCCESS':
      const itemId = action.payload;
      return state.filter(id => id !== itemId);
    default:
      return state;
  }
}

Цель действий — отделить то, что произошло, от того, как должно измениться состояние. То, что произошло, было DELETE_ITEM_SUCCESS. Задача редюсеров — вызвать соответствующее изменение состояния.


Удаление идентификатора из favoriteItems не является побочным эффектом удаления Item. Весь процесс полностью синхронен и может быть обработан редюсерами. Ngrx/effects не нужен.


3. Запрос данных для компонента


Вашему компоненту нужны данные из store, но сначала их нужно получить с сервера. Вопрос в том, как мы можем поместить данные в store, чтобы компонент мог их получить?


Болезненный способ: используйте Ngrx/effects!


В компоненте мы инициируем запрос, отправив действие:


ngOnInit() {
  this.store.dispatch({ type: 'GET_USERS' });
}

В классе эффектов мы слушаем GET_USERS:


@Effect
getUsers$ = this.actions$.pipe(
  ofType('GET_USERS'),
  withLatestFrom(this.userSelectors.needUsers$),
  filter(([action, needUsers]) => needUsers),
  switchMap(() => this.getUsers()),
  map(users => ({ type: 'RECEIVE_USERS', users })),
);

Теперь предположим, что пользователь решает, что определенный route грузится слишком много времени, поэтому он с него переходит на другой. Чтобы быть эффективными и не загружать ненужные данные, мы хотим отменить этот запрос. Когда компонент будет уничтожен, мы отменим подписку на запрос, отправив действие:


ngOnDestroy() {
  this.store.dispatch({ type: 'CANCEL_GET_USERS' });
}

Теперь в классе эффектов мы слушаем оба действия:


@Effect
getUsers$ = this.actions$.pipe(
  ofType('GET_USERS', 'CANCEL_GET_USERS'),
  withLatestFrom(this.userSelectors.needUsers$),
  filter(([action, needUsers]) => needUsers),
  map(([action, needUsers]) => action),
  switchMap(
    action =>
      action.type === 'CANCEL_GET_USERS'
        ? of()
        : this.getUsers().pipe(map(users => ({ type: 'RECEIVE_USERS', users }))),
  ),
);

Хорошо. Теперь другой разработчик добавляет компонент, которому требуется тот же HTTP-запрос (мы не будем делать никаких предположений о других компонентах). Компонент отправляет те же действия в тех же местах. Если оба компонента активны одновременно, первый компонент для своей инициализации инициирует HTTP-запрос. Когда второй компонент инициализируется, ничего дополнительного не произойдет, потому что needUsers будет false. Замечательно!


Затем, когда первый компонент будет уничтожен, он отправит CANCEL_GET_USERS. Но второй компонент все еще нуждается в этих данных. Как мы можем предотвратить отмену запроса? Может быть заведем счетчик всех подписчиков? Я не собираюсь реализовывать это, но вы, полагаю, поняли суть. Мы начинаем подозревать, что есть лучший способ управления этими зависимостями данных.


Теперь предположим, что появляется еще один компонент, и он зависит от данных, которые невозможно извлечь до тех пор, пока данные users не появятся в store. Это может быть подключение к веб-сокету для чата, дополнительная информация о некоторых пользователях или что-то еще. Мы не знаем, будет ли этот компонент инициализирован до или после подписки двух других компонентов на users.


Лучшая помощь, которую я нашел для этого конкретного сценария, — это этот отличный пост. В его примере callApiY требует, чтобы callApiX уже был завершен. Я убрал комментарии, чтобы это выглядело менее пугающе, но не стесняйтесь читать оригинальный пост, чтобы узнать больше:


@Effect()
actionX$ = this.actions$.pipe(
  ofType('ACTION_X'),
  map(toPayload),
  switchMap(payload =>
    this.api.callApiX(payload).pipe(
      map(data => ({ type: 'ACTION_X_SUCCESS', payload: data })),
      catchError(err => of({ type: 'ACTION_X_FAIL', payload: err })),
    ),
  ),
);

@Effect()
actionY$ = this.actions$.pipe(
  ofType('ACTION_Y'),
  map(toPayload),
  withLatestFrom(this.store.select(state => state.someBoolean)),
  switchMap(([payload, someBoolean]) => {
    const callHttpY = v => {
      return this.api.callApiY(v).pipe(
        map(data => ({
          type: 'ACTION_Y_SUCCESS',
          payload: data,
        })),
        catchError(err =>
          of({
            type: 'ACTION_Y_FAIL',
            payload: err,
          }),
        ),
      );
    };

    if (someBoolean) {
      return callHttpY(payload);
    }
    return of({ type: 'ACTION_X', payload }).merge(
      this.actions$.pipe(
        ofType('ACTION_X_SUCCESS', 'ACTION_X_FAIL'),
        first(),
        switchMap(action => {
          if (action.type === 'ACTION_X_FAIL') {
            return of({
              type: 'ACTION_Y_FAIL',
              payload: 'Because ACTION_X failed.',
            });
          }
          return callHttpY(payload);
        }),
      ),
    );
  }),
);

Теперь добавьте требование, что HTTP-запросы должны быть отменены, когда компоненты в них больше не нуждаются, и это станет еще более сложным.


                            . . .


Итак, почему так много проблем с управлением зависимостями данных, когда RxJS должен делать это действительно легко?


Хотя данные, поступающие с сервера, технически являются побочным эффектом, мне не кажется, что Ngrx/effects — лучший способ справиться с этим.


Компоненты — это интерфейсы ввода/вывода для пользователя. Они показывают данные и отправляют действия, произведенные им. Когда компонент загружается, он не отправляет никаких действий, произведенных этим пользователем. Он хочет показать данные. Это скорее похоже на подписку, нежели на побочный эффект.


Очень часто можно увидеть приложения, использующие действия для инициации запроса данных. Эти приложения реализуют специальный интерфейс для observable через побочные эффекты. И, как мы видели, этот интерфейс может стать очень неудобным и громоздким. Подписываться на, отписываться от и связывать сами observable гораздо проще.


                            . . .


Менее болезненный способ: компонент зарегистрирует свою заинтересованность в данных, подписавшись на них через observable.


Мы создадим observable, которые содержат нужные HTTP-запросы. Мы увидим, насколько проще управлять несколькими подписками и цепочками запросов, зависящими друг от друга, используя чистый RxJS, нежели делать это через эффекты.


Создадим эти observable в сервисе:


requireUsers$ = this.store.pipe(
  select(selectNeedUser),
  filter(needUsers => needUsers),
  tap(() => this.store.dispatch({ type: 'GET_USERS' })),
  switchMap(() => this.getUsers()),
  tap(users => this.store.dispatch({ type: 'RECEIVE_USERS', users })),
  finalize(() => this.store.dispatch({ type: 'CANCEL_GET_USERS' })),
  share(),
);

users$ = muteFirst(
  this.requireUsers$.pipe(startWith(null)),
  this.store.pipe(select(selectUsers)),
);

Подписка на users$ будет передаваться как на requireUsers$, так и на this.store.pipe(select(selectUsers)), но данные будут получены только от this.store.pipe(select(selectUsers)) (пример реализации muteFirst и исправленный muteFirst с ее тестом.)


В компоненте:


ngOnInit() {
  this.users$ = this.userService.users$;
}

Поскольку эта зависимость данных теперь простой observable, мы можем подписаться и отписаться в шаблоне, используя async pipe, и нам больше не нужно отправлять действия. Если приложение уходит с роута последнего компонента, подписанного на данные, HTTP-запрос отменяется или веб-сокет закрывается.


Цепочку зависимостей данных можно обрабатывать так:


requireUsers$ = this.store.pipe(
  select(selectNeedUser),
  filter(needUsers => needUsers),
  tap(() => this.store.dispatch({ type: 'GET_USERS' })),
  switchMap(() => this.getUsers()),
  tap(users => this.store.dispatch({ type: 'RECEIVE_USERS', users })),
  share(),
);

users$ = muteFirst(
  this.requireUsers$.pipe(startWith(null)),
  this.store.pipe(select(selectUsers)),
);

requireUsersExtraData$ = this.users$.pipe(
  withLatestFrom(this.store.pipe(select(selectNeedUsersExtraData))),
  filter(([users, needData]) => Boolean(users.length) && needData),
  tap(() => this.store.dispatch({ type: 'GET_USERS_EXTRA_DATA' })),
  switchMap(() => this.getUsers()),
  tap(users =>
    this.store.dispatch({
      type: 'RECEIVE_USERS_EXTRA_DATA',
      users,
    }),
  ),
  share(),
);

public usersExtraData$ = muteFirst(
  this.requireUsersExtraData$.pipe(startWith(null)),
  this.store.pipe(select(selectUsersExtraData)),
);

Вот параллельное сравнение вышеупомянутого метода с этим методом:


параллельное сравнение

Использование чистого observable требует меньше строк кода и автоматически отписывается от зависимостей данных по всей цепочке. (Я пропустил операторы finalize, которые изначально были включены, чтобы сделать сравнение более понятным, но даже без них запросы все равно будут соответственно отменены.)


эффекты как вишинка в коктейле

Заключение


Ngrx/effects — прекрасный инструмент! Но рассмотрите эти вопросы, прежде чем использовать его:


  • Это действительно побочный эффект?
  • Действительно ли Ngrx/effects — лучший способ сделать это?
  • +12
  • 4,2k
  • 4
Поделиться публикацией

Комментарии 4

    +1
    От «редуктора» покоробило. Лучше было остаться с редюсером.

    Правильный ответ: DELETE_ITEM_SUCCESS может обрабатываться как редуктором items, так и редуктором favoriteItems.
    Это тоже неправильный ответ. Ну или не лучший из правильных. Я реализую это как действия редюсера, который стоит в узле дерева выше этих двух. То есть у одного Action всегда только один редюсер, который его обрабатывает. А этот родительский редюсер в свою очередь вызывает соответствующие чистые функции создающие новое дочернее состояние для этого родителя. Родительский редюсер же интегрирует это в состояние. примерно так.
    export function parentReducer(prev: State, action: IAction){
    switch (action.type)
    case Remove:
    return {
    ...prev,
    items: childFunctions.removeItem(prev.items, action.itemId),
    favoriteItems: childFunctions.removeItem(prev.favoriteItems, action.itemId),
    };
    }

      0
      Воспользовался вашим советом по поводу редюсера. Не ахти какой перевод, да лучше не придумать.
      А по поводу «DELETE_ITEM_SUCCESS» — хорошее замечание. Сам бы я тоже не стал делать 2 отдельных состояния. Но, знаете, в реальной разработке всякое бывает. Может быть автор был вынужден так реализовать.
      В любом случае, для меня эта статья совсем о другом, в большей степени про подход использования сервисов с чистым RxJS взамен Ngrx/effects.
      +1
      В текущем рабочем проекте для написания большого монолитного frontend-приложения на Angular решили брать полный инструментарий ngrx (store, entity, effects), основное применение effects в проекте — асинхронные операции (запросы к бэкенду).

      В результате любой модуль обрастает излишней сложностью, например, если есть задача отслеживания контекста, из которого был отправлен action — допустим, нам нужно сообщить пользователю, что данные были успешно загружены с бэкенда, но только если пользователь ушел со страницы, с которой был отправлен action.

      По личным ощущениям, можно оставлять только store и entity модули ngrx библиотеки, и делать любые максимально гибкие и расширяемые UX сценарии.
        0
        Я с вами по большей части согласен. Но а от effects совсем отказываться не стоит. Есть редкие задачи, когда удобно следить именно за потоком экшенов. По моему опыту большинство задач требует слежения за потоком изменений данных в state, тогда я применяю сервис с чистым RxJS.
        Тем самым, если задача требует слежения за потоком экшенов — effects, за потоком данных в state — сервис с RxJS.

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое