Реактивные приложения на Angular/NGRX. Часть 2. Store.

Igor Demyanyuk
8 min readJan 26, 2018
Photo by timoshchsveta

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

TL;DR Статья вышла немного объемнее, чем обычно, поэтому если вы знакомы с некоторыми аспектами, можете просто посмотреть пример на github и полезные ссылки в заключении статьи.

Redux

Быстро пробежимся по основным моментам:

  • Единственно точный источник данные — состояние\хранилище (store)
  • Состояние является неизменяемым (read only)
  • Изменять состояние могут только специальные функции — reduсers
  • Редьюсеры срабатывают только при возникновении какого-либо действия — actions

Отлично, теперь перейдем непосредственно к практике:

Сгенерируем приложение c помощью angular-cli:

ng new ngrx-films-list

И сразу же установим необходимый нам модуль ngrx/store:

npm install @ngrx/store --save

Для демонстрации и принципа работы store нам понадобится:

  1. Описать состояние и то как оно будет изменятся
  2. Описать действия, которые смогут изменять состояние
  3. Создать компонент контейнер и связать его с хранилищем

В данном проекте будем использовать следующую структуру директорий:

  • components содержит stateless компоненты
  • models модельки сущностей
  • store/actions действия, меняющие состояние хранилища
  • store/reducers описание состояния и его изменений
  • app.component будет контейнером

1. State. Reducers. Selectors.

Итак, для начала опишем нашу модельку models/film.ts:

export interface Film {    
id: number;
name: string;
img: string;
description: string;
}

Состояние

Теперь опишем как будет выглядеть наше хранилище и его начальное состояние в store/reducers/films.ts Для примера заполним начальное состояние 3-мя фильмами:

import { Action } from '@ngrx/store';
import * as filmAction from '../actions/films';
import { Film } from '../../models';export interface State {
ids: number[];
films: { [id: number]: Film };
selected: number;
}
export const initialState: State = {
ids: [1, 2, 3],
films: {
1: {
id: 1, name: 'Interstellar',
description: 'Interstellar is a 2014 epic science fiction film
directed, co-written, and co-produced by Christopher Nolan.',
img: 'https://goo.gl/8mG12t'
},
2: {
id: 2, name: 'Shutter Island',
description: 'In 1954, a U.S. Marshal investigates the
disappearance of a murderer, who escaped from a hospital for
the criminally insane.',
img: 'https://goo.gl/wfhjUF'
},
3: {
id: 3, name: 'The Grand Budapest Hotel',
description: 'The adventures of Gustave H, a legendary
concierge at a famous hotel the lobby boy who becomes his most
trusted friend.',
img: 'https://goo.gl/mDBt45'
},
},
selected: null,
};

Хотел бы обратить ваше внимание на то, в каком виде мы храним наши сущности , это Map (словарь) и массив идентификаторов. Таким образом мы обеспечиваем быстрый доступ при поиске\выборе\удалении записи.

Хранилище, конечно, не база данных, но старайтесь при проектировании избегать избыточности (в поле selected мы храним лишь id фильма, а не весь объект).

Редьюсер

Далее опишем reducer store/films.ts

export function reducer(state = initialState, 
action: filmAction.Action) {
switch (action.type) {
case filmAction.ADD_ONE: {
const newFilm: Film = action.payload;
return {
...state,
ids: [...state.ids, newFilm.id],
films: { ...state.films, newFilm }
};
}
case filmAction.SELECT: {
const id = action.payload;
return {
...state,
selected: id
};
}
default:
return state;
}
}

Редьюсер принимает два аргумента: текущее состояние (при инициализации назначаем начальное состояние) и действие, содержащее тип и полезную нагрузку. Далее в зависимости от действия определенным образом формирует и возвращает новый объект хранилища.

Если не возвращать новый объект, а изменить старый — хранилище не обновится и приложение не узнает об изменениях.

Композиция

Вы заметили, что мы описали часть хранилища в контексте фильмов? Так вот таких частей может быть несколько даже в одном модуле. Мы можем описать множество таких отдельно взятых частей хранилища, но нам обязательно необходимо их объединить store/reducers/index.ts :

