Руководство по реализации NgRx в Angular 16

NgRx — одна из самых популярных систем управления состоянием во всей экосистеме Angular. Но что такое на самом деле система управления состоянием и зачем она мне нужна в моем приложении?

Начнем с краткого объяснения:

  • Состояние — набор данных, который используется в нашем приложении.
  • Система управления — набор централизованных инструментов для упрощения/организации/структурирования данных, используемых нашим приложением.

По определению, данные системы управления состоянием имеют глобальную область действия, что означает, что к ним можно обращаться и использовать из любого места в нашем приложении. В отличие от простого Angular Service, его нельзя ограничить конкретной частью/функцией приложения.

NgRx дополнительно предлагает библиотеку @ngrx/component-store, в частности, для обеспечения функций области видимости. Хранилище компонентов можно лениво загружать, и оно привязывается к жизненному циклу компонента (инициализируется при создании компонента и уничтожается вместе с ним) — будет рассмотрено в другой статье.

Но мы здесь, чтобы больше сосредоточиться на глобальном подходе. Библиотека NgRx обеспечивает реализацию Redux архитектуры Flux, что означает однонаправленный поток и предсказуемость.

Не каждый набор данных требует использования системы управления состоянием! Вам не нужно помещать все в магазин NgRx! Если ваши данные необходимо совместно использовать / повторно использовать в различных функциях приложения / несвязанных компонентах или содержать словари, имеет смысл использовать NgRx! Иначе он может вам не понадобиться!

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

  • Состояние — представляет простой объект JSON, который является единым источником достоверных данных приложения в глобальном масштабе.
  • Селекторы — запоминаемые функции, позволяющие брать определенный фрагмент состояния и прослушивать/подписываться на его изменения.
  • Редукторы — единственное место, где непосредственно объект состояния обновляется неизменяемым образом. Гарантирует предсказуемые обновления. Вы можете использовать адаптер из @ngrx/entity library, чтобы обеспечить некоторые из наиболее общих неизменяемых операций из коробки.
  • Эффекты — классы (теперь даже функции!) для обработки асинхронных операций, таких как выборка данных. Это место, где мы можем прослушивать отправку конкретных действий. В большинстве случаев эффекты возвращают действие, но это не обязательно.
  • Действия — это определенные операции/команды, которые отправляются, например обновления данных (операции CRUD). Это единственный способ обновить состояние приложения NgRx.

Кроме того, именно в Angular-приложениях имеет смысл применять технику Facade Services — класс, реализующий паттерн проектирования фасада, используется как единственная возможность доступа к данным/триггерным действиям из хранилища.

Кроме того, ознакомьтесь с этими инструментами разработки Angular для упрощения разработки Angular в 2023 году.

Пример использования: когда необходимо получить доступ к данным из хранилища, нам нужно внедрить через Dependency Injection, Facade Service в компонент, который должен использовать данные/методы/атрибуты оттуда. Не допускается прямое внедрение зависимостей хранилища в компонент. Это имеет решающее значение в случае предоставления одной реализации, которую можно повторно использовать в другом месте (в разных областях приложения).

Для начала просто выполните команду ниже:

ng add @ngrx/store@latest

Магазин NgRx будет первоначально добавлен в ваш файл package.json, а также будет установлен дополнительный пакет eslint, если вы используете eslint в своем приложении. Если вы перейдете к файлу main.ts, вы заметите, что provideStore() был автоматически добавлен в массив ваших поставщиков.

Далее нам нужно установить некоторые дополнительные библиотеки NgRx с помощью следующей команды:

npm install @ngrx/{effects,entity,store-devtools} --save

