Angular-ngrx-data — state management і CRUD за п’ять хвилин
На сьогоднішній день жодна велика SPA програма не обходиться без state management (управління). Для Angular за даним напрямом є кілька рішень. Найпопулярнішим з них є NgRx. Він реалізує Redux патерн з використанням бібліотеки RxJs і володіє хорошим інструментарієм.
У даній статті ми коротко пройдемося по основним модулям NgRx і більш детально зосередимося на бібліотеці angular-ngrx-data, яка дозволяє зробити повноцінний CRUD зі state management за п’ять хвилин.
Огляд NgRx
Детально про NgRx можна почитати в наступних статтях:
— Реактивні програми на Angular/NGRX. Частина 1. Введення
— Реактивні програми на Angular/NGRX. Частина 2. Store
— Реактивні програми на Angular/NGRX. Частина 3. Effects
Коротко розглянемо основні модулі NgRx, його плюси і мінуси.
NgRx/store — реалізує Redux патерн.
Проста реалізація storecounter.actions.ts
export const INCREMENT = 'INCREMENT';
export const DECREMENT = 'DECREMENT';
export const RESET = 'RESET';
counter.reducer.ts
import { Action } from '@ngrx/store';
const initialState = 0;
export function counterReducer(state: number = initialState, action: Action) {
switch (action.type) {
case INCREMENT:
return state + 1;
case DECREMENT:
return state - 1;
case RESET:
return 0;
default:
return state;
}
}
.
Підключення до модуля
import { NgModule } from '@angular/core';
import { StoreModule } from '@ngrx/store';
import { counterReducer } from './counter';
@NgModule({
imports: [StoreModule.forRoot({ count: counterReducer })],
})
export class AppModule {}
Використання в компоненті
import { Component } from '@angular/core';
import { Store, select } from '@ngrx/store';
import { Вами } from 'rxjs';
import { INCREMENT, DECREMENT, RESET } from './counter';
interface AppState {
count: number;
}
@Component({
selector: 'app-my-counter',
template: `
<button (click)="increment()">Increment</button>
<div>Current Count: {{ count$ | async }}</div>
<button (click)="decrement()">Decrement</button>
<button (click)="reset()">Reset Counter</button>
`,
})
export class MyCounterComponent {
count$: Вами<number>;
constructor(private store: Store<AppState>) {
this.count$ = store.pipe(select('count'));
}
increment() {
this.store.dispatch({ type: INCREMENT });
}
decrement() {
this.store.dispatch({ type: DECREMENT });
}
reset() {
this.store.dispatch({ type: RESET });
}
}
NgRx/store-devtools — дозволяє відстежувати зміни в додатку через redux-devtools.
Приклад підключення
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
@NgModule({
imports: [
StoreModule.forRoot(reducers),
// Модуль повинен бути підключений після StoreModule
StoreDevtoolsModule.instrument({
maxAge: 25, // Зберігаються останні 25 станів
}),
],
})
export class AppModule {}
NgRx/effects дозволяє додавати в сховище дані, що приходять в додаток, такі як http запити.
Приклад./effects/auth.effects.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Action } from '@ngrx/store';
import { Actions, Effect, ofType } from '@ngrx/effects';
import { Вами, of } from 'rxjs';
import { catchError, map, mergeMap } from 'rxjs/operators';
@Injectable()
export class AuthEffects {
// Listen for the 'LOGIN' action
@Effect()
login$: Вами<Action> = this.actions$.pipe(
ofType('LOGIN'),
mergeMap(action =>
this.http.post('/auth', action.payload).pipe(
// If successful, dispatch success action with result
map(data => ({ type: 'LOGIN_SUCCESS', payload: data })),
// If request fails, dispatch failed action
catchError(() => of({ type: 'LOGIN_FAILED' }))
)
)
);
constructor(private http: HttpClient, private actions$: Actions) {}
}
Підключення ефекту в модуль
import { EffectsModule } from '@ngrx/effects';
import { AuthEffects } from './effects/auth.effects';
@NgModule({
imports: [EffectsModule.forRoot([AuthEffects])],
})
export class AppModule {}
NgRx/entity — надає можливість працювати з масивами даних.
Прикладuser.model.ts
export User interface {
id: string;
name: string;
}
user.actions.ts
import { Action } from '@ngrx/store';
import { Оновити } from '@ngrx/entity';
import { User } from './user.model';
export enum UserActionTypes {
LOAD_USERS = '[User] Load Users',
ADD_USER = '[User] Add User',
UPSERT_USER = '[User] Upsert User',
ADD_USERS = '[User] Add Users',
UPSERT_USERS = '[User] Upsert Users',
UPDATE_USER = '[User] Update User',
UPDATE_USERS = '[User] Update Users',
DELETE_USER = '[User] Delete User',
DELETE_USERS = '[User] Delete Users',
CLEAR_USERS = '[User] Clear Users',
}
export class LoadUsers implements Action {
readonly type = UserActionTypes.LOAD_USERS;
constructor(public payload: { users: User[] }) {}
}
export class AddUser implements Action {
readonly type = UserActionTypes.ADD_USER;
constructor(public payload: { user: User }) {}
}
export class UpsertUser implements Action {
readonly type = UserActionTypes.UPSERT_USER;
constructor(public payload: { user: User }) {}
}
export class AddUsers implements Action {
readonly type = UserActionTypes.ADD_USERS;
constructor(public payload: { users: User[] }) {}
}
export class UpsertUsers implements Action {
readonly type = UserActionTypes.UPSERT_USERS;
constructor(public payload: { users: User[] }) {}
}
export class UpdateUser implements Action {
readonly type = UserActionTypes.UPDATE_USER;
constructor(public payload: { user: Update<User> }) {}
}
export class UpdateUsers implements Action {
readonly type = UserActionTypes.UPDATE_USERS;
constructor(public payload: { users: Update<User>[] }) {}
}
export class DeleteUser implements Action {
readonly type = UserActionTypes.DELETE_USER;
constructor(public payload: { id: string }) {}
}
export class DeleteUsers implements Action {
readonly type = UserActionTypes.DELETE_USERS;
constructor(public payload: { ids: string[] }) {}
}
export class ClearUsers implements Action {
readonly type = UserActionTypes.CLEAR_USERS;
}
export type UserActionsUnion =
| LoadUsers
| AddUser
| UpsertUser
| AddUsers
| UpsertUsers
| UpdateUser
| UpdateUsers
| DeleteUser
| DeleteUsers
| ClearUsers;
user.reducer.ts
import { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity';
import { User } from './user.model';
import { UserActionsUnion, UserActionTypes } from './user.actions';
export interface State extends EntityState<User> {
// additional entities state properties
selectedUserId: number | null;
}
export const adapter: EntityAdapter<User> = createEntityAdapter<User>();
export const initialState: State = adapter.getInitialState({
// additional entity state properties
selectedUserId: null,
});
export function reducer(state = initialState, action: UserActionsUnion): State {
switch (action.type) {
case UserActionTypes.ADD_USER: {
return adapter.addOne(action.payload.user, state);
}
case UserActionTypes.UPSERT_USER: {
return adapter.upsertOne(action.payload.user, state);
}
case UserActionTypes.ADD_USERS: {
return adapter.addMany(action.payload.users, state);
}
case UserActionTypes.UPSERT_USERS: {
return adapter.upsertMany(action.payload.users, state);
}
case UserActionTypes.UPDATE_USER: {
return adapter.updateOne(action.payload.user, state);
}
case UserActionTypes.UPDATE_USERS: {
return adapter.updateMany(action.payload.users, state);
}
case UserActionTypes.DELETE_USER: {
return adapter.removeOne(action.payload.id state);
}
case UserActionTypes.DELETE_USERS: {
return adapter.removeMany(action.payload.ids, state);
}
case UserActionTypes.LOAD_USERS: {
return adapter.addAll(action.payload.users, state);
}
case UserActionTypes.CLEAR_USERS: {
return adapter.removeAll({ ...state, selectedUserId: null });
}
default: {
return state;
}
}
}
export const getSelectedUserId = (state: State) => state.selectedUserId;
// get the selectors
const { selectIds, selectEntities, selectAll, selectTotal } = adapter.getSelectors();
// select the array of user ids
export const selectUserIds = selectIds;
// select the dictionary of user entities
export const selectUserEntities = selectEntities;
// select the array of users
export const selectAllUsers = selectAll;
// select the total user count
export const selectUserTotal = selectTotal;
reducers/index.ts
import {
createSelector,
createFeatureSelector,
ActionReducerMap,
} from '@ngrx/store';
import * as fromUser from './user.reducer';
export interface State {
користувачі: fromUser.State;
}
export const reducers: ActionReducerMap<State> = {
користувачі: fromUser.reducer,
};
export const selectUserState = createFeatureSelector<fromUser.State>('users');
export const selectUserIds = createSelector(
selectUserState,
fromUser.selectUserIds
);
export const selectUserEntities = createSelector(
selectUserState,
fromUser.selectUserEntities
);
export const selectAllUsers = createSelector(
selectUserState,
fromUser.selectAllUsers
);
export const selectUserTotal = createSelector(
selectUserState,
fromUser.selectUserTotal
);
export const selectCurrentUserId = createSelector(
selectUserState,
fromUser.getSelectedUserId
);
export const selectCurrentUser = createSelector(
selectUserEntities,
selectCurrentUserId,
(userEntities, userId) => userEntities[userId]
);
Що в підсумку?
Ми отримуємо повноцінний state management з купою плюсів:
— єдиний джерело даних для програми,
— стан зберігається окремо від програми,
— єдиний стиль написання для всіх розробників у проекті,
— changeDetectionStrategy.OnPush у всіх компонентах програми,
— зручне налагодження через redux-devtools,
— легкість тестування, так як reducers є “чистими” функціями.
Але є і мінуси:
— велика кількість незрозумілих на перший погляд модулів,
— багато однотипного коду, на який без смутку не глянеш,
— складність в освоєнні через все вище перераховане.
CRUD
Як правило, значну частину програми займає робота з об’єктами (створення, читання, оновлення, видалення), тому для зручності роботи була придумана концепція CRUD (Create, Read, Update, Delete). Таким чином, базові операції для роботи з усіма типами об’єктів стандартизовані. На бэкенді це уже давно процвітає. Багато бібліотеки допомагають реалізувати дану функціональність і позбутися від рутинної роботи.
У NgRx за CRUD відповідає модуль entity, і якщо подивитися приклад його реалізації, відразу видно, що це найбільша і найскладніша частина NgRx. Саме тому Papa John Ward і Bell створили angular-ngrx-data.
angular-ngrx-data
angular-ngrx-data — це бібліотека-надбудова над NgRx, яка дозволяє працювати з масивами даних без написання зайвого коду.
Крім створення повноцінного state management, вона бере на себе створення сервісів з http для взаємодії з сервером.
Розглянемо на прикладі
Установка
npm install --save @ngrx/store @ngrx/effects @ngrx/entity @ngrx/store-devtools ngrx-data
Модуль angular-ngrx-data
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
EntityMetadataMap,
NgrxDataModule,
DefaultDataServiceConfig
} from 'ngrx-data';
const defaultDataServiceConfig: DefaultDataServiceConfig = {
root: 'crud'
};
export const entityMetadata: EntityMetadataMap = {
Hero: {},
User:{}
};
export const pluralNames = { Hero: 'heroes' };
@NgModule({
imports: [
CommonModule,
NgrxDataModule.forRoot({ entityMetadata, pluralNames })
],
declarations: [],
providers: [
{ provide: DefaultDataServiceConfig, useValue: defaultDataServiceConfig }
]
})
export class EntityStoreModule {}
Підключення в додаток
@NgModule({
imports: [
BrowserModule,
HttpClientModule,
StoreModule.forRoot({}),
EffectsModule.forRoot([]),
EntityStoreModule,
StoreDevtoolsModule.instrument({
maxAge: 25,
}),
],
declarations: [
AppComponent
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule {}
Тільки що ми отримали згенероване API для роботи з беком та інтеграцію API з NgRx, не написавши при цьому жодного effect, reducer і action і selector.
Розберемо більш докладно те, що тут відбувається
Константа defaultDataServiceConfig задає конфігурацію для нашого API і підключається в providers модуля. Властивість root вказує, куди звертатися за запитами. Якщо його не поставити, то за замовчуванням буде «api».
const defaultDataServiceConfig: DefaultDataServiceConfig = {
root: 'crud'
};
Константа entityMetadata визначає назви сторів, які будуть створені при підключенні NgrxDataModule.forRoot.
export const entityMetadata: EntityMetadataMap = {
Hero: {},
User:{}
};
...
NgrxDataModule.forRoot({ entityMetadata, pluralNames })
Шлях до API складається з базового шляху (у нашому випадку «crud») та імені стора.
Наприклад, для отримання користувача з певним номером шлях буде такий — «crud/user/{userId}».
Для одержання повного списку користувачів в кінці імені стора за замовчуванням додається літера «s» — «crud/users».
Якщо для одержання повного списку потрібен (наприклад, «heroes», а не «heros»), його можна змінити, задавши pluralNames і підключивши їх в NgrxDataModule.forRoot.
export const pluralNames = { Hero: 'heroes' };
...
NgrxDataModule.forRoot({ entityMetadata, pluralNames })
Підключення в компоненті
Для підключення в компоненті необхідно передати в конструктор entityServices і через метод getEntityCollectionService вибрати сервіс потрібного сховища
import { Component, OnInit, ChangeDetectionStrategy } from '@angular/core';
import { Вами } from 'rxjs';
import { Hero } from '@appModels/hero';
import { EntityServices, EntityCollectionService } from 'ngrx-data';
@Component({
selector: 'app-heroes',
templateUrl: './heroes.component.html',
styleUrls: ['./heroes.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class HeroesComponent implements OnInit {
heroes$: Вами<Hero[]>;
heroesService: EntityCollectionService<Hero>;
constructor(entityServices: EntityServices) {
this.heroesService = entityServices.getEntityCollectionService('Hero');
}
...
}
Для прив’язки до списку компоненту достатньо взяти з сервісу властивість entities$, а для отримання даних з сервера викликати метод getAll().
ngOnInit() {
this.heroes$ = this.heroesService.entities$;
this.heroesService.getAll();
}
Також, крім основних даних, можна отримати:
— loaded$, loading$ — отримання статусу завантаження даних,
— errors$ — помилки при роботі сервісу,
— count$ — загальна кількість записів у сховище.
Основні методи взаємодії з сервером:
— getAll() — отримання списку даних,
— getWithQuery(query) — отримання списку, відфільтрованого з допомогою query-параметрів,
— getByKey(id) — отримання запису за ідентифікатором,
— add(entity) — додавання нової сутності із запитом на бек,
— delete(entity) — видалення сутності із запитом на бек,
— update(entity) — оновлення сутності із запитом на бек.
Методи локальної роботи зі сховищем:
— addManyToCache(entity) — додавання масиву нових сутностей в сховище,
— addOneToCache(entity) — додавання нової сутності тільки в сховище,
— removeOneFromCache(id) — видалення однієї сутності з сховища,
— updateOneInCache(entity) — оновлення сутності в сховищі,
— upsertOneInCache(entity) — якщо сутність із зазначеним id існує, вона оновлюється, якщо немає — створюється нова,
— та ін
Приклад використання в компоненті
import { EntityCollectionService, EntityServices } from 'ngrx-data';
import { Hero } from '../../core';
@Component({
selector: 'app-heroes',
templateUrl: './heroes.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class HeroesComponent implements OnInit {
heroes$: Вами<Hero[]>;
heroesService: EntityCollectionService<Hero>;
constructor(entityServices: EntityServices) {
this.heroesService = entityServices.getEntityCollectionService('Hero');
}
ngOnInit() {
this.heroes$ = this.heroesService.entities$;
this.getHeroes();
}
getHeroes() {
this.heroesService.getAll();
}
addHero(hero: Hero) {
this.heroesService.add(hero);
}
deleteHero(hero: Hero) {
this.heroesService.delete(hero.id);
}
updateHero(hero: Hero) {
this.heroesService.update(hero);
}
}
Всі методи angular-ngrx-data поділяються на працюючі локально і взаємодіють з сервером. Це дозволяє використовувати бібліотеку при маніпуляціях з даними як на клієнті, так і з використанням сервера.
Логування
Для логування необхідно заінжектити EntityServices в компонент або сервіс і використовувати властивості:
— reducedActions$ — для логування дій,
— entityActionErrors$ — для логування помилок.
import { Component, OnInit } from '@angular/core';
import { MessageService } from '@appServices/message.service';
import { EntityServices } from 'ngrx-data';
@Component({
selector: 'app-messages',
templateUrl: './messages.component.html',
styleUrls: ['./messages.component.css']
})
export class MessagesComponent implements OnInit {
constructor(
public messageService: MessageService,
private entityServices: EntityServices
) {}
ngOnInit() {
this.entityServices.reducedActions$.subscribe(res => {
if (res && res.type) {
this.messageService.add(res.type);
}
});
}
}
Переїзд в основний репозиторій NgRx
Як було оголошено на ng-conf 2018, angular-ngrx-data найближчим часом буде перенесений в основний репозиторій NgRx.
Відео з доповіддю Reducing the Boilerplate with NgRx – Brandon Roberts & Mike Ryan
Джерела
Творці anguar-ngrx-data:
— Papa John twitter.com/John_Papa
— Ward Bell twitter.com/wardbell
Російськомовне Angular співтовариство в Telegram