我最初需要的 NgRx 技巧

2025-05-28

我最初需要的 NgRx 技巧

封面照片由Léonard Cotte在 Unsplash 上拍摄。

本文包含使用@ngrx/store@ngrx/effects库的技巧和最佳实践列表。这些技巧和最佳实践基于我多次见过的 NgRx 常见错误(其中一些我自己也犯过),以及参考资料部分提供的精彩演讲和文章

内容

商店提示

将全局状态放在一个地方

尽量将应用程序的全局状态保存在一个地方 - NgRx 存储。将状态分散到多个有状态服务中会使应用程序更难维护。这还经常导致这些服务“重新存储”派生状态,这使得我们更难理解特定数据的实际来源在哪里。

但是,如果您正在将应用程序迁移到 NgRx,那么保留遗留状态服务作为临时解决方案是可以的。


不要将本地状态放入全局存储中

本地状态与特定组件的生命周期紧密相关。它在组件生命周期内初始化和管理,并在组件销毁时清除。

将本地状态存储在组件中并以命令式方式管理完全没问题。但是,如果您已经在使用诸如 NgRx Store 之类的响应式全局状态管理解决方案,那么可以考虑使用诸如 之类的响应式本地状态管理解决方案@ngrx/component-store。它具有许多强大的功能,并且与全局 NgRx Store 完美契合。


使用选择器来获取派生状态

不要将派生状态放入存储中,而是使用选择器。

我们首先看一下使用派生值管理状态的 Reducer:

export const musiciansReducer = createReducer(
  on(musiciansPageActions.search, (state, { query }) => {
    // `filteredMusicians` is derived from `musicians` and `query`
    const filteredMusicians = state.musicians.filter(({ name }) =>
      name.includes(query)
    );

    return {
      ...state,
      query,
      filteredMusicians,
    };
  }))
);
Enter fullscreen mode Exit fullscreen mode

的值filteredMusicians源自query和数组。如果您决定将派生值保留在 store 中,则每次派生值之一发生变化时,都应该更新它。这样一来,state 会变得更大,reducer 会包含额外的逻辑,而且您很容易忘记在另一个更新或 的musiciansreducer 中添加过滤逻辑querymusicians

处理派生状态的正确方法是通过选择器。返回已筛选音乐家的选择器如下所示:

export const selectFilteredMusicians = createSelector(
  selectAllMusicians,
  selectMusicianQuery,
  (musicians, query) =>
    musicians.filter(({ name }) => name.includes(query))
);
Enter fullscreen mode Exit fullscreen mode

现在就musiciansReducer简单多了:

export const musiciansReducer = createReducer(
  on(musiciansPageActions.search, (state, { query }) => ({
    ...state,
    query,
  }))
);
Enter fullscreen mode Exit fullscreen mode

使用视图模型选择器

视图模型选择器会组合其他选择器,以返回特定视图所需的所有状态块。每个容器使用一个选择器,可以有效简化容器组件。除此之外,视图模型选择器还具有其他优势。

让我们首先看看没有视图模型选择器的容器组件会是什么样子:

@Component({
  // the value of each Observable is unwrapped via `async` pipe
  template: `
    <musician-search [query]="query$ | async"></musician-search>

    <musician-list
      [musicians]="musicians$ | async"
      [activeMusician]="activeMusician$ | async"
    ></musician-list>

    <musician-details
      [musician]="activeMusician$ | async"
    ></musician-details>
  `,
})
export class MusiciansComponent {
  // select all state chunks required for the musicians container
  readonly musicians$ = this.store.select(selectFilteredMusicians);
  readonly query$ = this.store.select(selectMusiciansQuery);
  readonly activeMusician$ = this.store.select(selectActiveMusician);

  constructor(private readonly store: Store) {}
}
Enter fullscreen mode Exit fullscreen mode

这种方法有几个缺点:

  • 容器组件的大小随着所需状态块的数量而增加。
  • 测试更加困难——可能有许多选择器需要模拟。
  • 模板中有多个订阅。

现在让我们为这个容器创建一个视图模型选择器:

export const selectMusiciansPageViewModel = createSelector(
  selectFilteredMusicians,
  selectMusiciansQuery,
  selectActiveMusician,
  (musicians, query, activeMusician) => ({
    musicians,
    query,
    activeMusician,
  })
);
Enter fullscreen mode Exit fullscreen mode