import { ActionReducerMap, createSelector, createFeatureSelector, 
ActionReducer, MetaReducer } from '@ngrx/store';
import * as fromFilms from './films';export interface State {
films: fromFilms.State;
}
export const reducers: ActionReducerMap<State> = {
films: fromFilms.reducer
};
export function logger(reducer: ActionReducer<State>):
ActionReducer<State> {
return function (state: State, action: any): State {
console.log('state', state);
console.log('action', action);
return reducer(state, action);
};
}
export const metaReducers: MetaReducer<State>[] = [logger];

Мы создали ещё один редьюсер, который является композитором и включает в себя более атомарные редьюсеры. Также мы создали для него интерфейс и функцию logger для того, чтобы видеть в консоле как меняется наше хранилище. Эту функцию мы добавим в массив метаредьюсеров (можно считать ее как middleware).

Селекторы

Теперь представьте то, как бы мы могли обратиться к нашему хранилищу? Легко! Допустим, в компоненте нужно вывести название выбранного фильма:

const name = store.films.selected.name;

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

export const getIds = (state: State) => state.ids;
export const getFilms = (state: State) => state.films;
export const getSelected = (state: State) => state.selected;

И кое-что добавим в store/reducers/index.ts :

export const getFilmState = 
createFeatureSelector<fromFilms.State>('films');
export const getIds = createSelector(
getFilmState,
fromFilms.getIds,
);
export const getFilms = createSelector(
getFilmState,
fromFilms.getFilms,
);
export const getSelected = createSelector(
getFilmState,
fromFilms.getSelected,
);
export const getSelectedFilm = createSelector(
getSelected,
getFilms,
(selectedId, films) => {
return {
...films[selectedId]
};
}
);
export const getAllFilms = createSelector(
getIds,
getFilms,
(ids, films) => {
return ids.map(id => films[id]);
}
);

Функция getFilmState принимает текущее состояние и возвращает только его часть, а именно films, как раз ту, которую мы описывали в store/reduсers/film.ts . Остальные функции делают то же самое, но отдают уже конкретные свойства ids, films, selected.

А вот функция getAllFilms принимает уже три аргумента-функции: getIds, getFilms и третью — анонимную, которая принимает параметры (результаты выполнения) от двух предыдущих функций. Таким образом мы получаем массив ids и словарь films и в результате отдаем массив фильмов. Аналогично выполнена функция getAllFilms.

Селекторы, как вы могли заметить, преобразуют “нормализованные” данные в те, которые мы можем показать пользователю. Если Вам непросто сразу вникнуть в код - это нормально, особенно, если Вы не знакомы с функциональным программированием. Прочтите пару статей про ФП и Ваше понимание селекторов станет лучше — не опускайте руки!

Преимущества использования селекторов:

  • мемоизация — т.к. селекторы это чистые функции, т.е. при одном и том же значении функция возвращает один и тот же результат, ранее вычисленные результаты могут быть возвращены без повторных вычислений (для простоты воспринимайте это как кэш), что дает преимущества в производительности;
  • подписка на значимые для конкретного модуля части хранилища;
  • селекторы не будут вычисляться вхолостую, если в сторе не появилось нового значения. Например, при добавлении нового фильма в словарь и массив идентификаторов селектор getSelected не выполнит next у Observable нашего потока и перерендера не будет.

2. Actions

На данном этапе мы имеем хранилище и описание его изменений. Но как компонент может сообщить хранилищу, что должно произойти изменение?

Описание действий

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

const SELECT = '[Films] Select';// аргумент функции отправки действия
{ type: SELECT, payload: 1 }

или

const SELECT = '[Films] Select';class Select implements Action {
readonly type = SELECT;
constructor(public payload: number) { }
}
// аргумент функции отправки действия
new Select(1)

Может показаться, что первый вариант удобнее, но поверьте, по мере роста вашего приложения и разнообразия действий и их аргументов, второй вариант будет Вашим спасением (как минимум автодополнение и проверка типов аргумента payload). Кстати, в квадратных скобках мы просто указали название части нашей сторы (или модуля в остальных случаях), чтобы в консоли или в redux-devtools мы видели какой тип изменений произошел и к чему он относится. Согласитесь, тип loadData или selectEntity может быть не в одном модуле.

Опишем действия для нашего приложения store/actions/fillms.ts:

import { Action } from '@ngrx/store';
import { Film } from '../../models';
export const SELECT = '[Films] Select';
export const ADD_ONE = '[Films] Add One';
export class Select implements Action {
readonly type = SELECT;
constructor(public payload: number) { }
}
export class AddOne implements Action {
readonly type = ADD_ONE;
constructor(public payload: Film) { }
}
export type Action = AddOne | Select;