Мы планируем установить:

  • @ngrx/store-devtools, чтобы связать наше приложение с инструментами разработки браузера и получить приятный опыт наблюдения за изменениями состояния/запуском действий и т. д.
  • Необходима библиотека @ngrx/effects, так как будут выполняться асинхронные операции.
  • Библиотека @ngrx/entity поможет нам управлять нашими данными масштабируемым образом, используя подход, подобный словарю, когда наши объекты хранятся в Record‹id, entity› — где ключ — это уникальный идентификатор нашей сущности, а значение — это сама сущность. Таким образом, даже если мы обработаем сотни тысяч сущностей, наш механизм хранения будет столь же эффективен, как и всего пара (большой O-алгоритм) из них. Дерзкий, да?

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

Наша библиотека @ngrx/store-devtools была успешно извлечена, поэтому мы можем подключить ее к нашему автономному приложению, как показано ниже:

import { enableProdMode, isDevMode } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';

import { environment } from './environments/environment';
import { AppComponent } from './app/app.component';
import { provideStore } from '@ngrx/store';
import { provideStoreDevtools } from '@ngrx/store-devtools';

if (environment.production) {
  enableProdMode();
}

bootstrapApplication(AppComponent, {
  providers: [
    provideStore(),
    provideStoreDevtools({
      maxAge: 25, // Retains last 25 states
      logOnly: !isDevMode(), // Restrict extension to log-only mode
      autoPause: true, // Pauses recording actions and state changes when the extension window is not open
      trace: false, //  If set to true, will include stack trace for every dispatched action, so you can see it in trace tab jumping directly to that part of code
      traceLimit: 75, // maximum stack trace frames to be stored (in case trace option was provided as true)
    }),
  ],
}).catch(err => console.error(err));

Чтобы в полной мере использовать Store Devtools, мне нужно установить плагин для браузера. Поскольку я использую браузер Chrome, это будет Redux Devtools, который доступен по следующей ссылке: https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd?hl=en

Я предпочитаю создать специальную папку, чтобы хранить всю реализацию магазина в одном месте со следующей структурой:

- store
  |-feature-store-1
  |-feature-store-2
  |-feature-store-2
  |  |-index.ts
  |  |-feature-store-1.actions.ts
  |  |-feature-store-1.effects.ts
  |  |-feature-store-1.facade.ts
  |  |-feature-store-1.reducers.ts
  |  |-feature-store-1.selectors.ts
  |  |-feature-store-1.state.ts
  |  |-... // do not forget about unit tests files with .spec.ts! :)
  |-index.ts

В моем случае я создал папку функций messages в магазине, и моя структура выглядит следующим образом:

- store
  |-messages
  |  |-index.ts
  |  |-messages.actions.ts
  |  |-messages.effects.ts
  |  |-messages.facade.ts
  |  |-messages.reducers.ts
  |  |-messages.selectors.ts
  |  |-messages.state.ts
  |-index.ts

Давайте теперь заполним файлы некоторой базовой конфигурацией.

Вот как выглядит мой файл messages.state.ts:

import { createEntityAdapter, EntityAdapter, EntityState } from '@ngrx/entity';
import { Message } from '../../messenger';

export interface MessagesState extends EntityState<Message> {
  loading: [];
}

export const selectId = ({ id }: Message) => id;

export const sortComparer = (a: Message, b: Message): number =>
  a.publishDate.toString().localeCompare(b.publishDate.toString());

export const adapter: EntityAdapter<Message> = createEntityAdapter(
{ selectId, sortComparer }
);

export const initialState: MessagesState = adapter.getInitialState(
{ loading: [] }
);

Я использую библиотеку @ngrx/entity для удобного управления своими данными. В моем определении состояния функции я расширяю общий EntityState, предоставляя библиотеке свой собственный интерфейс данных Message. Тогда я заявляю:

  • Функция selectId, чтобы сообщить адаптеру, как создать уникальный идентификатор для значения объекта объекта.
  • определение функции sortComparer (компаратор не требуется, его не нужно указывать).

Наконец, я создаю свой экземпляр adapter и initialState, которые будут переданы редюсеру в качестве отправной точки. Благодаря адаптеру мой initialState будет выглядеть так в инструментах разработки браузера:

{ 
  messages: { 
    ids: [],
    entities: {},
    loading: [],
  }, 
}