现在容器看起来是这样的:

@Component({
  // single subscription in the template via `async` pipe
  // access to the view model properties via `vm` alias
  template: `
    <ng-container *ngIf="vm$ | async as vm">
      <musician-search [query]="vm.query"></musician-search>

      <musician-list
        [musicians]="vm.musicians"
        [activeMusician]="vm.activeMusician"
      ></musician-list>

      <musician-details
        [musician]="vm.activeMusician"
      ></musician-details>
    </ng-container>
  `,
})
export class MusiciansComponent {
  // select the view model
  readonly vm$ = this.store.select(selectMusiciansPageViewModel);

  constructor(private readonly store: Store) {}
}
Enter fullscreen mode Exit fullscreen mode

该组件现在更小,更易于测试。此外,模板中只有一个订阅。


将动作视为独特事件

将 NgRx 操作视为唯一事件,而不是命令,并且不要重复使用它们。

对于简单且独立的功能,命令可能没问题。然而,对于使用多个功能状态的复杂功能,命令可能会导致代码混乱,并可能带来性能问题。现在让我们通过示例来理解将操作视为独立事件(即良好的操作规范)的重要性。

对于显示实体列表的页面,有一个简单的 NgRx 流程:

  1. 在组件初始化时调度操作来加载实体集合。
  2. 监听此操作的实际效果,从 API 加载实体,并返回以加载的实体作为有效负载的新操作。
  3. 创建一个案例简化器,它将监听从效果返回的操作并将加载的实体添加到状态中。
  4. 最后,从商店中选择实体并将其显示在模板中:
@Component(/* ... */)
export class SongsComponent implements OnInit {
  // select songs from the store
  readonly songs$ = this.store.select(selectSongs);

  constructor(private readonly store: Store) {}

  ngOnInit(): void {
    // dispatch the `loadSongs` action on component initialization
    this.store.dispatch({ type: '[Songs] Load Songs' });
  }
}
Enter fullscreen mode Exit fullscreen mode

一切正常,一开始无需任何修改。但是,如果我们想加载特定容器组件所需的另一个集合,该怎么办呢?在本例中,假设我们想显示每首已加载歌曲的作曲家。如果我们将操作视为命令,那么ngOnInit方法SongsComponent将如下所示:

ngOnInit(): void {
  this.store.dispatch({ type: '[Songs] Load Songs' });
  this.store.dispatch({ type: '[Composers] Load Composers' });
}
Enter fullscreen mode Exit fullscreen mode

这里我们再来谈一下另一个非常重要的规则:不要顺序调度多个 action。顺序调度的 action 可能会导致意外的中间状态,并引发不必要的事件循环。

最好调度单个动作来表明用户已经打开了歌曲页面,并在loadSongs$loadComposers$效果中收听该动作:

ngOnInit(): void {
  this.store.dispatch({ type: '[Songs Page] Opened' });
}
Enter fullscreen mode Exit fullscreen mode

“歌曲页面”是此操作的来源(它从歌曲页面调度),“打开”是事件的名称(歌曲页面打开)。

这给我们带来了一条新规则:操作命名要保持一致,使用“[源] 事件”模式。此外,操作命名要具有描述性。这在应用程序维护方面非常有帮助,尤其是在捕获错误方面。

如果我们在 Redux DevTools 中检查此示例,当操作被视为唯一事件时,我们会看到类似以下内容:

[Login Page] Login Form Submitted
[Auth API] User Logged in Successfully
[Songs Page] Opened
[Songs API] Songs Loaded Successfully
[Composers API] Composers Loaded Successfully
Enter fullscreen mode Exit fullscreen mode

当我们看到一系列描述清晰的操作时,我们可以轻松得出应用程序中发生的事情:

  1. 用户提交了登录表单。
  2. Auth API 响应登录成功。
  3. 用户打开了歌曲页面。
  4. 歌曲已成功从 Song API 加载。
  5. 已成功从 Composers API 加载 Composers。

不幸的是,命令并非如此:

[Auth] Login
[Auth] Login Success
[Songs] Load Songs
[Composers] Load Composers
[Songs] Load Songs Success
[Composers] Load Composers Success
Enter fullscreen mode Exit fullscreen mode