Отправка действий

Чтобы сообщить сторе, что нужно произвести изменение нашего хранилища, мы воспользуемся методом dispatch класса Store. Для этого заинжектим его в сервис или компонент, указав стейт нашего хранилища. Аргументом функции dispatch, как раз, является наш класс и полезная нагрузка.

import { Film } from './models';
import { Store } from '@ngrx/store';
import * as fromRoot from './store/reducers';
import * as filmAction from './store/actions/films';
/* ... */constructor(private store: Store<fromRoot.State>) {}/* ... */this.store.dispatch(new filmAction.Select(id));

3. Использование в компонентах

Итак, мы преодолели довольно длительный путь и сейчас самое время вывести данные пользователю — сделаем это:

Сгенерируем два stateless компонента:

ng g c components/film-list -it -is
ng g c components/film-item -it -is

Далее опишем темплейты и байндинг для каждого film-item.ts:

import { Film } from '../../models';@Component({
selector: 'app-film-item',
template: `<li (click)="select.emit(film.id)">{{film.name}}</li>`,
styles: []
})
export class FilmItemComponent implements OnInit {
@Input() film: Film;
@Output() select = new EventEmitter();

constructor() { }

ngOnInit() {}
}

film-list.ts :

import { Film } from '../../models';@Component({
selector: 'app-film-list',
template: `
<div class="list">
<h2>{{label}}</h2>
<app-film-item *ngFor="let film of films"
[film]="film"
(select)="select.emit($event)">
</app-film-item>
</div>`,
styles: [`
.list {
display:flex;
flex-direction:column;
}
`]
})
export class FilmListComponent implements OnInit, OnChanges {
@Input() films: Film[];
@Input() label: string;
@Output() select = new EventEmitter();
constructor() { } ngOnInit() {}
}

film-selected.ts :

import { Film } from '../../models';@Component({
selector: 'app-film-selected',
template: `
<div *ngIf="film.name">
<h1>{{film.name}}</h1>
<p>{{film.description}}</p>
<img src="{{film.img}}" alt="film image">
</div>
`,
styles: []
})
export class FilmSelectedComponent implements OnInit {
@Input() film: Film;
constructor() { }

ngOnInit() {}
}

И отредактируем существующий app.component.ts , который в данном случае будет являться контейнером:

import { Component } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { Film } from './models';
import { Store } from '@ngrx/store';
import * as fromRoot from './store/reducers';
import * as filmAction from './store/actions/films';
@Component({
selector: 'app-root',
template: `
<main class="container">
<app-film-list
[films]="films$ | async"
[label]="'Список фильмов'"
(select)="onSelect($event)">
</app-film-list>
<app-film-selected [film]="selected$ | async">
</app-film-selected>
</main>
`,
styleUrls: ['./app.component.scss']
})
export class AppComponent {
films$: Observable<Film[]>;
selected$: Observable<any>;
constructor(private store: Store<fromRoot.State>) {
this.films$ = store.select(fromRoot.getAllFilms);
this.selected$ = store.select(fromRoot.getSelectedFilm);
}
onSelect(id: number) {
this.store.dispatch(new filmAction.Select(id));
}
}

В этом компоненте мы импортируем state и actions. Создаем два свойства films$ и selected$ — это два потока данных (store.select() возвращает Observable), поэтому при передачи их в наши компоненты представления — не забываем подписаться на них | async . Так же на прокинутые Output вешаем dispatch для отправки изменений.

Итак, состояние приложения меняется и мы в потоке слушаем эти изменения, при их возникновении происходит ререндер компонентов, при условии, конечно, что данные являются новым объектом.

Заключение

В заключение хочу сказать, что код приложения максимально упрощён, в проде необходимо разбивать все на модули и подключать сторы не как root, а forFeature(), что станет необходимостью при реализации lazyLoad модулей.

Когда я, в том числе и некоторые мои знакомые, только начинали знакомиться с ngrx/store, не совсем понимали как связать его с данными с сервера и где держать side effects нашего приложения — об этом мы поговорим уже в следующей статье про ngrx/effects.

Полезные ссылки:

Можете подписаться, чтобы не пропустить следующие статьи, и не забываем хлопать — так я буду знать, что материал оказался полезным для Вас!

--

--