Мы не добавили атрибуты ids и entities, это магия адаптера :-). Итак, когда мы добавляем новую сущность, отправляя действие, наше состояние будет выглядеть следующим образом:

{ 
  messages: { 
    ids: ['1'],
    entities: {
      '1': {
        ... // my Message entity attributes
      }
    },
    loading: [],
  }, 
}

Теперь добавим действия в файл messages.actions.ts:

import { createAction, props } from '@ngrx/store';
import { Message } from '../../messenger';

export const messagesKey = '[Messages]';

export const addMessage = createAction(
  `${messagesKey} Add Message`, 
  props<{ message: Message }>()
);

export const deleteMessage = createAction(
  `${messagesKey} Delete Message`, 
  props<{ id: string }>()
);

Библиотека NgRx позволяет создавать ActionGroups, но об этом я расскажу в отдельной статье!

Далее у нас на повестке дня файл messages.reducers.ts. Вот как это должно выглядеть:

import { ActionReducer, createReducer, on } from '@ngrx/store';
import { adapter, initialState, MessagesState } from './messages.state';
import { addMessage, deleteMessage } from './messages.actions';

export const messagesReducers: ActionReducer<MessagesState> = createReducer(
  initialState,
  on(addMessage, (state: MessagesState, { message }) => 
    adapter.addOne(message, state)),
  on(deleteMessage, (state: MessagesState, { id }) => 
    adapter.removeOne(id, state))
);

Мы создаем messagesReducer с помощью функции createReducer из @ngrx/store library. Мы передаем initialState, объявленный в messages.state.ts, и используем уже созданный адаптер в каждом случае.

Библиотека NgRx предлагает создание функций с очень похожим опытом в React Redux Toolkit или Vuex, но мы рассмотрим это в другой статье!

Кроме того, организуйте свой код NgRx на основе функциональных модулей. Каждый модуль функций должен инкапсулировать связанные действия, редукторы, эффекты, селекторы и модели состояний. Затем вы можете независимо упаковывать, автоматически документировать, делиться и повторно использовать их с помощью Bit. Такой подход обеспечивает организацию вашей кодовой базы, упрощает ее понимание и поддержку.

Узнать больше:



Теперь пришло время добавить наш любимый экспорт стволов в файл index.ts в папке messages следующим образом:

// file location: store/messages/index.ts
export * from './messages.actions';
export * from './messages.reducers';
export * from './messages.state';

Все, что нам нужно сделать сейчас, это подключить наше хранилище функций сообщений к поставщику хранилища, как показано ниже:

import { enableProdMode, isDevMode } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';

import { environment } from './environments/environment';
import { AppComponent } from './app/app.component';
import { provideStore } from '@ngrx/store';
import { provideStoreDevtools } from '@ngrx/store-devtools';
import { messagesReducers } from './app/store/messages';

if (environment.production) {
  enableProdMode();
}

bootstrapApplication(AppComponent, {
  providers: [
    provideStore({ messages: messagesReducers }), // <-- this is the place! :-)
    provideStoreDevtools({
      maxAge: 25, // Retains last 25 states
      logOnly: !isDevMode(), // Restrict extension to log-only mode
      autoPause: true, // Pauses recording actions and state changes when the extension window is not open
      trace: false, //  If set to true, will include stack trace for every dispatched action, so you can see it in trace tab jumping directly to that part of code
      traceLimit: 75, // maximum stack trace frames to be stored (in case trace option was provided as true)
    }),
  ],
}).catch(err => console.error(err));

На этом этапе, если мы хотим проверить наше достижение, мы вполне можем выполнить некоторую проверку работоспособности, внедрив хранилище в наш случайный компонент и отправив такое действие:

import { Component, inject, OnInit } from '@angular/core';
import { MessagesService } from '../../services';
import { Message } from '../../models';
import { Store } from '@ngrx/store';
import { addMessage } from '../../../store/messages';