命令可以从多个地方发送,所以我们无法弄清楚它们的来源是什么。


按来源对操作进行分组

我们在前面的例子中看到,一个操作可能会导致多个功能状态发生变化。因此,不要按功能状态对操作进行分组,而是按来源进行分组。

按来源创建操作文件。以下是按来源分组的操作文件的一些示例:

// songs-page.actions.ts
export const opened = createAction('[Songs Page] Opened');
export const searchSongs = createAction(
  '[Songs Page] Search Songs Button Clicked',
  props<{ query: string }>()
);
export const addComposer = createAction(
  '[Songs Page] Add Composer Form Submitted',
  props<{ composer: Composer }>()
);

// songs-api.actions.ts
export const songsLoadedSuccess = createAction(
  '[Songs API] Songs Loaded Successfully',
  props<{ songs: Song[] }>()
);
export const songsLoadedFailure = createAction(
  '[Songs API] Failed to Load Songs',
  props<{ errorMsg: string }>()
);

// composers-api.actions.ts
export const composerAddedSuccess = createAction(
  '[Composers API] Composer Added Successfully',
  props<{ composer: Composer }>()
);
export const composerAddedFailure = createAction(
  '[Composers API] Failed to Add Composer',
  props<{ errorMsg: string }>()
);

// composer-exists-guard.actions.ts
export const canActivate = createAction(
  '[Composer Exists Guard] Can Activate Entered',
  props<{ composerId: string }>()
);
Enter fullscreen mode Exit fullscreen mode

不要有条件地调度动作

不要根据状态值有条件地调度 action。请将条件移至 effect 或 reducer。此技巧也与良好的 action 卫生习惯相关。

我们首先看一下根据状态值调度动作的情况:

@Component(/* ... */)
export class SongsComponent implements OnInit {
  constructor(private readonly store: Store) {}

  ngOnInit(): void {
    this.store.select(selectSongs).pipe(
      tap((songs) => {
        // if the songs are not loaded
        if (!songs) {
          // then dispatch the `loadSongs` action
          this.store.dispatch(songsActions.loadSongs());
        }
      }),
      take(1)
    ).subscribe();
  }
}
Enter fullscreen mode Exit fullscreen mode

在上面的例子中,loadSongs如果歌曲尚未加载,则会触发 action。然而,有一种更好的方法可以达到同样的效果,同时保持组件简洁。我们可以将此条件移至效果中:

readonly loadSongsIfNotLoaded$ = createEffect(() => {
  return this.actions$.pipe(
    // when the songs page is opened
    ofType(songsPageActions.opened),
    // then select songs from the store
    concatLatestFrom(() => this.store.select(selectSongs)),
    // and check if the songs are loaded
    filter(([, songs]) => !songs),
    // if not, load songs from the API
    exhaustMap(() => {
      return this.songsService.getSongs().pipe(
        map((songs) => songsApiActions.songsLoadedSuccess({ songs })),
        catchError((error: { message: string }) =>
          of(songsApiActions.songsLoadedFailure({ error }))
        )
      );
    })
  );
});
Enter fullscreen mode Exit fullscreen mode

然后,组件看起来会更干净:

@Component(/* ... */)
export class SongsComponent implements OnInit {
  constructor(private readonly store: Store) {}

  ngOnInit(): void {
    this.store.dispatch(songsPageActions.opened());
  }
}
Enter fullscreen mode Exit fullscreen mode

创建可重用的 Reducer

当多个操作触发相同的状态变化时,使用单个 case reducer:

export const composersReducer = createReducer(
  initialState,
  // case reducer can listen to multiple actions
  on(
    composerExistsGuardActions.canActivate,
    composersPageActions.opened,
    songsPageActions.opened,
    (state) => ({ ...state, isLoading: true })
  )
);
Enter fullscreen mode Exit fullscreen mode

但是,如果任何这些操作需要不同的状态更改,请不要向现有的案例减少器添加额外的逻辑,如下所示:

