...

пятница, 9 апреля 2021 г.

[Перевод] Неудачные приёмы работы с NGRX

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

Никогда (почти никогда) не подписывайтесь на Store: используйте селекторы


Взгляните на этот код:
@Component({
  template: `
    <span></span>
  `
})
export class ComponentWithStore implements OnInit {
  name = '';
  
  constructor(store: Store<AppState>) {}
  
  ngOnInit() {
    this.store.subscribe(state => this.name = state.name);
  }
}

Как по мне, так это — просто ужас. Во-первых, подписка на хранилище означает, что позже нам надо будет от него отписаться (в данном примере это не реализовано), а это значит, что перед нами встанут дополнительные трудности. Во-вторых — это значит, что в компоненте будет присутствовать некий объём императивного кода. И наконец (хотя это — далеко не последний недостаток показанного здесь кода), тут возможности NGRX используются не в полном объёме. Попробуем улучшить код:
@Component({
  template: `
    <span></span>
  `
})
export class ComponentWithStore {
  name$ = this.store.select(state => state.name);
  
  constructor(store: Store<AppState>) {}
}

Этот вариант, конечно, лучше предыдущего. Нем теперь не только не надо отписываться от хранилища (асинхронный конвейер сделает всё сам), но и можно без проблем воспользоваться ChangeDetectionStrategy.OnPush ради улучшения производительности. Есть тут и ещё один плюс — нам удалось снизить объём кода и сделать его более декларативным.

Вот ещё пример:

@Component({
  template: `
    <span></span>
  `
})
export class ComponentWithStore implements OnInit {
  name = '';
  
  constructor(store: Store<AppState>) {}
  
  ngOnInit() {
    this.store.subscribe(state => {
      if (state.name === 'ReservedName') {
        this.store.dispatch(reservedNameEncountered());  
      }
    });
  }
}

Здесь мы проверяем состояние на предмет наличия в нём определённого значения и, обнаружив это значение, выполняем некое действие. С таким сценарием можно столкнуться в любом приложении, но неправильно реализовывать подобный функционал именно так. Потребители производного состояния не должны реагировать на изменение состояния, прибегая к действиям. Для этих целей у нас имеется класс Effects:
export class Effects {
  reservedName$ = createEffect(() => this.actions$.pipe(
    ofType(actions.setName),
    filter(({payload}) => payload === 'ReservedName'),
    map(() => reservedNameEncountered())
  ));
  
  constructor(actions$: Actions) {}

}

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

В заголовке этого раздела есть фраза «почти никогда», поэтому, очевидно, существуют ситуации, в которых подписка на Store имеет смысл. Представьте себе следующее: в нашем приложении имеется сложная система разрешений, регулируемых администратором, который может менять их в режиме реального времени. Эти разрешения, конечно, хранятся в AppState. Теперь представьте себе, что мы, в компоненте, пользуемся реактивными формами, при этом некоторые поля таких форм могут отключиться в процессе работы с формой, если администратор изменит разрешения пользователя в тот момент, когда он заполняет форму. Состояние при этом будет обновляться в режиме реального времени. Как отключить поле ввода, основываясь на разрешениях, сведения о которых находятся в хранилище?

@Component({
  template: `
    опущено для краткости
  `
})
export class ComponentWithStore implements OnInit {
  permissions$ = this.store.select(state => state.permissions);
  form = this.formBuilder.group({
    firstName: ['', Validators.require],
  });
  
  constructor(store: Store<AppState>) {}
  
  ngOnInit() {
    this.permissions$.subscribe(permissions => {
      const control = this.form.get('firstName');
      if (permissions.canEditFirstName) {
        control.enable();
      } else {
        control.disable();
      }
    });
  }
}

Дело тут в том, что отключить реактивный элемент FormControl можно только используя его метод disable(). Это, по своей природе, императивная операция, у которой нет декларативной альтернативы (при условии, что нам совершенно необходимо использовать реактивные формы). В данном случае, так как значение, отвечающее за отключение элемента управления, зависит от значения из нашего AppState, мы вынуждены подписаться на хранилище. Это приводит нас к следующему практическому правилу:

Никогда не подписывайтесь на Store вручную, за исключением тех случаев, когда необходимо выполнить императивную стороннюю функцию (вроде FormControl.disable()), и при этом у вас нет другой возможности добиться желаемого. Если же вы так и поступили — не забудьте отписаться!