@Component({
  selector: 'app-messenger',
  templateUrl: './messenger.component.html',
  styleUrls: ['./messenger.component.scss'],
  standalone: true,
})
export class MessengerComponent implements OnInit {
  private readonly store: Store = inject(Store);

  addMessage(): void {
    const message: Message = { /* message object with id attribute */ };
    this.store.dispatch(addMessage({ message: { content } }));
  }

  ngOnInit(): void {
    this.addMessage();
}

В инструментах разработки Redux для Chrome (или другого браузера по вашему выбору) вы сможете увидеть отправленное действие/его полезную нагрузку/разницу/самое последнее состояние, как показано ниже:

Мы также можем добавить основной интерфейс состояния приложения, который можно назвать AppState. Идеальное место для него, похоже, находится в файле index.ts, расположенном непосредственно в сохранить папку:

// file location: store/index.ts
import { MessagesState } from './messages';

export interface AppState {
  messages?: MessagesState;
}

Теперь, когда у нас есть фактические данные в нашем магазине, мы можем создавать селекторы в нашем файле messages.selectors.ts (не забудьте добавить экспорт ствола в соответствующий store/messages/index.ts). !):

import { AppState } from '../index';
import { createFeatureSelector, createSelector, MemoizedSelector } from '@ngrx/store';
import { MessagesState } from './messages.state';
import { Message } from '../../messenger';

export const selectMessagesFeature: MemoizedSelector<AppState, MessagesState> =
  createFeatureSelector<MessagesState>('messages');

export const selectMessages: MemoizedSelector<AppState, Message[]> = 
createSelector(
  selectMessagesFeature,
  ({ entities }: MessagesState): Message[] => 
    Object.values(entities) as Message[]
);

Мы почти закончили, но мы хотим быть профессионалами и следовать принципам DRY-программирования. Мы не хотим внедрять хранилище всякий раз, когда нам нужно получить доступ к нашим данным о состоянии. Мы представим Facade для нашего MessagesState! Не забудьте добавить фасадный импорт в файл store/messages/index.ts!