export const composersReducer = createReducer(
  initialState,
  on(
    composerExistsGuardActions.canActivate,
    composersPageActions.opened,
    songsPageActions.opened,
    (state, action) =>
      // `composerExistsGuardActions.canActivate` action requires
      // different state change
      action.type === composerExistsGuardActions.canActivate.type &&
      state.entities[action.composerId]
        ? state
        : { ...state, isLoading: true }
  )
);
Enter fullscreen mode Exit fullscreen mode

相反,创建一个新的案例减少器:

export const composersReducer = createReducer(
  initialState,
  on(
    composersPageActions.opened,
    songsPageActions.opened,
    (state) => ({ ...state, isLoading: true })
  ),
  // `composerExistsGuardActions.canActivate` action is moved
  // to a new case reducer
  on(
    composerExistsGuardActions.canActivate,
    (state, { composerId }) =>
      state.entities[composerId]
        ? state
        : { ...state, isLoading: true }
  )
);
Enter fullscreen mode Exit fullscreen mode

小心外观

我以前使用 Facades 作为 NgRx 存储包装器,但我停止了,原因如下:

  • 如果您不喜欢 Redux 模式,并且需要将其包装在服务中,那么您应该看看基于服务的状态管理解决方案,例如 Akita 或 NGXS(或@ngrx/component-store也用于全局状态)。
  • 当使用视图模型选择器并遵循良好的操作规范时,使用 Facade 并没有多大意义。这样会多一层测试和维护的负担,却没有任何好处。
  • 如果编码指南中没有严格的规则,外观就会留下足够的滥用空间(例如产生副作用)。

但是,如果容器组件既有本地状态,又使用全局状态,则可以考虑使用 ComponentStore 作为该容器的专用外观。在这种情况下,ComponentStore 将管理本地状态,但也会选择全局状态切片和/或将操作分发到全局存储。


效果提示

像函数一样命名效果

根据他们正在做的事情来命名效果,而不是根据他们正在听的动作来命名。

如果我们根据它所监听的动作来命名效果,它看起来是这样的:

// the name of the effect is the same as the action it listens to
readonly composerAddedSuccess$ = createEffect(
  () => {
    return this.actions$.pipe(
      ofType(composersApiActions.composerAddedSuccess),
      tap(() => this.alert.success('Composer saved successfully!'))
    );
  },
  { dispatch: false }
);
Enter fullscreen mode Exit fullscreen mode

这种方法至少有两个缺点。首先,我们无法根据其名称推断出该效果的作用。其次,它不符合开放封闭原则——如果我们想为另一个动作触发相同的效果,就应该更改其名称。但是,如果我们将此效果命名为函数(showSaveComposerSuccessAlert),前面提到的缺点将迎刃而解。

例如,如果我们想在作曲家成功更新时显示相同的成功警报,我们只需要将composerUpdatedSuccess动作传递给ofType操作员,而无需更改效果名称:

// the effect name describes what the effect does
readonly showSaveComposerSuccessAlert$ = createEffect(
  () => {
    return this.actions$.pipe(
      ofType(
        composersApiActions.composerAddedSuccess,
        // new action is added here
        // the rest of the effect remains the same
        composersApiActions.composerUpdatedSuccess
      ),
      tap(() => this.alert.success('Composer saved successfully!'))
    );
  },
  { dispatch: false }
);
Enter fullscreen mode Exit fullscreen mode

保持效果简单

有时我们需要调用多个 API 来实现副作用,或者 API 响应的格式不合适,因此需要对其进行重构。然而,将所有逻辑都放到 NgRx Effect 中可能会导致代码非常难以阅读。

以下是需要两次 API 调用才能获取所有必要数据的效果示例:

readonly loadMusician$ = createEffect(() => {
  return this.actions$.pipe(
    // when the musician details page is opened
    ofType(musicianDetailsPage.opened),
    // then select musician id from the route
    concatLatestFrom(() =>
      this.store.select(selectMusicianIdFromRoute)
    ),
    concatMap(([, musicianId]) => {
      // and load musician from the API
      return this.musiciansResource.getMusician(musicianId).pipe(
        // wait for musician to load
        mergeMap((musician) => {
          // then load band from the API
          return this.bandsResource.getBand(musician.bandId).pipe(
            // append band name to the musician
            map((band) => ({ ...musician, bandName: band.name }))
          );
        }),
        // if the musician is successfully loaded
        // then return success action and pass musician as a payload
        map((musician) =>
          musiciansApiActions.musicianLoadedSuccess({ musician })
        ),
        // if an error occurs, then return error action
        catchError((error: { message: string }) =>
          of(musiciansApiActions.musicianLoadedFailure({ error }))
        )
      );
    })
  );
});
Enter fullscreen mode Exit fullscreen mode