Ещё один хороший и сравнительно новый способ решения подобных задач без оформления подписки заключается в использовании хранилища @ngrx/component-store с поддерживаемыми им эффектами компонентов.

Не пользуйтесь методом pipe() наблюдаемых объектов, выбранных из хранилища


Мы отказались от подписок на хранилище или на наблюдаемые объекты производного состояния. Поэтому теперь, по идее, всё должно быть совершенно замечательно. Но, на самом деле, не всё так просто. Взгляните на следующий код:
@Component({
  template: `
    <span *ngFor="let user of (activeUsers$ | async)"></span>
  `
})
export class ComponentWithStore {
  activeUsers$ = this.store.select(state => state.users).pipe(
    map(users => users.filter(user => user.isActive)),
  );
  
  constructor(store: Store<AppState>) {}
}

В этом примере исходное состояние хранится в AppState, в объекте Array, но в данном компоненте нам, на самом деле, нужен лишь список активных пользователей. Поэтому мы просто преобразуем наблюдаемый объект производного состояния с использованием операторов и чистых функций. А это, вероятно, не так уж и плохо.

Но дело тут в том, что даже самые простые RxJS-операторы устроены сложнее, чем простые функции-селекторы. В результате в коде появляется больше «информационного шума», а если речь идёт о сложной логике, то усложняется и отладка кода. Решим эту проблему, воспользовавшись именованным селектором:

// selectors.ts
const activeUsers = (state: AppState) => state.users.filter(user => user.isActive)

А после этого мы можем немедленно воспользоваться получившимся производным состоянием:
@Component({
  template: `
    <span *ngFor="let user of (activeUsers$ | async)"></span>
  `
})
export class ComponentWithStore {
  activeUsers$ = this.store.select(activeUsers);
  
  constructor(store: Store<AppState>) {}
}

В результате мы выходим на более компактный код, который легче читать. Глядя на этот код можно сразу же разобраться в том, что именно было выбрано, поэтому он отличается и большей декларативностью. Хорошей альтернативой этому неудачному приёму работы является использование NGRX-селекторов:

Не пользуйтесь pipe() и операторами для работы с производным состоянием. Вместо этого применяйте именованные селекторы.

Не пользуйтесь функцией combineLatest(). Вместо этого применяйте именованные селекторы


Иногда состояния приложений бывают очень сложными, они могут зависеть от нескольких сущностей. Иногда в основе производного состояния лежит не одно исходное состояние, а комбинация из двух или большего количества таких состояний. Вот пример. Представим, что у нас есть набор объектов, описывающих товары в магазине одежды. Это — объекты типа Clothing, хранящиеся в массиве. В проекте имеется корзина покупателя, в основе которой тоже лежит массив объектов Clothing. Добавлять в корзину товары (объекты Clothing) можно, пользуясь кнопкой «Добавить в корзину», но если соответствующий объект Clothing уже присутствует в корзине, нам надо показать пользователю другую кнопку, на которой написано «Убрать из корзины». Логика работы с корзиной не слишком сложна, нам, для её реализации, нужно лишь добавить к каждому объекту Clothing из производного списка элементов свойство isInShoppingCart. Его значение указывает на то, имеется ли в массиве корзины элемент с соответствующим id. У нас есть два селектора, которые позволяют выбирать все элементы и массив, представляющий корзину. Вот код компонента:
@Component({
  template: `
    <app-clothing-item
      *ngFor="let item of (clothingItems$ | async)" [item]="item">
    </app-clothing-item>
  `
})
export class ClothingItemListComponent {
  clothingItems$ = combineLatest([
    this.store.select(state => state.clothingItems),
    this.store.select(state => state.cart),
  ]).pipe(
    map(([items, cart]) => items.map(item => ({
      ...item,
      isInShoppingCart: cart.map(cartItem => cartItem.id).includes(item.id),
    })))
  );
  
  constructor(store: Store<AppState>) {}
}

Сейчас вполне очевидно то, что эта логика выглядит немного сложнее, чем логика, которую стоило бы размещать в классе компонента. Кроме того, опять же, хотя логика эта и декларативна, для того чтобы разобраться в том, что тут происходит, нужно внимательно прочесть код и как следует его понять. Что делать в такой ситуации? Ответ, снова, заключается в применении селекторов. NGRX позволяет комбинировать селекторы с использованием createSelector:
const allItems = (state: AppState) => state.clothingItems;

