Сигналы изменят Angular к лучшему
От NgRx ComponentStore до SignalStore: основные выводы из моего демонстрационного проекта
Как эффективно подготовиться к миграции
Я считаю, что сигналы в Angular коренным образом изменят то, как мы создаем приложения Angular. Эта статья является первой частью серии, цель которой показать вам потенциал этой новой функции и в то же время помочь вам эффективно подготовиться к этому изменению: в то время как Signals находится в предварительной версии для разработчиков, а магазин на основе сигналов NgRx — это просто прототип, вы можете приступить к созданию и рефакторингу своих компонентов таким образом, чтобы миграция была для вас действительно гладкой. В этой первой части я покажу вам, как я использовал демонстрационное приложение, чтобы продемонстрировать различия между ComponentStore
и новой моделью на основе сигналов. В следующей части серии я собираюсь предложить вам несколько рекомендаций о том, как ориентироваться в этом изменении. Итак, сначала позвольте мне представить Сигналы и NgRx SignalStore.
Сигналы Angular — это новая модель реактивности в Angular 16. Сигналыпомогают нам отслеживать изменения состояния в наших приложениях и запускать оптимизированный рендеринг шаблонов обновления. Если вы новичок в Signals, вот несколько настоятельно рекомендуемых статей:
- Официальная документация Angular Signals
- «Сигналы в Angular — как писать более реактивный код» Дебора Курата
- «Угловой и сигналы. Все, что вам нужно знать Робин Гетц
Команда NgRx и Марко Станимирович открыли новый RFC (запрос комментариев) для решения для управления состоянием на основе сигналов, SignalStore. Он имеет аналогичный подход к @ngrx/component-store
. Первоначальная реализация с документацией по API доступна в репозитории игровой площадки NgRx SignalStore.
Как я уже говорил, я уверен, что Signals изменит то, как мы разрабатываем приложения Angular. Чтобы получить больше информации об этой новой функции и ее будущих последствиях, я создал две версии компонента «список статей». Я сначала построил на основе ComponentStore
, а затем мигрировал на основе SignalStore
. В этой статье я объясню этапы реализации и основные отличия, которые я обнаружил, чтобы вы могли лучше понять, как на самом деле работают SignalStore
.
Полный исходный код доступен здесь:
Приложение использует стили и общедоступный бэкэнд из проекта RealWorld.
Приложение имеет следующие функции:
- Простое меню для переключения между списком статей на основе
ComponentStore
иSignalStore
. - Два списка статей, один из них основан на
ComponentStore
, другой — наSignalStore
. Они показывают автора статьи, дату публикации, количество, теги и лиды. Они загружают список статей с сервера, поэтому у них есть загрузка и состояние ошибки. - Компонент пагинации под каждым списком статей. Пользователь также может изменить пагинацию по параметрам URL, например:
http://localhost:4200/article-list-component-store?selectedPage=3&pageSize=2
. Если пользователь изменяет параметры URL или нажимает на компонент разбиения на страницы, список статей перезагружается.
Архитектура приложения
Я использую Angular v16 с автономными компонентами. Поскольку сигналы еще не работают в приложениях без зон, я использую стратегию обнаружения изменений OnPush
с async
каналами.
Приложение загружает AppComponent
с router-outlet
и двумя пунктами меню для двух версий списка статей:
ArticleListComponent_CS
— это версия списка статей на основеComponentStore
. Это связано сArticleListComponentStore
.ArticleListComponent_SS
— это версия списка статей на основеSignalStore
. Это связано сArticleListSignalStore
.
Обе реализации «списка статей» используют хранилище на уровне компонентов и полагаются на следующие компоненты пользовательского интерфейса:
UiArticleListComponent
отображает список статей (UiArticleLisItemComponent
)UiPaginationComponent
обрабатывает нумерацию страниц
Структура каталогов следующая:
src/ |-- app/ | |-- article-list-ngrx-component-store/ => ArticleListComponent_CS | |-- article-list-ngrx-signal-store/ => ArticleListComponent_SS | |-- models/ | |-- services/ | |-- ui-components/ | |-- app.component.ts | |-- app.routes.ts |-- libs/signal-store/
Компоненты списка статей
Коды классов двух компонентов списка статей практически идентичны:
- инжектим роутер и магазин,
- мы обновляем параметры пагинации в магазине после создания компонента. Мы также обновляем параметры, если параметры URL-адреса меняются.
Единственная разница между ними — это класс внедряемого хранилища: ArticleListComponentStore
и ArticleListSignalStore
:
export class ArticleListComponent_CS { readonly store = inject(ArticleListComponentStore); readonly route = inject(ActivatedRoute); constructor( ) { this.route.queryParams.pipe(takeUntilDestroyed()).subscribe( routeParams => { this.store.setPaginationSettings(routeParams); this.store.loadArticles(); }); } } export class ArticleListComponent_SS { readonly store = inject(ArticleListSignalStore); readonly route = inject(ActivatedRoute); constructor( ) { this.route.queryParams.pipe(takeUntilDestroyed()).subscribe( routeParams => { this.store.setPaginationSettings(routeParams); this.store.loadArticles(); }); } }
Шаблоны компонентов тоже похожи. Принципиальное отличие заключается в том, как мы считываем данные из хранилищ:
- мы используем каналы
async
для чтения из селекторов элементаComponentStore
, и - мы просто получаем значение сигналов в
SignalStore
@Component({ selector: ‘app-article-list-cs’, // ... providers: [ArticleListComponentStore], template: ` <ng-container *ngIf="(store.httpRequestState$ | async) === ‘FETCHING’"> Loading... </ng-container> <ng-container *ngIf="store.httpRequestState$ | async | httpRequestStateErrorPipe as errorMessage"> {{ errorMessage }} </ng-container> <ng-container *ngIf="(store.httpRequestState$ | async) === ‘FETCHED’"> <ng-container *ngIf="store.articles$ | async as articles"> <app-ui-article-list [articles]="articles"/> </ng-container> <ng-container *ngIf="store.pagination$ | async as pagination"> <app-ui-pagination [selectedPage]="pagination.selectedPage" [totalPages]="pagination.totalPages" (onPageSelected)="store.setSelectedPage($event); store.loadArticles();" /> </ng-container> </ng-container> ` }) @Component({ selector: ‘app-article-list-ss’, // ... providers: [ArticleListSignalStore], template: ` <ng-container *ngIf="store.httpRequestState() === ‘FETCHING’"> Loading... </ng-container> <ng-container *ngIf="store.httpRequestState() | httpRequestStateErrorPipe as errorMessage"> {{ errorMessage }} </ng-container> <ng-container *ngIf="store.httpRequestState() === ‘FETCHED’"> <ng-container *ngIf="store.articles() as articles"> <app-ui-article-list [articles]="articles"/> </ng-container> <ng-container *ngIf="store.pagination() as pagination"> <app-ui-pagination [selectedPage]="pagination.selectedPage()" [totalPages]="pagination.totalPages()" (onPageSelected)="store.setSelectedPage($event); store.loadArticles();" /> </ng-container> </ng-container> ` })
Состояние
Я применяю одну и ту же неизменяемую структуру данных для хранения состояния в обоих хранилищах (типы HttpRequestState
и Articles
также являются неизменяемыми):
export type ArticleListState = { readonly selectedPage: number, readonly pageSize: number, readonly httpRequestState: HttpRequestState, readonly articles: Articles, readonly articlesCount: number, }
Свойство selectedPage
указывает отображаемую в данный момент страницу, свойство pageSize
определяет количество видимых статей. Пользователь может изменить эти значения, используя компонент разбиения на страницы или применяя параметры URL.
Свойство httpRequestState
содержит состояние запроса списка статей:
export type HttpRequestState = DeepReadonly< 'EMPTY' | 'FETCHING' | 'FETCHED' | { errorMessage: string } >;
Изначально его значение равно `EMPTY`
. Мы меняем его на `FETCHING`
прямо перед отправкой запроса на сервер. Когда приходит ответ сервера, мы устанавливаем его значение `FETCHED`
. Если сервер отправляет ответ об ошибке или во время запроса возникает ошибка, мы устанавливаем состояние запроса на объект { errorMessage: string }
с сообщением об ошибке.
Ответ сервера содержит общее количество статей и сами статьи, мы храним их в свойствах articlesCount
и articles
.
После того, как мы создадим компоненты списка статей, их хранилища имеют начальное состояние:
export const initialArticleListState: ArticleListState = { selectedPage: 0, pageSize: 3, httpRequestState: ‘EMPTY’, articles: [], articlesCount: 0 }
магазины
Я расширяю ArticleListComponentStore
от ComponentStore
:
@Injectable() export class ArticleListComponentStore extends ComponentStore<ArticleListState> { readonly selectedPage$: Observable<number> = /* ... */; readonly pageSize$: Observable<number> = /* ... */; readonly httpRequestState$: Observable<HttpRequestState> = /* ... */; readonly articles$: Observable<DeepReadonly<Articles>> = /* ... */; readonly articlesCount$: Observable<number> = /* ... */; readonly totalPages$: Observable<number> = /* ... */; readonly pagination$: Observable<{ selectedPage: number, totalPages: number }> = /* ... */; readonly articlesService = inject(ArticlesService); constructor( ) { super(initialArticleListState); } setPaginationSettings = this.updater( (state, s: RouteParamsPaginatonState) => /* ... */); readonly loadArticles = this.effect<void>(/* ... */); setRequestStateLoading = this.updater( (state) => /* ... */); setRequestStateSuccess = this.updater( (state, params: ArticlesResponseType) => /* ... */); setRequestStateError = this.updater( (state, error: string): => /* ... */); setSelectedPage = this.updater( (state, selectedPage: number) => /* ... */); }
Я создаю `ArticleListSignalStore` с помощью функции `signalStore()`. Он принимает последовательность функций магазина, я объясню их более подробно:
export const ArticleListSignalStore = signalStore( { debugId: ‘ArticleListSignalStore’ }, withState<ArticleListState>(initialArticleListState), withComputed(({ articlesCount, pageSize }) => ({ /* ... */ })), withComputed(({ selectedPage, totalPages }) => ({ /* ... */ })), withUpdaters(({ update }) => ({ setPaginationSettings: (s: RouteParamsPaginatonState) => /* ... */, setRequestStateLoading: () => /* ... */ , setRequestStateSuccess: => /* ... */ , setRequestStateError: (error: string) => /* ... */ , setSelectedPage: (selectedPage: number) => /* ... */, withEffects( ( { selectedPage, pageSize, setRequestStateLoading, setRequestStateSuccess, setRequestStateError }, ) => { const articlesService = inject(ArticlesService) // ... } ) );
Селекторы
ArticleListComponentStore
сохраняет состояние в своей теме store$
. Этот субъект выдает значение при каждом изменении состояния. Чтобы наблюдать за изменениями свойств состояния по отдельности, мы делаем отдельный селектор для каждого из этих свойств:
readonly selectedPage$: Observable<number> = this.select(state => state.selectedPage); readonly pageSize$: Observable<number> = this.select(state => state.pageSize); readonly httpRequestState$: Observable<HttpRequestState> = this.select(state => state.httpRequestState); readonly articles$: Observable<DeepReadonly<Articles>> = this.select(state => state.articles); readonly articlesCount$: Observable<number> = this.select(state => state.articlesCount);
SignalStore
автоматически создает отдельный signal
для всех корневых свойств состояния. Мы называем их частичными состояниями. Мы можем получить доступ к этим частичным состояниям:
ArticleListSignalStore.selectedPage()
ArticleListSignalStore.pageSize()
ArticleListSignalStore.httpRequestState()
ArticleListSignalStore.articles()
иArticleListSignalStore.articlesCount()
Я создаю дополнительный комбинированный селектор в ArticleListComponentStore
для подсчета количества страниц:
readonly totalPages$: Observable<number> = this.select( this.articlesCount$, this.pageSize$, (articlesCount, pageSize) => Math.ceil(articlesCount / pageSize));
Чтобы сделать то же самое в ArticleListSignalStore
, я использую функцию withComputed()
. Я передаю сигналы articlesCount
и pageSize
в качестве параметра функции и вычисляю общее количество страниц:
withComputed(({ articlesCount, pageSize }) => ({ totalPages: computed(() => Math.ceil(articlesCount() / pageSize())), })),
Нам также нужно добавить селектор «модель просмотра» в компонент пагинации. Это код селектора в ArticleListComponentStore
:
readonly pagination$: Observable<{ selectedPage: number, totalPages: number }> = this.select( this.selectedPage$, this.totalPages$, (selectedPage, totalPages) => ({ selectedPage, totalPages }) );
И это тот же селектор в ArticleListSignalStore
тоже:
withComputed(({ selectedPage, totalPages }) => ({ pagination: computed(() => ({ selectedPage, totalPages })), })),
Обновления
Внутри updaters
объекта ComponentStore
мы всегда создаем новый неизменяемый объект состояния с обновленными значениями и возвращаем его. Возвращенный объект состояния содержит все свойства из состояния, как обновленные, так и неизмененные.
Например, вот как мы обрабатываем ответ сервера:
setRequestStateSuccess = this.updater((state, params: ArticlesResponseType): ArticleListState => { return { ...state, httpRequestState: ‘FETCHED’, articles: params.articles, articlesCount: params.articlesCount } });
Параметр params
содержит значения articles
и articlesCount
из ответа сервера:
export type ArticlesResponseType = { articles: Articles, articlesCount: number }
В ArticleListSignalStore
мы создаем updater
с функцией withUpdaters()
. В этих updater
мы создаем новый неизменяемый объект только из обновленных свойств, поэтому …state
здесь нет. SignalStore
обновляет частичные состояния с использованием этих возвращаемых значений свойств:
withUpdaters(({ update }) => ({ setPaginationSettings: (s: RouteParamsPaginatonState) => update(() => ({ // ... setRequestStateSuccess: (params: ArticlesResponseType) => update(() => ({ httpRequestState: ‘FETCHED’, articles: params.articles, articlesCount: params.articlesCount })) // ... }))
Последствия
В магазинах есть один effect
, который получает список статей с сервера. Это effect
из ArticleListComponentStore
:
readonly loadArticles = this.effect<void>((trigger$: Observable<void>) => { return trigger$.pipe( withLatestFrom(this.selectedPage$, this.pageSize$), tap(() => this.setRequestStateLoading()), switchMap(([, selectedPage, pageSize]) => { return this.articlesService.getArticles({ limit: pageSize, offset: selectedPage * pageSize }).pipe( tapResponse( (response) => { this.setRequestStateSuccess(response); }, (errorResponse: HttpErrorResponse) => { this.setRequestStateError(‘Request error’); } ), ); }), ); });
В SignalStore
мы реализуем эффекты с помощью функции withEffects()
. SignalStores
поддерживает два разных типа эффектов: эффекты на основе RxJs и эффекты на основе Promise
. Эффекты на основе RxJs очень похожи на эффекты, которые мы используем в ComponentStore
:
withEffects( ( { selectedPage, pageSize, setRequestStateLoading, setRequestStateSuccess, setRequestStateError }, ) => { const articlesService = inject(ArticlesService) return { loadArticles: rxEffect<void>( pipe( tap(() => setRequestStateLoading()), switchMap(() => articlesService.getArticles({ limit: pageSize(), offset: selectedPage() * pageSize() })), tapResponse( (response) => { setRequestStateSuccess(response); }, (errorResponse: HttpErrorResponse) => { setRequestStateError(‘Request error’); } ) ) ) } } )
Эффекты на основе Promise
полезны, когда Promise
имеет достаточную функциональность и нам не нужна мощь RxJ. В случае получения данных с сервера у него есть недостаток: он не поддерживает логику отмены:
withEffects( ( { selectedPage, pageSize, setRequestStateLoading, setRequestStateSuccess, setRequestStateError }, ) => { const articlesService = inject(ArticlesService) return { async loadArticles() { setRequestStateLoading(); try { const response = await lastValueFrom(articlesService.getArticles({ limit: pageSize(), offset: selectedPage() * pageSize() })); setRequestStateSuccess(response); } catch(e) { setRequestStateError(‘Request error’); } } } } )
Краткое содержание
Подводя итог, ключевые различия между ComponentStore
и SignalStore
заключаются в следующем:
ComponentStore
имеет свое состояние в темеstate$
.SignalStore
имеет отдельный сигнал для всех корневых свойств состояния (частичных состояний)- В
SignalStore
нам не нужны селекторы для доступа к свойствам корневого уровня состояния. Он хранит их в отдельных сигналах, поэтому они доступны напрямую. - И
ComponentStore
, иSignalStore
поддерживают эффекты на основе RxJs, кроме того,SignalStore
также поддерживает эффекты на основеPromise
.
Хотя текущая реализация SignalStore
— это всего лишь прототип, и в будущем возможны изменения API, мне очень нравится с ней работать. Его API является гибким и простым для понимания, так как он следует основным концепциям ComponentStore
, но в более продвинутом виде.
Чтобы упростить отладку изменений состояния, updaters
и effects
, я исправил исходный код SignalStore
некоторым кодом отладки из моего проекта ngx-ngrx-component-store-debug-tools.
Главный вопрос, который у меня сейчас есть, заключается в том, как создать ComponentStores
таким образом, чтобы после выпуска готовой к производству версии SignalStore
можно было легко управлять процессом миграции.
В следующей части моей серии статей я определю некоторые принципы, которые помогут нам достичь этой цели. Кроме того, я собираюсь изучить некоторые сложные сценарии и сравнить эти два подхода, например, как работает отмена HTTP-запроса в SignalStore
и ComponentStore
.
Спасибо за прочтение, надеюсь, моя статья была вам полезна, дайте мне знать, если у вас есть отзывы!
👨💻Об авторе
Меня зовут Gergely Szerovay, я работаю руководителем отдела фронтенд-разработки. Преподавание (и изучение) Angular — одна из моих страстей. Я ежедневно просматриваю контент, связанный с Angular — статьи, подкасты, выступления на конференциях и так далее.
Я создал информационный бюллетень Angular Addict, чтобы отправлять вам лучшие ресурсы, с которыми я сталкиваюсь каждый месяц. Независимо от того, являетесь ли вы опытным Angular Addict или новичком, я помогу вам.
Рядом с информационным бюллетенем у меня также есть публикация под названием, как вы уже догадались, Angular Addicts. Это коллекция ресурсов, которые я считаю наиболее информативными и интересными. Дайте мне знать, если вы хотели бы быть включены в качестве писателя.
Давайте изучать Angular вместе! Подпишитесь здесь 🔥
Подпишитесь на меня в Medium, Twitter или LinkedIn, чтобы узнать больше об Angular!