Сигналы изменят Angular к лучшему

От NgRx ComponentStore до SignalStore: основные выводы из моего демонстрационного проекта

Как эффективно подготовиться к миграции

Я считаю, что сигналы в Angular коренным образом изменят то, как мы создаем приложения Angular. Эта статья является первой частью серии, цель которой показать вам потенциал этой новой функции и в то же время помочь вам эффективно подготовиться к этому изменению: в то время как Signals находится в предварительной версии для разработчиков, а магазин на основе сигналов NgRx — это просто прототип, вы можете приступить к созданию и рефакторингу своих компонентов таким образом, чтобы миграция была для вас действительно гладкой. В этой первой части я покажу вам, как я использовал демонстрационное приложение, чтобы продемонстрировать различия между ComponentStore и новой моделью на основе сигналов. В следующей части серии я собираюсь предложить вам несколько рекомендаций о том, как ориентироваться в этом изменении. Итак, сначала позвольте мне представить Сигналы и NgRx SignalStore.

Сигналы Angular — это новая модель реактивности в Angular 16. Сигналыпомогают нам отслеживать изменения состояния в наших приложениях и запускать оптимизированный рендеринг шаблонов обновления. Если вы новичок в Signals, вот несколько настоятельно рекомендуемых статей:

Команда 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!