Иногда нет необходимости добавлять огромные библиотеки магазина в приложение 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.
Наконец, мы показываем сохраненные рецепты на второй вкладке, беря значения из магазина, и переключаем рецепты так же, как и на первой вкладке.
Вот и все!
Буду рад ответить на ваши вопросы и услышать от вас отзывы :)