const shoppingCart = (state: AppState) => state.shoppingCart;

const cartIds = createSelector(shoppingCart, cart => cart.map(item => item.id));

const clothingItems = createSelector(
  allItems,
  cartIds,
  (items, cart) => items.map(item => ({
    ...item,
    isInShoppingCart: cart.includes(item.id),
  }),
);

Теперь код, благодаря наличию в нём новых функций, получился гораздо понятнее. Сначала мы выбираем все элементы и то, что находится в корзине. Затем создаём ещё один селектор, который выбирает лишь идентификаторы элементов, находящихся в корзине (селекторы мемоизированы). И, наконец, мы комбинируем два селектора для того чтобы преобразовать список всех элементов. В данном конкретном компоненте мы используем лишь результирующий селектор. Тут у вас может появиться вопрос о том, почему мы создали четыре селектора. Дело тут в том, что лучше иметь много простых селекторов, чем меньшее количество селекторов более сложных. Это упрощает многократное использование кода и создание нового кода путём компоновки того, который уже имеется в проекте. Отсюда выводим следующее правило:

Если вы замечаете, что combineLatest() используется в сочетании с производным состоянием — подумайте о комбинировании селекторов с применением createSelector().

Постарайтесь избегать модификации вложенного состояния с использованием withLatestFrom


Иногда выполнение эффектов требует, чтобы мы приняли бы во внимание ещё какое-то состояние, уже имеющееся в хранилище. Например — представьте, что у нас есть таблица, которую можно фильтровать и сортировать с использованием каких-то кнопок. Имеется действие setSorting, которое принимает объект, задающий порядок сортировки ({field: string, direction: -1 | 1}), и добавляет его к объекту query, который содержит все сведения о соответствующем запросе (фильтрация, разбиение результатов на страницы, сортировка — для выполнения каждой из этих операций имеется собственное действие). Потом setSorting отправляет получившийся объект на сервер, пользуясь соответствующей службой. При этом сервер принимает лишь полностью оформленные запросы (содержащие сведения о сортировке, о фильтрации и о разбиении таблицы на страницы), а не отдельные элементы запросов, но действие setSorting выполняет лишь модификацию той части запроса, которая имеет отношение к сортировке (модифицирует вложенное состояние). И в компоненте, из которого мы отправляем действие setSorting, может и не быть полного запроса. Всё это может склонить нас к написанию следующего кода в эффекте:
@Injectable()
export class Effects {

  getData$ = createEffect(() => this.actions$.pipe(
    ofType(setSorting, setFilters, setPagination),
    withLatestFrom(this.store.select(state => state.query)),
    map(([{payload}, query]) => ({...query, [payload.type]: payload.data})),
    exhaustMap(query => this.dataService.getData(query).pipe(
      map(response => getDataSuccess(response)),
      catchError(error => of(getDataError(error)))
    )),
  ));

  constructor(
    private readonly actions$: Actions,
    private readonly store: Store<AppState>,
    private readonly dataService: DataService,
  ) {}

}

Тут мы, в отчаянной попытке избежать выполнения неких действий в компоненте, сделали много нехорошего. Для начала — обратите внимание на то, как мы работаем с тремя действиями, которые отправляют один и тот же запрос (одно — для сортировки, одно — для фильтрации, одно — для разбиения таблицы на страницы). Мы, кроме того, принимаем уже существующее состояние, пользуясь withLatestFrom. Что теперь с этим всем делать? Начнём с избавления от трёх отдельных действий (setSorting, setFilters и setPagination). Прислушаемся к Майку Райану, который в этом видео говорит о том, что очень важно, чтобы код действий был бы чистым и компактным. Теперь у нас имеется лишь одно действие, getData, которое принимает весь объект запроса. А в компоненте мы сделаем следующее:
@Component({
  template: `
    <ng-container *ngIf="query$ | async as query">
      <app-sorting (sort)="setSorting($event, query)"></app-sorting>
      <app-filters (filter)="setFilters($event, query)"></app-filters>
      <app-table-data [data]="data$ | async"></app-table-data>
      <app-pagination (paginate)="setPagination($event, query)"></app-pagination>
    </ng-container>
  `,
})
export class TablePresenterComponent {

  query$ = this.store.select(state => state.query);
  data$ = this.store.select(state => state.data);

  constructor(
    private readonly store: Store<AppState>,
  ) {}
  
  setSorting(sorting: Sorting, query: Query) {
    this.store.dispatch(getData({...query, sorting}));
  }
  
  setFilters(sorting: Sorting, filters: Filters) {
    this.store.dispatch(getData({...query, filters}));
  }
  
  setPagination(sorting: Sorting, pagination: Pagination) {
    this.store.dispatch(getData({...query, pagination}));
  }

}

Обратите внимание на то, что код компонента не потерял простоты и компактности. Каждый его метод отвечает за поддержку отдельного сценария, отправляя при этом одно и то же действие. А из-за того, что тут используется одно и то же действие, мы теперь можем переработать и наш эффект, приведя его к такому виду:
@Injectable()
export class Effects {

  getTableData$ = createEffect(() => this.actions$.pipe(
    ofType(getData),
    exhaustMap(({payload}) => this.dataService.getData(payload).pipe(
      map(response => getDataSuccess(response)),
      catchError(error => of(getDataError(error)))
    )),
  ));

  constructor(
    private readonly actions$: Actions,
    private readonly dataService: DataService,
  ) {}

}

Теперь мы работаем лишь с одним действием, решаем одну простую задачу, и при этом мы избавились от withLatestFrom.

Наличие withLatestFrom в коде эффекта может говорить о невысоком качестве кода, что указывает на то, что некоторые механизмы программы, вероятно, можно реализовать проще.

Никогда не размещайте в хранилище производное состояние


Один из важных аспектов работы с NGRX (и, если уж на то пошло, с любой системой управления состоянием приложений) заключается в проведении различия между исходным и производным состоянием. Исходное состояние — это то, что хранится в AppState. Например — это данные, полученные от бэкенда приложения, которые тут же помещены в хранилище. А производное состояние — это такое состояние, которое получено на основе каких-то трансформаций исходного состояния, обычно выполняемых с помощью селекторов. Например, список элементов Clothing из примера о комбинировании селекторов — это яркий пример производного состояния.

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

  1. В хранилище находится больше данных, чем, на самом деле, нужно.
  2. Возникает необходимость в синхронизации двух частей состояния. Если, например, какое-то действие меняет исходное состояние, нужно модифицировать и производное состояние.
  3. Объект AppState выглядит весьма неопрятно.

Взгляните на этот пример:
const _reducer = createReducer(
  initialState,
  on(clothingActions.filter, (state, {payload}) => ({
    ...state,
    filteredClothings: state.clothings.filter(
      clothing => clothing.name.includes(query),
    ),
  })),
);

Тут совместно хранятся и исходное состояние (список всей одежды), и производное состояние (отфильтрованный список). Не лучше ли было бы хранить запрос и получать отфильтрованный список одежды, пользуясь селектором, комбинирующим этот запрос и весь список? Вот как это может выглядеть:
// reducer.ts

const _reducer = createReducer(
  initialState,
  on(clothingActions.filter, (state, {payload}) => ({
    ...state,
    query: payload,
  })),
);

// selectors.ts

const allClothings = (state: AppState) => state.clothings;

const query = (state: AppState) => state.query;

const filteredClothings = createSelector(
  allClothings,
  query,
  (clothings, query) => clothing.filter(
    clothing => clothing.name.includes(query),
  ),
);

А после этого в компоненте можно просто воспользоваться селектором производного состояния.

Итоги


Возможно, вы обратили внимание на то, что при решении многих проблем, связанных с неудачными приёмами работы в NGRX, применяются селекторы. Дело тут в том, что основная задача, которая стоит перед системами управления состоянием приложений заключается в различении производного и исходного состояний. Когда разработчику удаётся удачно разделить эти сущности и создать чёткие механизмы по работе с ними, по выбору из них того, что ему нужно, это означает, что такой разработчик получит простое, декларативное и реактивное Angular-приложение, в основе которого лежит NGRX.

Пользуетесь ли вы NGRX в своих проектах?

Let's block ads! (Why?)

Комментариев нет:

Отправить комментарий