Допустим, перед вами стоит задача написать фронтенд-приложение. Есть ТЗ с описанием функционала, тикеты в баг-трекере. Но выбор конкретной архитектуры лежит на вас.
Порой трудно заранее понять, какая архитектура будет удачной, а какая нет. Особую сложность представляет организация управления состоянием. Как лучше всего хранить и синхронизировать данные, которые нужно отображать? Каждый по-своему подходит к решению этой проблемы и либо пишет весь код сам, либо берет готовую библиотеку. Любой из этих способов — со своими плюсами и минусами.
В статье я расскажу о своем решении проблемы управления состоянием на примере разных стадий развития приложения.
Обычно в приложении есть две большие группы состояний: серверное и клиентское.
Серверное состояние. Расположено на сервере, но наше приложение берет оттуда кусочек, который нужно где-то хранить и отображать. Этот кусочек передается через HTTP API.
Клиентское состояние. Существует только на клиенте, и его часто нужно синхронизировать с серверным. Например, при смене страницы требуется запросить для нее данные с сервера. При смене значений фильтров нужно отправить запрос на сервер, получить отфильтрованные значения, сохранить их и отобразить в компоненте.
Собственно, проблема и состоит в организации хранения и синхронизации этих двух типов состояния.
Микроприложение — храним состояние в компонентах
Маленькое или среднее приложение — храним состояние в сервисах
Почему выбираем сервисы
Вырабатываем архитектуру сервиса
Примитивный метод: храним в полях метода без отслеживания
Продвинутый метод: добавляем реактивности
Реализуем сервисы в приложении
Маленькое приложение — у каждого компонента свой сервис
Среднее приложение — объединяем сервисы
Большое приложение — делаем универсальное хранилище или берем готовую библиотеку
Делаем универсальное хранилище
Переходим на NgRx
Как работает NgRx
Подключаем модули NgRx
Декомпозируем состояние в NgRx
Плюсы и минусы NgRx
Выводы
Микроприложение — храним состояние в компонентах
Допустим, в приложении есть страница с табличкой (компонент app-table) и фильтрами (компонент app-filters). Пользователь меняет значение фильтра и ожидает, что обновится таблица. Все состояние приложения в нашем случае — это данные для таблицы и фильтров, получаемые с сервера (серверное состояние), и значения выбранных фильтров (клиентское состояние).
Если ваше приложение будет таким же простым, то и управлять состоянием в нем будет очень легко. Состояние можно вовсе не хранить в явном виде:
import { Component, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common';
@Component({
selector: 'app-root',
template: `
<app-filters (selectedChange)="onFilterChange($event)" [filtersData]="filtersData$ | async"></app-filters>
<app-table [data]="data$ | async"></app-table>
`,
})
export class VerySimpleAppComponent implements OnInit {
data$: Observable<Item[]>;
filtersData$: Observable<any>;
constructor(public http: HttpClient) {
this.data$ = this.http.get<Item[]>('/api/data');
this.filtersData$ = this.http.get<any>('/api/filtersData');
}
onFilterChange(filterValue: any) {
this.data = this.filterData(filterValue);
}
private filterData(filterValue): Item[] {
// Тут логика фильтрации
}
}
Маленькое или среднее приложение — храним состояние в сервисах
Почему выбираем сервисы
Начнем развивать приложение: добавим пару новых таблиц и роутинг. Допустим также, что для всех таблиц нужны общие фильтры. Тогда корневой компонент будет выглядеть примерно так:
@Component({
selector: 'app-root',
template: `
<app-filters (selectedChange)="onFilterChange($event)" [filtersData]="filtersData"></app-filters>
<router-outlet></router-outlet> <!-- сюда будут подставляться компоненты с таблицами -->
`,
})
export class AppComponent {
}
Но в таком варианте у нас нет возможности напрямую передать в компонент таблицы данные. Поэтому пока что напрашивается вариант хранить данные таблицы в сервисе.
Но быть может, архитектура выглядит по-другому? Например, из-за дизайна нет возможности вынести компонент фильтра на общий внешний уровень или для отдельных таблиц помимо общего фильтра нужны еще и отдельные фильтры. Тогда layout будет примерно таким:
@Component({
selector: 'app-root',
template: `
<router-outlet></router-outlet> <!-- сюда будут подставляться компоненты с таблицами и фильтрами -->
`,
})
export class AppComponent {
}
// Страницы выглядят так
@Component({
selector: 'app-first-page',
template: `
<!-- Общие фильтры -->
<app-filters-common (selectedChange)="onFilterChange($event)" [filtersData]="filtersData"></app-filters-common>
<!-- Фильтры, предназначенные для первой таблицы -->
<app-filters-for-first-table><app-filters-for-first-table>
<app-table-first [data]="data"></app-table-first>
`,
})
export class FirstPageComponent {}
@Component({
selector: 'app-second-page',
template: `
<!-- Общие фильтры -->
<app-filters-common (selectedChange)="onFilterChange($event)" [filtersData]="filtersData"></app-filters-common>
<!-- Фильтры, предназначенные для второй таблицы -->
<app-filters-for-second-table><app-filters-for-second-table>
<app-table-second [data]="data"></app-table-second>
`,
})
export class SecondPageComponent {}
Это похоже на случай с примитивным приложением, который мы рассмотрели ранее: компоненты видны друг другу, и в принципе состояние можно хранить в самом компоненте. Но возникает другая проблема: при смене роута компоненты страниц (и все, что ниже по дереву) пересоздаются заново. Если получение данных сделано в компоненте, то при смене страниц данные для фильтров будут запрошены заново. Но чаще всего эти данные нужно запросить только один раз, а потом использовать полученные. Передать же их из корневого компонента app-root не выйдет, ведь из-за роутинга прямая связь с корнем потеряна.
Что же, опять напрашивается хранить данные, только уже для фильтров в сервисах. Это решит сразу несколько проблем:
Доступ к данным не будет зависеть от взаимного расположения компонентов. Нет нужды выстраивать архитектуру так, чтобы компонеты могли видеть инпуты и аутпуты друг друга. Можно создавать компоненты динамически (как, например, это делает роутер). Можно использовать разные техники типа порталов. А достучаться до сервиса в Angular просто: достаточно внедрить его в компонент через механизм DI.
Данные с сервера будут запрашиваться столько раз, сколько нужно, вне зависимости от жизненного цикла компонента. Сервисы в Angular по умолчанию синглтоны. Можно, конечно, иметь несколько инстансов одного сервиса. Например, по инстансу на модуль или даже на компонент (я писал подробную статью про сервисы; она немного устарела, но большинство вещей все еще актуальны), но без особой надобности так делать не нужно — сервис должен инстанцироваться один раз. Тогда те данные, которые не зависят от других (например, данные для фильтров), можно получать в конструкторе сервиса. Данные, которые зависят от других (например, данные в таблицах, которые зависят от выбранных фильтров), можно перезапрашивать только при изменении фильтра. Если бы мы получали такие табличные данные при инициализации компонента, то при его пересоздании был бы дополнительный запрос к серверу, даже если фильтры не менялись.
В компонентах становится меньше логики. Таким образом, почти для любого проекта на Angular хранение состояния нужно выносить в сервисный слой.
Вырабатываем архитектуру сервиса
Итак, с тем, где хранить данные, мы определились. Давайте подумаем, как это реализовать. Как будут устроены сервисы?
Примитивный метод: храним в полях метода без отслеживания
Данные можно хранить в полях сервиса, но такой подход имеет большой недостаток: сложно отслеживать изменения значений. В принципе, Angular может это делать сам, но только для компонентов со стратегией обнаружения изменений ChangeDetectionStrategy.Default, которая не всегда подходит (например, из-за того, что работает медленнее).
Вот наглядный пример: мы напрямую меняем поле в классе сервиса. При этом в компоненте со стратегией OnPush
при изменении поля ничего не происходит. И у нас даже нет способа как-то изменить это, ведь просто так мы не можем отслеживать изменения.
Для данных, которые зависят от других, все тоже не очень хорошо: сервисы никаких механизмов отслеживания не имеют. Так что понять, что фильтры изменились, и среагировать на это также не выйдет.
Продвинутый метод: добавляем реактивности
Итак, надо как-то сделать поля отслеживаемыми. В нашем проекте уже установлена позволяющая сделать это библиотека — RxJS.
Конвертируем обычное поле в Observable
-поле:
import { Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class StorageService {
// Было
// data = 'initial value';
// Стало
private dataSubject: BehaviorSubject<string> = new BehaviorSubject('initial value');
data$: Observable<string> = this.dataSubject.asObservable();
setData(newValue: string) {
this.dataSubject.next(newValue);
}
}
Здесь используется BehaviorSubject. Это Subject (т. е. одновременно и Observable
, и Observer
), плюс он кэширует последнее значение внутри себя и оповещает новых подписчиков, когда они подписываются на стрим. Сами же данные теперь доступны из поля data$
. Работать с ними в потоке просто.
Можно использовать стандартные средства RxJS для работы со стримами:
@Component({
selector: 'my-app',
template: `{{ data }}`,
})
export class AppComponent implements OnDestroy {
data: any;
subscription: Subscription = null;
constructor(public storage: StorageService) {
this.subscription = this.storage.data$.subscribe(data => this.data = data);
}
ngOnDestroy(): void {
// Только не забывайте отписаться!
if (this.subscription !== null) {
this.subscription.unsubscribe();
this.subscription = null;
}
}
}
Нужно всегда помнить, что если ты где-то подписался на стрим, то лучше бы от него отписаться. Есть несколько статей, которые поясняют, почему и как это надо делать (например, вот).
Но в Angular есть средство, упрощающее работу с Promise
и Observable
, — пайп async. Поэтому можно писать так:
@Component({
selector: 'my-app',
template: `{{ data$ | async }}`, // все отпишется само
})
export class AppComponent {
data$: Observable<any>;
constructor(public storage: StorageService) {
this.data$ = this.storage.data$;
}
}
Итак, кода в сервисе стало больше, а вместо простого поля появились два поля и один метод. Какие плюсы? Теперь можно отслеживать изменения данных:
- В компонентах с
OnPush
-стратегией можно отследить, когда изменилось поле, и запустить механизм проверки изменений. К тому же упомянутый пайп async позволяет не писать отслеживание вручную. - В сервисах также появилась возможность отследить изменения и пересчитать зависимые поля.
Вот пример использования нового сервиса. Заметьте, что в компоненте с OnPush
мы практически не меняли код: просто добавив пайп async
, сохранили всю выгоду OnPush
-стратегии и заставили компонент работать как надо.
Реализуем сервисы в приложении
Маленькое приложение — у каждого компонента свой сервис
Давайте вернемся к нашему приложению с табличками и фильтрами.
Будем для каждого компонента стараться сделать отдельный сервис — хранилище: один сервис на одну таблицу, один сервис на одну группу фильтров и т. д.
Поскольку придется писать много однотипного кода для Observable-полей, я сделаю такой вспомогательный класс:
export class BehaviorSubjectItem<T> {
readonly subject: BehaviorSubject<T>;
readonly value$: Observable<T>;
// Приятный бонус: так как мы используем `BehaviorSubject`, то можем получить текущее значение синхронно и без подписки.
get value(): T {
return this.subject.value;
}
set value(value: T) {
this.subject.next(value);
}
constructor(initialValue: T) {
this.subject = new BehaviorSubject(initialValue);
this.value$ = this.subject.asObservable();
}
}
Реализуем хранилище для данных фильтров:
@Injectable()
export class FiltersStore {
private apiUrl = '/path/to/api';
readonly filterData: BehaviorSubjectItem<{ value: string; text: string }[]> = new BehaviorSubjectItem([]);
readonly selectedFilters: BehaviorSubjectItem<string[]> = new BehaviorSubjectItem([]);
constructor(private http: HttpClient) {
this.fetch(); // тут какая-то логика по получению данных с бэка
}
fetch() {
this.http.get(this.apiUrl).subscribe(data => this.filterData.value = data);
}
setSelectedFilters(value: { value: string; text: string }[]) {
this.selectedFilters.value = value;
}
}
Теперь сделаем хранилище для табличных данных:
@Injectable()
export class TableStore {
private apiUrl = '/path/to/api';
tableData: BehaviorSubjectItem<any[]>;
loading: BehaviorSubjectItem<boolean> = new BehaviorSubjectItem(false);
constructor(private filterStore: FilterStore, private http: HttpClient) {
this.fetch();
}
fetch() {
this.tableData$ = this.filterStore.selectedFilters.value$.pipe(
tap(() => this.loading.value = true),
switchMap(selectedFilters => this.http.get(this.apiUrl, { params: selectedFilters })),
tap(() => this.loading.value = false),
share(),
);
}
}
Если нужно объединить данные из других источников (например, у нас будет два разных типа фильтров), используем combineLatest:
this.tableData$ = combineLatest(firstSource$, secondSource$).pipe(
// start loading
switchMap([firstData, secondData] => /* ... */)
// end loading
);
В компонентах все тоже довольно просто:
@Component({
selector: 'first-table-page',
template: `
<app-filters (selectedChange)="onFilterChange($event)" [filterData]="filterData$ | async"></app-filters>
<app-table-first [data]="tableData$ | async" [loading]="loading$ | async"></app-table-first>
`,
})
export class FirstTablePageComponent {
filterData$: Observable<FilterData[]>;
tableData$: Observable<TableItem[]>;
loading$: Observable<boolean>;
constructor(private filtersStore: FiltersStore, private tableStore: FirstTableStore) {
this.filterData$ = filtersStore.filterData.value$;
this.tableData$ = tableStore.tableData.value$;
this.loading$ = tableStore.loading.value$;
}
onFilterChange(selectedFilters) {
this.filtersStore.setSelectedFilters(selectedFilters)
// Или используем сеттер, чтобы поменять значение
this.filtersStore.selected.value = selectedFilters;
}
}
Такой подход достаточно хорош:
- бизнес-логика отделена от представления;
- для работы с каким-то определенным состоянием (например, состоянием одной из таблиц) достаточно внедрить нужный сервис;
- каждый такой сервис работает только со своей частью состояния;
- публичный интерфейс сервиса очень простой: поле для получения данных, метод для их изменения.
Среднее приложение — объединяем сервисы
С одной стороны, каждый store-сервис неплох с точки зрения общей архитектуры: он соответствует принципу Single Responsibility, вся логика по работе с данными у него инкапсулирована, а извне доступен простой интерфейс для данных. Но когда сервисов становится слишком много, начинают проявляться минусы такого подхода:
- Дублирование кода. Такие сервисы почти одинаковые, но в текущем виде нельзя выделить какую-то общую часть, чтобы вынести ее в отдельный класс.
- Сложные зависимости между сервисами. Вполне может оказаться, что для получения данных сервису требуется внедрить еще 5–10 других store-сервисов.
- Трудоемкий рефакторинг. Например, если нужно перенести поле из сервиса Foo в сервис Bar, то придется во всех местах, где внедрен Foo, изменить импорты, заменить в конструкторе потребителя Foo на Bar, поменять имя поля для класса и т. д.
- Нет централизованности, сложность в расширении функционала. Допустим, необходимо добавить логирование на все изменения состояния. Для этого придется в каждый сервис по отдельности добавлять логгер. Или, если вдруг при изменении узла состояния понадобится делать какие-то дополнительные побочные действия (например, записать состояние в localStorage), придется менять код сервиса. Изменить поведение извне не получится.
В таком случае давайте уменьшим количество сервисов, которые управляют состоянием. Предположим, что в приложении понадобилось иметь два фильтра: по цветам и по размерам. Попробуем не делать для них два разных сервиса, а объединим их в один.
Во-первых, разделим получение данных через HTTP API и, собственно, управление состоянием. Всю логику по формированию запроса к бэкенду и получению от него данных вынесем в сервисы. Делать для этого один сервис или несколько — дело вкуса.
Во-вторых, в store-сервисах все состояние будем хранить не в разных полях, а в одном объекте побольше:
export interface FilterItem<T> {
value: T;
text: string;
}
export interface FilterState<T> {
data: FilterItem<T>[];
selected: string[];
}
type ColorsFilterState = FilterState<string>;
type SizesFilterState = FilterState<int>;
export interface FiltersState {
colors: ColorsFilterState;
sizes: SizesFilterState;
}
const FILTERS_INITIAL_STATE: FiltersState = {
colors: {
data: [],
selected: [],
},
sizes: {
data: [],
selected: [],
},
};
@Injectable()
export class FiltersStore {
filtersState: BehaviorSubjectItem<FiltersState> = new BehaviorSubjectItem(FILTERS_INITIAL_STATE);
}
В-третьих, добавим методы для обновления нужной части состояния:
@Injectable()
export class FiltersStore {
filtersState: BehaviorSubjectItem<FiltersState> = = new BehaviorSubjectItem({/* ... */});
setColorsData(data: FilterItem<string>[]) {
const oldState = this.filtersState.value;
this.filtersState.value = {
...oldState,
colors: {
...oldState.colors,
data,
},
};
}
setColotsSelected(selected: string[]) {
const oldState = this.filtersState.value;
this.filtersState.value = {
...oldState,
colors: {
...oldState.colors,
selected,
},
};
}
}
В-четвертых, сделаем получение конкретного поля удобнее:
@Injectable()
export class FiltersStore {
filtersState: BehaviorSubjectItem<FiltersState> = = new BehaviorSubjectItem({/* ... */});
setColorsData(data: FilterItem<string>[]) {/* ... */}
setColotsSelected(selected: string[]) {/* ... */}
colorsFilter$: Observable<ColorsFilterState> = this.filtersState.value$.pipe(
map(filtersState => filtersState.colors), // либо pluck('colors')
);
colorsFilterData$: Observable<FilterItem<string>[]> = this.colorsFilter$.pipe(
map(colorsFilter => colorsFilter.data),
);
colorsFilterSelected$: Observable<string[]> = this.colorsFilter$.pipe(
map(colorsFilter => colorsFilter.selected),
);
}
Большое приложение — делаем универсальное хранилище или берем готовую библиотеку
Для массивного приложения уже сложно хранить огромное количество кусочков состояния в отдельных сервисах. Мы можем пойти еще дальше: отвязать сервис от конкретного состояния и сделать его пригодным для управления любым состоянием. В дальнейшем даже можно вынести его в библиотеку и использовать повторно в других проектах.
Делаем универсальное хранилище
Давайте вернемся к предыдущему подходу. В каждом сервисе у нас есть кусочек состояния и методы для изменения этого состояния. Состояние можно получить из observable-поля, так что оно по сути немутабельно: если получить объект из сервиса и мутировать его, то у других подписчиков этих изменений не будет. Чтобы изменить состояние, нужно вызвать метод сервиса (в нашем случае это сеттер поля). Такой подход очень напоминает паттерн Flux, предложенный Facebook.
Разовьем данную идею и попробуем сделать один сервис для управления всем состоянием приложения. Такое универсальное хранилище должно уметь:
- инициализироваться с каким-то начальным состоянием;
- изменять нужный кусочек состояния;
- отдавать нужный кусок состояния.
Для начала нужно описать и как-то инициализировать начальное состояние. Можно, например, передавать его через механизм DI:
export interface AppState {/* ... */}
export const INITIAL_STATE: InjectionToken<AppState> = new InjectionToken('InitialState');
// Где-то в AppModule
const appInitialState: AppState = {/* ... */};
@NgModule({
providers: [
Store,
{ provide: INITIAL_STATE, useValue: appInitialState },
]
})
export class AppModule {}
Еще один вариант — сделать в сервисе метод init
и вызывать его при старте приложения, используя APP_INITIALIZER:
@Injectable()
export class Store<AppState> {
state: BehaviorSubjectItem<AppState>;
init(initialState: AppState) {
this.state = new BehaviorSubjectItem(initialState);
}
}
const appInitialState: AppState = {/* ... */};
export function initStore(store: Store<AppState>) {
return () => store.init();
}
@NgModule({
providers: [
Store,
{
provide: APP_INITIALIZER,
useFactory: initStore,
deps: [Store],
multi: true,
}
]
})
export class AppModule {}
Теперь подумаем, как менять состояние. Добавлять методы в сервис уже не вариант — нужен другой способ описать, как будет меняться состояние.
Давайте вернемся к примеру с фильтрами. Посмотрим на состояние одного из них:
// Состояние фильтров по цвету выглядит вот так:
export interface ColorsFilterState {
data: FilterItem<string>[];
selected: string[];
}
У нас доступны два действия с этим состоянием: изменить данные фильтра и изменить список выбранных фильтров. Нужно как-то сообщить сведения о том, какое действие выполняется, и непосредственно передать новые данные для состояния. Объекты, описывающие действия, будут выглядеть примерно так:
interface ChangeColorsDataAction {
type: 'changeData';
payload: FilterItem<string>[];
}
interface ChangeColorsSelectedAction {
type: 'changeSelected';
payload: string[];
}
type ColorsActions = ChangeColorsDataAction | ChangeColorsSelectedAction;
Теперь давайте опишем, как изменяются состояния с помощью функций:
export const changeColorsDataState: (oldState: ColorsFilterState, data: FilterItem<string>[]) => FilterState = (oldState, data) => ({ ...oldState, data });
export const changeColorsSelectedState: (oldState: ColorsFilterState, selected: string[]) => FilterState = (oldState, selected) => ({ ...oldState, selected });
Далее мы можем объединить эти функции следующим образом. Будем передавать в функцию старое состояние, тип нужных действий и данные, необходимые для этого действия:
export const changeColorsState: (oldState: ColorsFilterState, action: ColorsActions) => ColorsFilterState = (state, action) => {
if (action.type === 'changeData') {
return changeColorsDataState(oldState, action.payload);
}
if (action.type === 'changeSelected') {
return changeColorsSelectedState(oldState, action.payload);
}
return oldState;
}
Как это применить в нашем сервисе? Давайте посмотрим, как бы все выглядело, если бы сервис работал только с состоянием ColorsFilterState
:
@Injectable()
export class Store<ColorsFilterState> {
state: BehaviorSubjectItem<ColorsFilterState>;
init(
state: ColorsFilterState,
changeStateFunction: (oldState: ColorsFilterState, action: ColorsActions) => ColorsFilterState, // <- будем передавать в метод инициализации функцию, которая умеет работать со всем состоянием
) {/* ... */}
changeState(action: ColorsActions) {
this.state.value = this.changeStateFunction(this.state.value, action);
}
}
// Использование
this.store.changeState({ type: 'changeSelected', payload: ['red', 'orange'] });
Итак, чтобы работать с кусочком состояния, достаточно:
- описать само состояние;
- задать начальное значение для состояния;
- описать действия, которые могут выполняться с этим состоянием;
- описать функцию, которая в зависимости от действия будет менять
состояние.
Осталось перейти от частного к общему. Сейчас мы умеем работать только с конечным узлом состояния. Конечно, можно раздуть функцию changeStateFunction
, чтобы она могла работать со всем состоянием. Но можно пойти другим путем. По-прежнему описывать работу с кусочками состояний, а потом просто комбинировать эти функции в одну большую:
// Пусть все состояние выглядит так:
export interface FiltersState {
colors: ColorsFilterState;
sizes: SizesFilterState;
}
// Функция для работы с состоянием фильтров по цвету
export const changeColorsState = (state, action) => {/* ... */};
// Функция для работы с состоянием фильтров по размеру
export const changeSizeState = (state, action) => {/* ... */};
export const changeFunctionsMap = {
colors: changeColorsState,
sizes: changeSizeState,
};
export function combineChangeStateFunction(fnsMap) {
return function (state, action) {
const nextState = {};
Object.entries(fnsMap).forEach(([key, changeFunction]) => {
nextState[key] = changeFunction(state[key], action);
});
return nextState;
}
}
Получилось что-то похожее на EventBus:
- действия — это сообщения с данными;
changeStateFunction
диспетчеризует эти сообщения и в зависимости от
типа выполняет разные манипуляции над состоянием;- чтобы изменить данные, мы отправляем сообщение в шину, а сами данные
обновляются сами из-за реактивности.
Итак, мы попытались сделать универсальное хранилище, и у нас появилось некое понимание одной из возможных архитектур state manager'а. Если в вашем проекте по каким-то причинам нельзя подключать сторонние библиотеки, то можно дальше продолжать реализовывать самописное хранилище, но в остальных случаях лучше взять готовую библиотеку с похожим функционалом. Подойдут, например:
- NgRx;
- NGXS;
- MobX + MobX Angular (это тоже
универсальное хранилище, но с другой архитектурой).
Далее я рассмотрю ту, которой пользуюсь в большинстве рабочих проектов.
Переходим на NgRx
Как работает NgRx
NgRx берет идею Redux и реализует ее с учетом специфики Angular. В целом Redux не привязана ни к какому фреймворку, но популярна в основном в react-комьюнити.
Подход Redux и NgRx заключается в следующем. Представим все состояние приложения в виде одного объекта. Этот объект поместим в хранилище — store. Из хранилища мы можем получить это состояние, однако напрямую изменить его не можем. Чтобы изменить состояние, мы должны отправить в хранилище «сигнал» — действие, которое определяется типом (обычно это просто строка) и данными. «Изменения» состояния происходят путем вызова чистых функций — редьюсеров, которые возвращают новое состояние.
Чтобы получать данные через API или выполнять какие-то побочные действия (например, логирование), предусмотрен механизм Middleware (в терминологии NgRx — Effect).
Вот наглядная схема, как работает Redux и NgRx:
- Во view наступает какое-то событие (нажатие кнопки, изменение данных формы, окончание XHR-запроса и т. д.).
- Это событие порождает действие.
- Действие диспетчеризуется в хранилище, выбирается нужный редьюсер для обновления состояния. Действие, проходя через Middleware (Effect), запускает запрос к API.
- Редьюсер берет данные и старое состояние из действия, возвращает новое состояние.
- View обновляется в соответствии с новым состоянием.
- Из API приходит ответ (необязательный шаг):
- на это событие создается новое действие;
- повторяются пункты 4 и 5.
Чтобы понять архитектуру библиотеки и ее внутренности, советую также прочитать вот эту статью. В ней создается примитивный Redux своими силами.
Подключаем модули NgRx
@ngrx/store
Это основной модуль: тут содержатся средства по управлению состоянием. Центральный объект всего проекта — сервис Store
. Получение данных и диспетчеризация действий происходит через него. Сам сервис наследуется от Observable
, так что работать с состоянием удобно и привычно — можно использовать все операторы из RxJS и async
-пайп. В самом сервисе находится все состояние, которое мы зарегистрируем. Чтобы работать только с частью состояния, есть специальный оператор select. В зависимости от переданных аргументов это аналог оператора map
либо оператора pluck.
Для изменения состояния нам сперва нужно описать действия и то, как они меняют состояние. Затем достаточно вызвать метод dispatch
в сервисе store
и передать туда действие.
export interface FilterItem<T> {
key: string;
value: T;
}
// Описываем действия
export enum FiltersActionTypes {
Change = '[Filter] Change',
Reset = '[Filter] Reset',
}
export class ChangeFilterAction implements Action {
readonly type = FiltersActionTypes.Change;
constructor(public payload: { filterItem: FilterItem<string> }) {}
}
export class ResetFilterAction implements Action {
readonly type = FiltersActionTypes.Reset;
constructor() {}
}
export type FiltersActions = ChangeFilterAction | ResetFilterAction;
// Описываем состояние и его начальное значение
export interface FiltersState {
filterData: FilterItem<string>[];
selected: FilterItem<string>;
}
export const FILTERS_INIT_STATE: FiltersState = {
filterData: [
{ key: '-', value: '---' },
{ key: 'red', value: 'Красный' },
{ key: 'green', value: 'Зеленый' },
{ key: 'blue', value: 'Синий' },
],
selected: { key: '-', value: '---' },
};
// Описываем, как действия будут менять состояние
export function filtersReducer(state: FiltersState = FILTERS_INIT_STATE, action: FiltersActions) {
switch (action.type) {
case FiltersActionTypes.Change:
return {
...state,
selected: action.payload.filterItem,
};
case FiltersActionTypes.Reset:
return {
...state,
selected: { key: '-', value: '---' },
};
default:
return state;
}
}
// Также мы можем создать функцию-селектор, которую передадим в оператор select
// Селекторы мемоизируют состояния, кроме того, есть возможность комбинировать селекторы между собой
export const selectedFilterSelector = createSelector(state => state.filters.seleced);
// Регистрируем состояние
export interface AppState {
filters: FiltersState;
}
export const reducers: ActionReducerMap<AppState> = {
filters: filtersReducer,
}
@NgModule({
imports: [
StoreModule.forRoot(reducers),
],
// ...
})
export class AppModule {}
// Используем
@Component({
selector: 'app-root',
template: `
<app-filters [data]="filtersData$ | async"
[selected]="selectedFilter$ | async"
(change)="onChange($event)"
(reset)="onReset()">
`,
})
export class AppComponent {
filtersData$: Observable<FilterItem<string>[]>;
selectedFilter$: Observable<FilterItem<string>>;
constructor(private store: Store<AppState>) {
this.filtersData$ = this.store.pipe(select('filters', 'data'));
// Используем функцию-селектор
this.selectedFilter$ = this.store.pipe(select(selectedFilterSelector));
}
onChange(filterItem: FilterItem) {
this.store.dispatch(new ChangeFilterAction({ filterItem }));
}
onReset() {
this.store.dispatch(new ResetFilterAction());
}
}
Пока что вышло достаточно много кода для всего одного фильтра. Немного уменьшить бойлерплейт помогут альтернативный способ описания состояния и создание action, а также cli-комманды.
@ngrx/effects
В самом простом варианте все состояние может меняться только синхронно: пользователь меняет данные или нажимает на что-то, и тогда стартует связанное действие. Хранилище диспатчеризует действия по типу, эти данные обрабатываются в редьюсере, и в итоге возвращается новое состояние. Оно становится доступно всем, кто подписан на соответствующий кусок стора в приложении. Основной модуль @ngrx/store
позволяет обрабатывать только «чистое» состояние.
Но иногда при выполнении действия нужно сделать что-то еще: запросить данные через API, записать в лог, вывести нотификацию. В Redux для этого есть Middleware, NgRx использует аналог — эффекты. Каждый эффект — поле в специальном сервисе с аннотацией @Effect
.
Поля, помеченные аннотацией Effect
, собственно, описывают эффект. Эти поля должны быть типа Observable<Action>
. Обычно они выглядят так. Из сервиса Actions фильтруются необходимые действия, далее запускается какая-то задача: логирование, запрос к API и т. д. По завершении задачи в поток возвращается какое-то другое действие.
@Injectable()
export class AppEffects {
// Просто логируем действие логина
@Effect({ dispatch: false })
loginSuccess$ = this.actions$.pipe(
ofType(LoginActionTypes.Success),
tap(() => {
this.logger.log('Login success')
}),
);
// Типичный сценарий использования эффекта для запросов к API
// Создаем три действия
// При запуске действия FiltersActionTypes.LoadStarted отправляется запрос к API
// По его окончании будет запущено либо действие FiltersDataLoadSuccess в случае успешной загрузки данных
// Либо FiltersDataLoadFailure, если произойдет ошибка
@Effect()
loadFilterData$ = this.actions$.pipe(
ofType(FiltersActionTypes.LoadStarted),
switchMap(() => this.filtersBackendService.fetch()),
map(filtersData => new FiltersDataLoadSuccess({ filtersData })),
catchError(error => new FiltersDataLoadFailure({ error })),
);
// Actions — сервис Observable из пакета @ngrx/store
// В нем мы можем отслеживать все диспатчеризуемые действия
constructor(private actions$: Actions, private filtersBackendService: FiltersBackendService) {}
}
Чтобы эффекты заработали, нужно подключить соответствующий модуль и передать в метод список сервисов-эффектов:
@NgModule({
imports: [
EffectsModule.forRoot([ AppEffects ]),
],
})
export class AppModule {}
@ngrx/store-devtools
Позволяет подключить Redux DevTools для NgRx. Это очень удобный и мощный инструмент разработчика. Вот некоторые из возможностей:
- просмотр полного лога действий с их данными;
- просмотр всего состояния в виде объекта или интерактивного графа;
- сохранение истории действий с возможностью промотки вперед-назад по истории.
Ниже — пример работы подключенного в проект @ngrx/store-devtools
. Слева — список выполненных действий, справа — дерево состояния:
@ngrx/router-store
NgRx имеет модуль для интеграции с роутером Angular. Он позволяет:
- отслеживать события роутинга;
- сериализовать состояния роутера в стор;
- изменить способ навигации на вызов действий NgRx.
Когда в вашем приложении добавится роутинг, можно будет подключить этот модуль. В самом простом варианте он просто «логирует» действия роутера, так что их можно будет посмотреть в DevTools в логе действий. Если в URL будет много данных, этот модуль поможет их сериализовать и отправить в стор, откуда их легко можно будет получить.
Декомпозируем состояние в NgRx
Сильная сторона NgRx — возможность декомпозировать состояние. Хотя все состояние приложения является большим объектом, нам не обязательно описывать его в одном месте. Гораздо удобнее описывать небольшие независимые кусочки отдельно, а потом все компоновать в один объект.
Например, вспомним, как мы описали состояние фильтров:
export interface FiltersState {/* ... */}
export const FILTERS_INIT_STATE: FiltersState = {/* ... */};
export function filtersReducer(state, action) {/* ... */}
export interface AppState {
filters: FiltersState;
}
export const reducers: ActionReducerMap<AppState> = {
filters: filtersReducer,
}
Если нужно добавить новый узел состояния, то достаточно сделать четыре
шага:
- Описать действия.
- Описать начальное состояние.
- Написать редьюсер.
- Написать необходимые селекторы.
Далее мы просто расширяем AppState и объект с редьюсерами приложения:
export interface FirstTableState {/* ... */}
export const TABLE_INIT_STATE: FirstTableState = {/* ... */};
export function firstTableReducer(state, action) {/* ... */}
export const firstTableDataSelector = state => state.firstTable.data;
export const firstTableLoadingSelector = state => state.firstTable.loading;
export interface AppState {
filters: FiltersState;
firstTable: FirstTableState;
}
export const reducers: ActionReducerMap<AppState> = {
filters: filtersReducer,
firstTable: firstTableReducer,
}
Возможно, у вас используется lazy loading некоторых модулей. Что делать, если состояние относится только к такому модулю? Для этого есть возможность подключить часть стора не напрямую в корень, а к конкретному модулю. Вообще, это отличный функционал. Мы описываем кусок стора, а затем можем его подключить в любой модуль приложения. Если модуль грузится лениво, тогда все, что относится к этому куску стора, подключится тоже лениво.
Это можно применять, чтобы писать библиотеки с использованием NgRx. Допустим, у вас есть несколько однотипных приложений. В каждом из них нужна одинаковая авторизация, загрузка пользователя и загрузка конфига. С использованием feature-модулей нет нужды делать стор только в приложении. Всю работу со стором реализовываем в библиотеке, а в экспортируемый модуль добавляем такое:
@NgModule({
imports: [
StoreModule.forFeature('auth', authReducer),
StoreModule.forFeature('user', userReducer),
StoreModule.forFeature('config', configReducer),
EffectsModule.forFeature([ AuthEffects, UserEffects, ConfigEffects ]),
],
})
export class LibModule {}
Осталось только добавить в публичное API библиотеки действия и селекторы. Вся логика скрыта в недрах библиотеки, так что у нас будет достаточно прозрачный и ясный API для получения и изменения состояния.
Теперь при импорте LibModule
в наше приложение в сторе появятся три поля в корне состояния. При этом управлять состоянием мы сможем через API, предоставленный библиотекой.
Плюсы и минусы NgRx
Плюсы
В главе про хранение в сервисах я упомянул о проблемах, связанных с таким подходом. Давайте посмотрим, как NgRx позволяет справиться с ними.
Дублирование кода в NgRx по большей части отсутствует. Мы пишем код только для описания состояния по кусочкам, а все остальное делает библиотека. В целом NgRx предлагает более декларативный подход: вместо описания алгоритма изменения состояния мы описываем, какие действия можно выполнить с этим состоянием, как в зависимости от действий меняется состояние и как получить конкретный кусок состояния.
Сложные зависимости между сервисами отсутствуют. Сервис в этом случае всего один. Если для получения одних данных нам нужны другие, мы просто берем их из этого же стора (например, используя оператор withLatestFrom из пакета RxJS). Также очень удобно объединить несколько селекторов, чтобы получить комбинацию данных из различных частей состояния.
Трудоемкий рефакторинг отчасти упрощается. Если мы хотим поменять структуру данных в хранилище, то в компонентах и сервисах никакого изменения кода не будет. Поменяется только код селекторов и редьюсеров:
// Перенесем поле qux из куска состояния foo в bar
// { foo: { qux: 1 }, bar: { baz: 2 } } => { foo: {}, bar: { qux: 1, baz: 2 } }
// Было
export const fooReducer = (state, action) => {/* ... */}
export const quxSelector = state => state.foo.qux;
// Стало
export const barReducer = (state, action) => {/* ... */}
export const quxSelector = state => state.bar.qux;
export const reducers = {
foo: fooReducer,
bar: barReducer,
};
// Компонент никак не изменится
@Component({})
export class AppComponent {
constructor(private store: Store<AppState>) {
this.qux$ = this.store.pipe(select(quxSelector));
}
}
Есть возможность централизации и расширения функционала. В NgRx можно одним эффектом добавить логирование всех действий:
@Injectable()
export class AppEffects {
@Effect({ dispatch: false })
log$ = this.actions$.pipe(tap(action => this.logger.log(action)));
constructor(private actions$: Actions, private logger: Logger) {}
}
Или же очень просто добавить сохранение данных пользователя при логине, даже если авторизация сделана в библиотеке. Ведь через сервис Actions мы можем получить любое действие, в том числе и то, которое подключается лениво:
@Injectable()
export class AppEffects {
@Effect({ dispatch: false })
log$ = this.actions$.pipe(tap(action => this.logger.log(action)));
constructor(private actions$: Actions, private logger: Logger) {}
}
А еще в комплекте идут отличные инструменты для разработчика и интеграция с роутингом.
Минусы
Конечно, NgRx не является серебряной пулей и подходит далеко не к любому проекту.
Кода получается довольно много. С каждым кусочком состояния нужно много чего сделать, чтобы включить его в проект.
Проблемы с интеграцией с другими библиотеками. Порой бывает трудно подружить NgRx и другие библиотеки из-за их архитектуры. Например, в части наших проектов используется ag-Grid. При фильтрации или сортировке грида меняется его состояние и, соответственно, его отображение. Однако полное состояние грида хранится «внутри» ag-Grid и меняется там же. Если хранить данные грида в сторе, то нужно как-то синхронизировать эти изменения. Конкретно ag-Grid позволяет это сделать, но для этого придется заново реализовывать сортировку и фильтрацию самому. Кроме того, нужно будет хранить и синхронизировать состояние колонок грида (порядок, группировка, видимость) и управлять ими извне, через действия. Таким образом, чтобы встроить грид из ag-Grid в архитектуру NgRx, придется переписывать добрую половину ag-Grid самому. Некоторые же библиотеки вообще имеют плохой API, так что интегрировать их в архитектуру NgRx не получится. Выход простой: не включать части состояния, которые управляются такими библиотеками, в инфраструктуру NgRx.
В чистом виде неудобно работать с формами. Если форм много и каждое изменение поля ввода пытаться обернуть в действие или если пытаться сделать валидацию полей через действия, то кода получится очень много. Лучше использовать средства Angular, а в хранилище записывать данные только при отправке формы. Или не записывать вообще.
Выводы
Итак, я постарался подробно описать два подхода к управлению состоянием, которые использовал в больших проектах на Angular.
Решение с несколькими самописными сервисами я реализовал, когда Angular 2 находился в бете и никаких внятных библиотек для управления состоянием еще не было. Но этот подход до сих пор имеет право на жизнь и достаточно хорош для средних проектов.
NgRx применяется на моей текущей работе. Из-за специфики проектов (одни и те же данные используются во многих местах, сами проекты достаточно большие) эта библиотека хорошо подходит.
Вкратце вот мои рекомендации по выбору того или иного подхода:
- Если приложение примитивное, состоит из пары компонентов — храните состояние в них.
- Если в приложении несколько страниц, десяток компонентов — храните состояние в сервисах.
- Если приложение простое, но возможно будет расти, — используйте сервисы, но начните готовиться к возможному рефакторингу:
- Храните состояние в сервисах, а весь код, связанный с HTTP API, выносите в отдельные сервисы (я называю их backend-сервисы). В дальнейшем все backend-сервисы останутся, а поменяются только store-сервисы.
- Используйте два типа компонентов: «умные» и «глупые». В «умных» получайте все данные и передавайте их в «глупые» через атрибуты. В «глупых» компонентах не должно быть логики, только отображение данных и реакция на действия пользователя. Все данные в «глупом» компоненте передаются через
@Input
и@Output
. При рефакторинге код «глупых» компонентов не изменится, поменяются только «умные» компоненты. - В компонентах не обращайтесь к полям сервиса напрямую, сохраняйте стримы в полях компонента. Тогда вам не придется менять шаблон, только логику компонента.
- Если предполагается написать большое приложение с большим состоянием, которое нужно отображать, используйте библиотеки, например NgRx.
- Даже если вы взяли NgRx, необязательно использовать ее для всего. Некоторые сторонние библиотеки плохо интегрируются с NgRx. Если есть сложности со встраиванием в инфраструктуру NgRx, то не стоит писать костыли — просто не храните это состояние в общем сторе, ничего страшного в этом нет.
- Если в приложении предполагается больше форм ввода информации, чем вывода, то, возможно, не стоит тянуть в проект NgRx. Либо просто не храните состояние форм в сторе.