Иногда нет необходимости добавлять огромные библиотеки магазина в приложение Angular. Все, что нам нужно, это библиотека RxJs, которая предоставляет наблюдаемые и операторы. В статье мы разберемся, как создать управление состоянием с нуля с помощью Subject и Observables на примере приложения Angular для поиска рецептов.

В чем проблема?

У нас есть несколько компонентов или даже модулей вокруг приложения, которые используют одни и те же данные. Как легко предоставить и обновить эти данные? Где его хранить?

Мистер Магазин спешит на помощь!

Определение магазина

Давайте создадим отдельный сервис, в котором мы хотим сохранить все, что мы можем использовать в любом компоненте проекта. Также мы хотели бы брать любые значения из нашего Магазина и, кроме того, подписываться на любые изменения.

export interface Store {
  recipes: Recipe[];
  savedRecipes: Set<Recipe>;
}
@Injectable({
  providedIn: 'root',
})
export class StoreService {
  private store: BehaviorSubject<Store>;

  constructor() {
    this.store = new BehaviorSubject<Store>({
      recipes: [],
      savedRecipes: new Set<Recipe>()
    });
  }
}

Служба Store имеет частное инкапсулированное поле store, которое является BehaviourSubject. Субъект магазина будет хранить наши данные. Есть две сущности, к которым мы хотим иметь доступ везде, описанные интерфейсом Store (рецепты, которые ищет пользователь, и сохраненные рецепты, которые пользователь сохраняет, взяв их из рецептов, мы вернемся к ним позже).

Что такое BehaviourSubject? Почему мы это используем?

BehaviourSubject - это тип Subject, который является горячим наблюдаемым.

Основная особенность его использования заключается в том, что BehaviourSubject всегда имеет значение (потому что он инициализирован этим значением) и сохраняет последнее. Следовательно, BehaviourSubject всегда выдает значение, в отличие от Subject, даже если оно не обновляется методом next.

Значение может быть получено методом getValue () и обновлено методом next (value).

Принимая все во внимание, BehaviourSubject идеально подходит для сохранения и обновления значений нашего магазина. Добавим для этого методы:

setItem(key: keyof Store, value: any) {
  this.store.next({
    ...this.store.getValue(),
    [key]: value,
  });
}

private getItem(key: keyof Store): any {
  return this.store.getValue()[key];
}
  • Метод setItem сохраняет новые значения в Store. Он принимает ключ и значение: ключ - это имя сущности Store, а значение - это сущность, которая будет сохранена в Store. Мы используем следующий метод BehaviourSubject для сохранения обновленного Store с помощью синтаксиса распространения объекта (мы берем предыдущие значения с помощью метода this.store.getValue ()).
  • getItem - частный метод для получения значений из Магазина в текущий момент. Метод получает значение по определенному ключу из Магазина. Мы используем метод getValue BehaviourSubject, чтобы получить сохраненные значения в нашем магазине (который является object ‹Store›) и получить конкретное значение с помощью предоставленного ключевого параметра. Основная идея использовать этот метод для взятия начальных значений для будущих обновлений в других методах Store. Мы не публикуем его, чтобы не смешивать синхронные и асинхронные данные.

Мы почти закончили строительство собственного магазина. Однако у нас по-прежнему нет возможности подписаться на какие-либо изменения. По этой причине нам нужен наблюдаемый объект, который можно использовать для подписки.

Subject тоже является Observable, и мы тоже можем подписаться на него. Чтобы инкапсулировать наш Магазин от предоставления непредсказуемых значений с помощью метода «next», мы сохраняем наблюдаемое Subject в новом общедоступном поле.

Давайте создадим Observable и метод для подписки на конкретный объект Store:

export interface Store {
  recipes: Recipe[];
  savedRecipes: Set<Recipe>;
}
@Injectable({
  providedIn: 'root',
})
export class StoreService {
  private store: BehaviorSubject<Store>;
  store$: Observable<Store>;
constructor() {
    this.store = new BehaviorSubject<Store>({
      recipes: [],
      savedRecipes: new Set<Recipe>()
    });
    this.store$ = this.store.asObservable();
}
getItem$(key: keyof Store): Observable<any> {
  return this.store$
    .pipe(
      map((store: Store) => store[key]),
      distinctUntilChanged(),
    );
}
...

store $ обрабатывается с помощью оператора map, откуда возвращается store [key]. Кроме того, здесь independentUntilChanged используется для предотвращения дополнительных обновлений там, где будет использоваться подписка. Новый поток будет передан только в том случае, если значение отличается от предыдущего с использованием сравнения=== по умолчанию (ссылки на объекты должны совпадать).

Это означает, что нам не следует беспокоиться о других объектах в магазине, наша подписка будет работать только с определенным полем и при его изменении и игнорировать другие обновленные значения. Действительно, это полезный оператор.

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

Я создал приложение, которое помогает пользователям искать рецепты по запросу и сохранять некоторые из них. Я использовал api edamam.com для поиска рецептов. Полный код вы можете найти на моей странице GitHub.

В приложении есть две вкладки для отображения поля поиска с найденными рецептами и сохраненными рецептами.

Пользователь пишет поисковый запрос и использует фильтры, чтобы установить свои предпочтения для рецептов на первой вкладке. При отправке мы вызываем метод getRecipes в компоненте SearchForm, который внедряет службу рецептов. Мы получаем рецепты и сохраняем там результат в Store:

getRecipes(searchParams: SearchRecipesParams): Observable<Recipe[]> {
  this.store.setItem('recipes', null);

  return this.apiService.getRecipes(searchParams)
    .pipe(
      tap((recipes: Recipe[]) => {
        if (!recipes.length) {
          throw new Error('Nothing found');
        }
      }),
      tap((recipes: Recipe[]) => this.store.setItem('recipes', recipes)),
    );
}

Мы показываем найденные рецепты через async pipe в компоненте Recipes. Мы берем его из Магазина как Observable, используя метод getItem $:

<ul class="recipes">
  <li *ngFor="let recipe of recipes$ | async">
    <app-recipe [recipe]="recipe"
                [isSaved]="isSaved(recipe)"
                (toggleRecipe)="toggleRecipe($event)"></app-recipe>
  </li>
</ul>

В то же время, у нас есть возможность сохранить туда часть рецептов для второй вкладки. Мы перемещаем конкретную логику для сохранения (или удаления) рецепта в Store и вызываем метод только в компонентах.

toggleSavedRecipe(recipe: Recipe) {
  const savedRecipes = this.getItem('savedRecipes');
  savedRecipes.has(recipe)
    ? savedRecipes.delete(recipe)
    : savedRecipes.add(recipe);
  this.setItem('savedRecipes', savedRecipes);
}

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

Наконец, мы показываем сохраненные рецепты на второй вкладке, беря значения из магазина, и переключаем рецепты так же, как и на первой вкладке.

Вот и все!

Буду рад ответить на ваши вопросы и услышать от вас отзывы :)