Руководство по реализации 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 разработчиков создавать приложения с компонентами.
Превратите любой пользовательский интерфейс, функцию или страницу в компонент многократного использования — и поделитесь им со своими приложениями. Легче сотрудничать и строить быстрее.
Разделите приложения на компоненты, чтобы упростить разработку приложений, и наслаждайтесь наилучшими возможностями для рабочих процессов, которые вы хотите: