image
На сегодняшний день ни одно большое 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 паттерн.

Простая реализация store
counter.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 { Observable } 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$: Observable<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 { Observable, of } from 'rxjs';
import { catchError, map, mergeMap } from 'rxjs/operators';

@Injectable()
export class AuthEffects {
  // Listen for the 'LOGIN' action
  @Effect()
  login$: Observable<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 interface User {
  id: string;
  name: string;
}

user.actions.ts

import { Action } from '@ngrx/store';
import { Update } 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 {
  users: fromUser.State;
}

export const reducers: ActionReducerMap<State> = {
  users: 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. Именно поэтому John Papa и 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 { Observable } 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$: Observable<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$: Observable<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:
— John Papa twitter.com/John_Papa
— Ward Bell twitter.com/wardbell

Оффициальные репозитории:
NgRx
angular-ngrx-data

Пример приложения:
с NgRx без angular-ngrx-data
c NgRx и angular-ngrx-data

Русскоговорящее Angular сообщество в Telegram

Комментарии (11)


  1. Fengol
    13.08.2018 23:44

    Для не hello world приложений пишут unit и e2e тесты. Вопрос — кому сдались devtools?


    1. klimentRu Автор
      14.08.2018 06:09

      Даже если фитча описана со всеми ньюансами и вы используете TDD со 100% покрытием тестами, редко обходится без отладки через chrome-devtools (если это не hello world, то практически никогда).

      redux-devtools — это дополнительный инструмент более удобный чем console.log и debugger.


      1. Fengol
        14.08.2018 08:34

        А что в chrome-devtools отлаживать? У меня максимум взаимодействия с devtools, это редактирование стилей.


        1. klerick
          14.08.2018 14:40

          поставить точку останова, не?


          1. Fengol
            14.08.2018 20:07
            -1

            Я не понимаю зачем их ставить… Вот на сервере nodejs, c#, бывает ставлю. В unyty тоже ставлю. В браузере для отладки приложений написанных на canvas (в основном игр) тоже ставлю, так как там реально динамика и настолько сложная логика отображения (движение каждый тик множества объектов). Но я искренне не понимаю зачем точки остановы для обычного angular или reactприложения.


    1. Valery4
      14.08.2018 19:16

      Вы когда view слой пишете, помните наизусть, что там в состоянии и где?


      1. klimentRu Автор
        14.08.2018 19:49

        Что имеете в виду?
        Состояние хранится в store (хранилище), отдельно от приложения, а компоненты подписываются на данные которые им нужны.


        1. Valery4
          14.08.2018 20:08

          Мне вот удобно посмотреть содержимое некоего объекта в хранилище, которое ещё и динамически может изменяться. Держать всё это в голове не вижу ни малейшей необходимости. Для этого как раз и удобно использовать redux-devtools, которые прекрасно работают и с NgRx насколько мне известно.


      1. Fengol
        14.08.2018 20:03

        Конечно. А как я буду писать код, если не знаю или непомню что и где хранится?


        1. Valery4
          14.08.2018 20:11

          Знать необходимо, вот нужно ли помнить состояние всего приложения? Если вы пишете компонент с нуля, вполне реально держать всю картину в голове. А если вы пришли поправить, изменить или добавить что нибудь через пол-года? Открыв dev-tools вы сразу увидите, что есть в состоянии.


          1. Fengol
            15.08.2018 00:07

            Ну ведь это все надумано и раздуто до неприличия. Если Вы пришли поправить компонент, то зачем Вам все состояние всего приложения? И ведь куда проще посмотреть по спекам, редюсерам или chema. А при самой разработке, ведь есть тесты, где вы опять только с кусочком работаете не зная который вы не сможете написать код. А зная уже не сможете так быстро забыть.