import { inject, Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { Message } from '../../messenger';
import { addMessage } from './messages.actions';
import { selectMessages } from './messages.selectors';
import { Observable } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class MessagesFacade {
  private readonly store: Store = inject(Store);

  readonly messages$: Observable<Message[]> = this.store.select(selectMessages);

  addMessage(message: Message): void {
    this.store.dispatch(addMessage({ message }));
  }
}

Следующим шагом будет замена нашего тестового использования реальным MessagesFacade:

import { Component, inject, OnInit } from '@angular/core';
import { AsyncPipe, NgFor, NgIf } from '@angular/common';
import { MessagesService } from '../../services';
import { Message } from '../../models';
import { MessagesFacade } from '../../../store/messages';

@Component({
  selector: 'app-messenger',
  templateUrl: './messenger.component.html',
  styleUrls: ['./messenger.component.scss'],
  standalone: true,
})
export class MessengerComponent implements OnInit {
  private readonly messagesFacade: MessagesFacade = inject(MessagesFacade);

  addMessage(): void {
    const message: Message = { /* message object with id attribute */ };
    this.messagesFacade.addMessage(message));
  }

  ngOnInit(): void {
    this.addMessage();
}

В результате нет прямой ссылки на хранилище, все, что связано с MessagesState, перемещается через MessagesFacade. Теперь вы можете добавлять новые сообщения в наш магазин из любой точки нашего приложения! Только не забудьте добавить его в файл store/messages/index.ts:

// file location: store/messages/index.ts
export * from './messages.actions';
export * from './messages.reducers';
export * from './messages.state';
export * from './messages.selectors';
export * from './messages.facade';

Пока что мы обрабатываем наши синхронные операции с нашей конфигурацией хранилища NgRx, остался один бит, связанный с асинхронными операциями!

Если мы хотим сохранить наши сообщения где-то в БД, нам нужно вызвать соответствующую конечную точку и передать ее нашей внутренней реализации. Поскольку эта операция требует времени, она обрабатывается асинхронно (мы не знаем, придет ли когда-нибудь ответ, а если и придет, то не знаем, когда!). Вот где в игру вступает библиотека @ngrx/effects! Давайте настроим эффекты в нашей автономной реализации управления состоянием на основе компонентов!

Нам нужно добавить два дополнительных действия в наш файл messages.actions.ts. Вот как должен выглядеть файл:

import { createAction, props } from '@ngrx/store';
import { Message } from '../../messenger';

export const messagesKey = '[Messages]';

export const addMessage = createAction(
  `${messagesKey} Add Message`, 
  props<{ message: Message }>()
);

export const deleteMessage = createAction(
  `${messagesKey} Delete Message`, 
  props<{ id: string }>()
);

export const deleteMessageSuccess = createAction(
`${messagesKey} Delete Message Success`
);

export const deleteMessageError = createAction(
`${messagesKey} Delete Message Error`
);

Кроме того, необходим некоторый ApiService, чтобы поддерживать абстракцию уровня связи API (../../shared/services/messages-api.service.ts):

import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class MessagesApiService {
  private readonly http: HttpClient = inject(HttpClient);

  deleteMessage(id: string): Observable<void> {
    return this.http.delete<void>(`/${id}`);
  }
}

Есть два варианта объявления эффектов, вы можете использовать классовый или функциональный подход. Поскольку функциональный подход является относительно новым, мы будем использовать его в нашем примере (файл messages.effects.ts):

import { inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { deleteMessage, deleteMessageError, deleteMessageSuccess } from './messages.actions';
import { MessagesApiService } from '../../shared/services/messages-api.service';
import { catchError, map, mergeMap } from 'rxjs';

export const deleteMessage$ = createEffect(
  (actions$: Actions = inject(Actions), messagesApiService: MessagesApiService = inject(MessagesApiService)) => {
    return actions$.pipe(
      ofType(deleteMessage),
      mergeMap(({ id }) =>
        messagesApiService.deleteMessage(id).pipe(
          map(() => deleteMessageSuccess()),
          catchError(() => [deleteMessageError()])
        )
      )
    );
  },
  { functional: true }
);

Для начала мы используем функцию createEffect из библиотеки @ngrx/effects и передаем два аргумента: один — фактический эффект, который мы хотим создать, а второй — объект конфигурации. Поскольку мы придерживаемся функционального подхода, наша конфигурация будет содержать флаг functional, для которого установлено значение true.

Нам нужно внедрить actions$ и наш ApiService в качестве аргументов для нашего эффекта, точно так же, как в следующем примере. Это предпочтительнее, так как такой эффект определенно легче проверить!

Мы прослушиваем вызов ApiService и реагируем либо на успех, либо на неудачу определенными действиями.

Поскольку мы используем функциональный подход, экспорт эффектов должен выглядеть немного иначе. Просто добавьте этот бит в store/messages/index.ts:

export * as messagesEffects from './messages.effects';

Еще один диспетчер действий, который нужно добавить в наш MessagesFacade, и мы готовы протестировать наш эффект:

  deleteOne(id: string): void {
    this.store.dispatch(deleteMessage({ id }));
  }

Теперь вы готовы вызывать сообщение deleteOne из любого места вашего приложения!

Вуаля! Мы закончили! Реализация NgRx никогда не выглядела проще!

Создавайте приложения Angular с повторно используемыми компонентами, как Lego.

Инструмент с открытым исходным кодом Bit помогает более чем 250 000 разработчиков создавать приложения с компонентами.

Превратите любой пользовательский интерфейс, функцию или страницу в компонент многократного использования — и поделитесь им со своими приложениями. Легче сотрудничать и строить быстрее.



Подробнее

Разделите приложения на компоненты, чтобы упростить разработку приложений, и наслаждайтесь наилучшими возможностями для рабочих процессов, которые вы хотите:

Микро-интерфейсы

Система дизайна

Совместное использование кода и повторное использование

Монорепо

Узнать больше: