Redux в Angular. State management стандартными средствами Angular.
В данной статье приводится управления данными с помощью стандартных средств Angular.
Реализация state management
в Angular
выглядит следующим образом:
Конечно, Angular не реализует концепты redux
, но в angular service
может делать все тоже самое, что и описывают концепты redux
.
Основной смысл — создать единый источник данных, который будет передаваться и обновлятся реактивно.
Так как сервис не зависит ни от каких библиотек, то и соответственно предварительных подготовок никаких не нужно.
Создание PostFacade
Так как статья является продолжением цикла статей, то реализовываться будет PostFacade
.
Подробнее можно ознакомиться в статье — Создание базовых классов.
Для сайта новостей понадобятся следующие экшены:
- Загрузка списка новостей;
- Загрузка новости по
id
; - Создание новой новости;
- Изменение новости;
- Удаление новости;
- Удаление всех новостей;
- Восстановление новостей (сброс новостей до дефолтного списка новостей.
В результате получится сервис:
import { Injectable, OnDestroy } from '@angular/core';
import { BehaviorSubject, catchError, map, Subject, takeUntil, tap, throwError } from 'rxjs';
import { PostApiService } from '@angular-samples/redux/posts/api';
import { Post, PostChange, PostCreate } from '@angular-samples/redux/posts/common';
import { PostFacade } from '@angular-samples/redux/posts/facade';
export interface PostState {
readonly loaded: boolean;
readonly ids: string[];
readonly entities: Record<string, Post>;
}
export const initialPostState: PostState = {
ids: [],
entities: {},
loaded: false,
};
@Injectable()
export class NativePostFacade implements PostFacade, OnDestroy {
private readonly state$ = new BehaviorSubject<PostState>(initialPostState);
private readonly destroy$ = new Subject<void>();
loaded$ = this.state$.pipe(map((state) => state.loaded));
posts$ = this.state$.pipe(map((state) => Object.values(state.entities)));
postsPromo$ = this.posts$.pipe(
map((posts) => posts.filter((post) => post.promo).sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime()))
);
postsLast$ = this.posts$.pipe(map((posts) => posts.filter((post) => !post.promo).sort((a, b) => b.views - a.views)));
postsPopular$ = this.posts$.pipe(
map((posts) => posts.filter((post) => !post.promo).sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime()))
);
loadSuccess$ = new Subject<Post[]>();
loadFailure$ = new Subject<unknown>();
loadOneSuccess$ = new Subject<Post | null>();
loadOneFailure$ = new Subject<unknown>();
createSuccess$ = new SubjeВсе достаточно тривиально и немного похоже на Ngrx.ct<Post>();
createFailure$ = new Subject<unknown>();
changeSuccess$ = new Subject<Post>();
changeFailure$ = new Subject<unknown>();
removeSuccess$ = new Subject<string>();
removeFailure$ = new Subject<unknown>();
clearSuccess$ = new Subject<void>();
clearFailure$ = new Subject<unknown>();
post$ = (uuid: string) => this.state$.pipe(map((state) => state.entities[uuid] ?? null));
constructor(private readonly postApiService: PostApiService) {}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
load(): void {
this.postApiService
.get()
.pipe(
tap((posts) => {
const state = this.state$.getValue();
this.state$.next({
...state,
ids: posts.map((post) => post.uuid),
entities: posts.reduce((acc, current) => ({ ...acc, [current.uuid]: current }), {}),
});
this.loadSuccess$.next(posts);
}),
catchError((error) => {
this.loadFailure$.next(error);
return tмассивhrowError(() => error);
}),
takeUntil(this.destroy$)
)
.subscribe();
}
loadOne(uuid: string) {
this.postApiService
.getOne(uuid)
.pipe(
tap((post) => {
const state = this.state$.getValue();
this.state$.next({
...state,
ids: post && !state.ids.includes(post.uuid) ? [...state.ids, post.uuid] : state.ids,
entities: post ? { ...state.entities, [post.uuid]: post } : state.entities,
});
this.loadOneSuccess$.next(post);
}),
catchError((error) => {
this.loaмассивdOneFailure$.next(error);
return throwError(() => error);
}),
takeUntil(this.destroy$)
)
.subscribe();
}
create(postCreate: PostCreate): void {
this.postApiService
.create(postCreate)
.pipe(
tap((post) => {
const state = this.state$.getValue();
this.state$.next({
...state,
ids: !state.ids.includes(post.uuid) ? [...state.ids, post.uuid] : state.ids,
entities: { ...state.entities, [post.uuid]: post },
});
this.createSuccess$.next(post);
}),
catchError((error) => {
this.createFailure$.next(error);
return throwError(() => error);
}),
takeUntil(this.destroy$)
)
.subscribe();
}
change(postChange: PostChange): void {
this.postApiService
.change(postChange)
.pipe(
tap((post) => {
const state = this.state$.getValue();
this.state$.next({
...state,
ids: !state.ids.includes(post.uuid) ? [...state.ids, post.uuid] : state.ids,
entities: { ...state.entities, [post.uuid]: post },
});
this.changeSuccess$.next(post);
}),
catchError((error) => {
this.changeFailure$.next(error);
return throwError(() => error);
}),
takeUntil(this.destroy$)
)
.subscribe();
}
BehaviorSubject
remove(uuid: string): void {
this.postApiService
.remove(uuid)
.pipe(
tap(() => {
const state = this.state$.getValue();
const entities = { ...state.entities };
delete entities[uuid];
this.state$.next({
...state,
ids: state.ids.filter((id) => id !== uuid),
entities,
});
this.removeSuccess$.next(loadSuccessloadSuccessuuid);
}),массивмассив
catchError((error) => {
this.removeFailure$.next(error);
return throwError(() => error);
}),
takeUntil(this.destroy$)
)
.subscribe();
}
clear(): void {
this.postApiService
.clear()
.pipe(
tap(() => {
const state = this.state$.getValue();
this.state$.next({
...state,
ids: [],
entities: {},
});
this.clearSuccess$.next();
}),
catchError((error) => {loadSuccessloadSuccess
this.clearFailure$.next(error);
return throwError(() => error);
}),
takeUntil(this.destroy$)
)
.subscribe();
}
}
Все достаточно тривиально и немного похоже на
Ngrx
.
Сначала создается модель данных, в частности state
:
export interface PostState {
readonly loaded: boolean;
readonly ids: string[];
readonly entities: Record<string, Post>;
}
loaded
— загружен ли список новостей;ids
— список идентификаторов сущностей;entities
— словарь со списком всех новостей.
Для state
определяется начальное состояние:
export const initialPostState: PostState = {
ids: [],
entities: {},
loaded: false,
};loadSuccess
Хранится данные будут в BehaviorSubject
, где в качестве начального значения используется начальное состояние стейта initialPostState
:
state$ = new BehaviorSubject<PostState>(initialPostState);
Для того чтобы обратится к свойсвам стейта создадим несколько селекторов, которые формально будут обращаться к state и выбирать нужное значение:
loaded$ = this.state$.pipe(map((state) => state.loaded));
posts$ = this.state$.pipe(map((state) => Object.values(state.entities)));
В первом случае из state
выбирается значение loaded
, к которому можно будет получить доступ, обратившись к loaded$
.
В втором случае из statе
берется словарь с новостями и возвращается коллекция новостей.
Допустим, необходимо создать события успешной и неуспешной загрузки. Для этого достаточно создать Subject
на каждое событие:
loadSuccess$ = new Subject<Post[]>();
loadFailure$ = new Subject<unknown>();
Далее в коде достаточно только испускать требуемые события.
Для того чтобы реализовать эффекты необходимо просто создать observable
:
load(): void {
this.postApiService
.get()
.pipe(
tap((posts) => {
const state = this.state$.getValue();
this.state$.next({
...state,
ids: posts.map((post) => post.uuid),
entities: posts.reduce((acc, current) => ({ ...acc, [current.uuid]: current }), {}),
});
this.loadSuccess$.next(posts);
}),
catchError((error) => {
this.loadFailure$.next(error);
return throwError(() => error);
}),
takeUntil(this.destroy$)
)
.subscribe();
}
Сначала выполняется HTTP
запрос, который возвращает успех или неудачу.
Если запрос успешный, тогда берется последнее значение state
:
const state = this.state$.getValue();
Далее в state
записывается новое состояние:
this.state$.next({
...state,
ids: posts.map((post) => post.uuid),
entities: posts.reduce((acc, current) => ({ ...acc, [current.uuid]: current }), {}),
});
После этого испускается событие успеха:
this.loadSuccess$.next(posts);
В случае неудачи испускается соответствующее событие:
this.loadFailure$.next(error);
Аналогичным образом реализуются все эффекты.
Демо:
Можно посмотреть демо — native.fafn.ru.
Плюсы и минусы реализации
Решение с сервисом Angular содержит как плюсы, так и минусы.
К плюсам можно отнести:
- Скорость работы благодаря отсутствию экшенов, редьюсера и других обработчиков.
- Размер реализации, которая работает из коробки и не требует дополнительных библиотек.
К минусам можно отнести:
- Отсутствие экшенов, которые выполняют роль легковесных событий.
- Перегруженность сервиса, которая появляется в результате отсутствия разделения кода.
Еще один критерий, который нельзя отнести ни к плюсам, ни к минусам — это простота реализации. В данном случае проста будет определятся навыками и умениями разработчика, где решение может быть как простым, так и невозможно сложным.
Ссылки
Все исходники находятся на github, в репозитории:
Для того, чтобы посмотреть состояние проекта на момент написания статьи, нужно выбрать последний мастер.
Подписывайтесь на блог, чтобы не пропустить новые статьи про Angular, и веб-разработку. Medium | Telegram| VK |Tw| Ln