这个效果太大,即使加上注释也难以理解。不过,我们可以把 API 调用转移到服务端,让效果更易读。获取音乐家的服务方法如下所示:

@Injectable()
export class MusiciansService {
  getMusician(musicianId: string): Observable<Musician> {
    return this.musiciansResource.getMusician(musicianId).pipe(
      mergeMap((musician) => {
        return this.bandsResource.getBand(musician.bandId).pipe(
          map((band) => ({ ...musician, bandName: band.name }))
        );
      })
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

它不仅可以在效果中使用loadMusician$,也可以在应用程序的其他部分使用。loadMusician$现在效果看起来更易读了:

readonly loadMusician$ = createEffect(() => {
  return this.actions$.pipe(
    ofType(musicianDetailsPage.opened),
    concatLatestFrom(() =>
      this.store.select(selectMusicianIdFromRoute)
    ),
    concatMap(([, musicianId]) => {
      // API calls are moved to the `getMusician` method
      return this.musiciansService.getMusician(musicianId).pipe(
        map((musician) =>
          musiciansApiActions.musicianLoadedSuccess({ musician })
        ),
        catchError((error: { message: string }) =>
          of(musiciansApiActions.musicianLoadedFailure({ error }))
        )
      );
    })
  );
});
Enter fullscreen mode Exit fullscreen mode

如果您正在使用旧版 API,则可能会遇到 API 返回的响应不符合应用程序所需格式的问题,因此需要进行转换。请遵循上述原则:将 API 调用及其映射逻辑移至服务方法,并在效果中使用它。


不要制造“锅炉”效应

不要创建将多个相关操作映射到单个操作的效果:

// this effect returns the `loadMusicians` action
// when current page or page size is changed
readonly invokeLoadMusicians$ = createEffect(() => {
  return this.actions$.pipe(
    ofType(
      musiciansPageActions.currentPageChanged,
      musiciansPageActions.pageSizeChanged
    ),
    map(() => musiciansActions.loadMusicians())
  );
});

// this effect loads musicians from the API
// when the `loadMusicians` action is dispatched
readonly loadMusicians$ = createEffect(() => {
  return this.actions$.pipe(
    ofType(musiciansAction.loadMusicians),
    concatLatestFrom(() =>
      this.store.select(selectMusiciansPagination)
    ),
    switchMap(([, pagination]) => {
      return this.musiciansService.getMusicians(pagination).pipe(
        /* ... */
      );
    }) 
  );
});
Enter fullscreen mode Exit fullscreen mode

因为ofType操作员可以接受一系列动作:

readonly loadMusicians$ = createEffect(() => {
  return this.actions$.pipe(
    // `ofType` accepts a sequence of actions
    // and there is no need for "boiler" effects (and actions)
    ofType(
      musiciansPageActions.currentPageChanged,
      musiciansPageActions.pageSizeChanged
    ),
    concatLatestFrom(() =>
      this.store.select(selectMusiciansPagination)
    ),
    switchMap(([, pagination]) => {
      return this.musiciansService.getMusicians(pagination).pipe(
        /* ... */
      );
    }) 
  );
});
Enter fullscreen mode Exit fullscreen mode

应用单一职责原则

换句话说,不要在单个 NgRx 效果中执行多个副作用。单一职责的效果更易读且更易于维护。

我们首先看一下执行两个副作用的 NgRx 效果:

readonly deleteSong$ = createEffect(() => {
  return this.actions$.pipe(
    ofType(songsPageActions.deleteSong),
    concatMap(({ songId }) => {
      // side effect 1: delete the song
      return this.songsService.deleteSong(songId).pipe(
        map(() => songsApiActions.songDeletedSuccess({ songId })),
        catchError(({ message }: { message: string }) => {
          // side effect 2: display an error alert in case of failure
          this.alert.error(message);
          return of(songsApiActions.songDeletedFailure({ message }));
        })
      );
    })
  );
});
Enter fullscreen mode Exit fullscreen mode

如果我们应用单一责任原则,我们将得到两个 NgRx 效果:

// effect 1: delete the song
readonly deleteSong$ = createEffect(() => {
  return this.actions$.pipe(
    ofType(songsPageActions.deleteSong),
    concatMap(({ songId }) => {
      return this.songsService.deleteSong(songId).pipe(
        map(() => songsApiActions.songDeletedSuccess({ songId })),
        catchError(({ message }: { message: string }) =>
          of(songsApiActions.songDeletedFailure({ message }))
        )
      );
    })
  );
});

// effect 2: show an error alert
readonly showErrorAlert$ = createEffect(
  () => {
    return this.actions$.pipe(
      ofType(songsApiActions.songDeletedFailure),
      tap(({ message }) => this.alert.error(message))
    );
  },
  { dispatch: false }
);
Enter fullscreen mode Exit fullscreen mode

另一个优点是:单一职责的 Effect 是可复用的。我们可以showErrorAlert$在任何需要显示错误警报的操作中使用这种 Effect。


采取良好的行动卫生措施

对于通过商店调度的操作所描述的相同原则应适用于效果:

  • 不要从效果中返回动作(命令)数组。
  • 返回可由多个减速器和/或效果处理的独特动作。

我们先来看一个效果返回多个动作的例子:

readonly loadAlbum$ = createEffect(() => {
  return this.actions$.pipe(
    ofType(albumsActions.loadCurrentAlbum),
    concatLatestFrom(() => this.store.select(selectAlbumIdFromRoute)),
    concatMap(([, albumId]) => {
      return this.albumsService.getAlbum(albumId).pipe(
        // an array of actions is returned on successful load
        // then, `loadSongsSuccess` is handled by `songsReducer`
        // and `loadComposersSuccess` is handled by `composersReducer`
        mergeMap(({ songs, composers }) => [
          songsActions.loadSongsSuccess({ songs }),
          composersActions.loadComposersSuccess({ composers }),
        ]),
        catchError(/* ... */)
      );
    })
  );
});
Enter fullscreen mode Exit fullscreen mode

我见过很多次类似的情况。当操作被视为命令时,就会发生这种情况。您可以在“将操作视为唯一事件”部分中了解这种方法的缺点。

然而,如果我们采取良好的行动卫生措施,loadAlbum$效果将会如下:

readonly loadAlbum$ = createEffect(() => {
  return this.actions$.pipe(
    // when the album details page is opened
    ofType(albumDetailsPageActions.opened),
    // then select album id from the route
    concatLatestFrom(() => this.store.select(selectAlbumIdFromRoute)),
    concatMap(([, albumId]) => {
      // and load current album from the API
      return this.albumsService.getAlbum(albumId).pipe(
        // return unique action when album is loaded successfully
        map(({ songs, composers }) =>
          albumsApiActions.albumLoadedSuccess({ songs, composers })
        ),
        catchError(/* ... */)
      );
    })
  );
});
Enter fullscreen mode Exit fullscreen mode

然后,该albumLoadedSuccessaction 就可以被 reducer 和/或其他 effect 处理。在本例中,它将被 和songsReducer处理composersReducer

// songs.reducer.ts
export const songsReducer = createReducer(
  on(albumsApiActions.albumLoadedSuccess, (state, { songs }) => ({
    ...state,
    songs,
  }))
);

// composers.reducer.ts
export const composersReducer = createReducer(
  on(albumsApiActions.albumLoadedSuccess, (state, { composers }) => ({
    ...state,
    composers,
  }))
);
Enter fullscreen mode Exit fullscreen mode

结论

NgRx 提供了以多种不同方式实现相同功能的能力。然而,随着时间的推移,其中一些方法已经成为最佳实践,您应该考虑在项目中应用它们,以提高代码质量、性能和可维护性。

资源

同行评审员

非常感谢我的队友 Brandon、Tim 和 Alex 对本文提出的有益建议!

文章来源:https://dev.to/this-is-angular/ngrx-tips-i-needed-in-the-beginning-4hno
PREV
组件是纯开销 你的框架是纯开销 组件 DX > 性能消失的组件 未来是无组件的
NEXT
欢迎主题 - v53