我最初需要的 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,
};
}))
);
的值filteredMusicians
源自query
和数组。如果您决定将派生值保留在 store 中,则每次派生值之一发生变化时,都应该更新它。这样一来,state 会变得更大,reducer 会包含额外的逻辑,而且您很容易忘记在另一个更新或 的musicians
reducer 中添加过滤逻辑。query
musicians
处理派生状态的正确方法是通过选择器。返回已筛选音乐家的选择器如下所示:
export const selectFilteredMusicians = createSelector(
selectAllMusicians,
selectMusicianQuery,
(musicians, query) =>
musicians.filter(({ name }) => name.includes(query))
);
现在就musiciansReducer
简单多了:
export const musiciansReducer = createReducer(
on(musiciansPageActions.search, (state, { query }) => ({
...state,
query,
}))
);
使用视图模型选择器
视图模型选择器会组合其他选择器,以返回特定视图所需的所有状态块。每个容器使用一个选择器,可以有效简化容器组件。除此之外,视图模型选择器还具有其他优势。
让我们首先看看没有视图模型选择器的容器组件会是什么样子:
@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) {}
}
这种方法有几个缺点:
- 容器组件的大小随着所需状态块的数量而增加。
- 测试更加困难——可能有许多选择器需要模拟。
- 模板中有多个订阅。
现在让我们为这个容器创建一个视图模型选择器:
export const selectMusiciansPageViewModel = createSelector(
selectFilteredMusicians,
selectMusiciansQuery,
selectActiveMusician,
(musicians, query, activeMusician) => ({
musicians,
query,
activeMusician,
})
);
现在容器看起来是这样的:
@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) {}
}
该组件现在更小,更易于测试。此外,模板中只有一个订阅。
将动作视为独特事件
将 NgRx 操作视为唯一事件,而不是命令,并且不要重复使用它们。
对于简单且独立的功能,命令可能没问题。然而,对于使用多个功能状态的复杂功能,命令可能会导致代码混乱,并可能带来性能问题。现在让我们通过示例来理解将操作视为独立事件(即良好的操作规范)的重要性。
对于显示实体列表的页面,有一个简单的 NgRx 流程:
- 在组件初始化时调度操作来加载实体集合。
- 监听此操作的实际效果,从 API 加载实体,并返回以加载的实体作为有效负载的新操作。
- 创建一个案例简化器,它将监听从效果返回的操作并将加载的实体添加到状态中。
- 最后,从商店中选择实体并将其显示在模板中:
@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' });
}
}
一切正常,一开始无需任何修改。但是,如果我们想加载特定容器组件所需的另一个集合,该怎么办呢?在本例中,假设我们想显示每首已加载歌曲的作曲家。如果我们将操作视为命令,那么ngOnInit
方法SongsComponent
将如下所示:
ngOnInit(): void {
this.store.dispatch({ type: '[Songs] Load Songs' });
this.store.dispatch({ type: '[Composers] Load Composers' });
}
这里我们再来谈一下另一个非常重要的规则:不要顺序调度多个 action。顺序调度的 action 可能会导致意外的中间状态,并引发不必要的事件循环。
最好调度单个动作来表明用户已经打开了歌曲页面,并在loadSongs$
和loadComposers$
效果中收听该动作:
ngOnInit(): void {
this.store.dispatch({ type: '[Songs Page] Opened' });
}
“歌曲页面”是此操作的来源(它从歌曲页面调度),“打开”是事件的名称(歌曲页面打开)。
这给我们带来了一条新规则:操作命名要保持一致,使用“[源] 事件”模式。此外,操作命名要具有描述性。这在应用程序维护方面非常有帮助,尤其是在捕获错误方面。
如果我们在 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
当我们看到一系列描述清晰的操作时,我们可以轻松得出应用程序中发生的事情:
- 用户提交了登录表单。
- Auth API 响应登录成功。
- 用户打开了歌曲页面。
- 歌曲已成功从 Song API 加载。
- 已成功从 Composers API 加载 Composers。
不幸的是,命令并非如此:
[Auth] Login
[Auth] Login Success
[Songs] Load Songs
[Composers] Load Composers
[Songs] Load Songs Success
[Composers] Load Composers Success
命令可以从多个地方发送,所以我们无法弄清楚它们的来源是什么。
按来源对操作进行分组
我们在前面的例子中看到,一个操作可能会导致多个功能状态发生变化。因此,不要按功能状态对操作进行分组,而是按来源进行分组。
按来源创建操作文件。以下是按来源分组的操作文件的一些示例:
// 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 }>()
);
不要有条件地调度动作
不要根据状态值有条件地调度 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();
}
}
在上面的例子中,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 }))
)
);
})
);
});
然后,组件看起来会更干净:
@Component(/* ... */)
export class SongsComponent implements OnInit {
constructor(private readonly store: Store) {}
ngOnInit(): void {
this.store.dispatch(songsPageActions.opened());
}
}
创建可重用的 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 })
)
);
但是,如果任何这些操作需要不同的状态更改,请不要向现有的案例减少器添加额外的逻辑,如下所示:
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 }
)
);
相反,创建一个新的案例减少器:
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 }
)
);
小心外观
我以前使用 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 }
);
这种方法至少有两个缺点。首先,我们无法根据其名称推断出该效果的作用。其次,它不符合开放封闭原则——如果我们想为另一个动作触发相同的效果,就应该更改其名称。但是,如果我们将此效果命名为函数(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 }
);
保持效果简单
有时我们需要调用多个 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 }))
)
);
})
);
});
这个效果太大,即使加上注释也难以理解。不过,我们可以把 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 }))
);
})
);
}
}
它不仅可以在效果中使用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 }))
)
);
})
);
});
如果您正在使用旧版 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(
/* ... */
);
})
);
});
因为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(
/* ... */
);
})
);
});
应用单一职责原则
换句话说,不要在单个 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 }));
})
);
})
);
});
如果我们应用单一责任原则,我们将得到两个 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 }
);
另一个优点是:单一职责的 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(/* ... */)
);
})
);
});
我见过很多次类似的情况。当操作被视为命令时,就会发生这种情况。您可以在“将操作视为唯一事件”部分中了解这种方法的缺点。
然而,如果我们采取良好的行动卫生措施,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(/* ... */)
);
})
);
});
然后,该albumLoadedSuccess
action 就可以被 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,
}))
);
结论
NgRx 提供了以多种不同方式实现相同功能的能力。然而,随着时间的推移,其中一些方法已经成为最佳实践,您应该考虑在项目中应用它们,以提高代码质量、性能和可维护性。
资源
- Mike Ryan 的NgRx 良好行动卫生指南
- Alex Okrushko重新思考 Angular 应用程序中的状态
- 使用 NgRx 选择器构建子状态,作者:Brandon Roberts
- 使用 NgRx 选择器最大化和简化组件视图,作者:Brandon Roberts
- Tim Deschryver使用 NgRx 选择器解决 Angular 渲染缓慢问题
- 开始使用 NgRx Effects 实现此目的,作者:Tim Deschryver
同行评审员
非常感谢我的队友 Brandon、Tim 和 Alex 对本文提出的有益建议!
文章来源:https://dev.to/this-is-angular/ngrx-tips-i-needed-in-the-beginning-4hno