在构建复杂的企业级Angular应用时,状态管理成为架构设计的核心挑战。随着应用规模扩大,组件间状态共享、异步操作协同和数据流一致性等问题日益突出。NgRx作为基于Redux模式的响应式状态管理库,已成为Angular生态的状态管理解决方案首选。
NgRx建立在严格的单向数据流基础上,其核心架构包含五个关键要素:
- Store:单一数据源(Single Source of Truth)
Store作为不可变的中央状态容器,存储整个应用状态树。在Angular中通过依赖注入使用 - Actions:状态变更描述器
Actions描述状态变更,Actions只描述状态发生了什么变化,但不负责处理, 处理交给Reducers。 Actions使用createAction工厂函数创建 - Reducers:状态变更处理器
Reducers是根据Action处理状态转换 - Selectors:状态查询器
Selectors提供高效的状态查询机制,支持组合和记忆化 - Effects:副作用处理器
Effects隔离异步操作,保持Reducer纯净
下图表示 NgRx 中应用程序状态的整体流程。
下载NgRx schematics
1
npm install @ngrx/schematics --save-dev
配置NgRx CLI
1
ng config cli.schematicCollections "[\"@ngrx/schematics\"]"
下载NgRx
1
npm install @ngrx/store @ngrx/effects @ngrx/entity @ngrx/router-store @ngrx/store-devtools
Store
NgRx Store 提供状态管理功能,通过使用单一状态和操作来表达状态变化,从而创建可维护、明确的应用程序。NgRx Store是全局的、覆盖整个应用程序的。
是否需要NgRx Store, 应考虑以下原则:
- 共享(Shared):被多个组件和服务访问的状态。
- 持久化(Hydrated):从外部存储中持久化并重新加载的状态。
- 可用(Available):在重新进入路由时需要保持可用的状态。
- 检索(Retrieved):必须通过side-effect(如 API 请求)来获取的状态。
- 受影响(Impacted):受到其他来源的动作影响的状态。
使用以下命令行创建store
1
ng generate store State --root --state-path store --module app.module.ts --state-interface AppState
–state-path: 指定一个新文件夹
–module: 创建的state属于哪个模块
–state-interface: 创建指定的State接口
命令执行完之后 app.module.ts
1
2
3
4
5
6
@NgModule({
imports: [
StoreModule.forRoot(reducers, { metaReducers }),
StoreDevtoolsModule.instrument()
],
})
src/app/store/index.ts
1
2
3
4
5
6
7
// State 类型接口
export interface AppState {
}
// State名字和reducer的对应关系
export const reducers: ActionReducerMap<AppState> = {
};
Actions
动作(Actions) 是 NgRx 中的主要构建模块之一, 用于表达应用程序中发生的独特事件。从用户与页面的交互、到通过网络与外部交互,到与设备 API 的直接交互,这些事件及更多内容都通过动作来描述。
在 NgRx 中,一个动作由一个简单的接口组成:
1
2
3
interface Action {
type: string;
}
该接口只有一个属性:type,它是一个字符串,用于描述将在应用程序中派发的动作。type 的值通常采用 [来源] 事件
的形式,用于提供动作的类别和来源的上下文。
以下是示例, 这个动作描述了在与后端 API 交互后认证成功所触发的事件。:
1
2
3
{
type: '[Auth API] Login Success'
}
下面这个动作描述了用户在登录页面点击登录按钮以尝试认证时触发的事件。可以为动作添加其他属性,以提供更多上下文或元数据。username 和 password 是来自登录页面的附加元数据。
1
2
3
4
5
{
type: '[Login Page] Login',
username: string;
password: string;
}
使用以下命令行创建action
1
ng g action store/actions/counter
生成store/actions/counter.actions.ts
1
2
3
4
5
6
7
8
9
import { createActionGroup, emptyProps, props } from '@ngrx/store';
export const CounterActions = createActionGroup({
source: 'Counter',
events: {
'increment': emptyProps(),
'decrement': emptyProps()
}
});
increment
和decrement
是不带输入参数的。
createAction 函数返回一个函数,调用该函数会返回一个符合 Action 接口结构的对象。props 方法用于定义处理该动作所需的附加数据。动作创建器提供了一种一致且类型安全的方式来构造将要派发的动作。
派发动作:
修改app.component.ts
分发消息
1
2
3
4
5
6
7
8
9
10
constructor(private store: Store<AppState>){
}
increment(){
console.log('increment');
this.store.dispatch(CounterActions.increment());
}
decrement(){
console.log('decrement');
this.store.dispatch(CounterActions.decrement());
}
修改app.component.html
1
2
<button (click)="increment()">+1</button>
<button (click)="decrement()">-1</button>
Reducers
在 NgRx 中,Reducer 负责处理应用程序中从一个状态到下一个状态的转换。Reducer 函数通过判断动作(Action)的类型type
来决定如何处理这些状态转换。
Reducer 是纯函数,它对相同的输入总是产生相同的输出。它们没有副作用side effects,并以同步方式处理每一次状态转换。每个 reducer 函数接收最新派发的 Action 和当前状态,并决定是返回一个新的修改后的状态,还是保持原状态不变。
每个由 reducer 管理的状态片段通常包含以下几个部分:
- 一个定义State的接口或类型
- 参数,包括初始状态或当前状态,以及当前动作
- 用于处理与特定动作相关的状态变化的函数
使用以下命令行创建reducer
1
ng g reducer store/reducers/counter --reducers=../index.ts
生成app/store/reducers/counter.reducer.ts
, 初始状态:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { createReducer, on } from '@ngrx/store';
import { CounterActions } from '../actions/counter.actions';
// state名称
export const counterFeatureKey = 'counter';
// State类型接口
export interface State {
}
// 初始状态
export const initialState: State = {
};
// 创建reducer函数
export const reducer = createReducer(
initialState,
);
app/store/index.ts
中已经自动注册了该State和reducer
1
2
3
4
5
6
7
8
9
10
11
12
import * as fromCounter from './reducers/counter.reducer';
// State 类型接口
export interface AppState {
[fromCounter.counterFeatureKey]: fromCounter.State;
}
// State名字和reducer的对应关系
export const reducers: ActionReducerMap<AppState> = {
[fromCounter.counterFeatureKey]: fromCounter.reducer,
};
fromCounter.counterFeatureKey
是自动生成的State名字counter
, 在ReducerMap里表明了counter和reducer的对应关系
修改counter.reducer.ts
,定义State接口里必须有一个number
类型的count,初始状态下count为0。reducer中除了initialState外,增加两个on方法接收action返回一个全新的State:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { createReducer, on } from '@ngrx/store';
import { CounterActions } from '../actions/counter.actions';
// state名称
export const counterFeatureKey = 'counter';
// State类型接口
export interface State {
count: number;
}
// 初始状态
export const initialState: State = {
count: 0
};
// 创建reducer函数
export const reducer = createReducer(
initialState,
on(CounterActions.increment, state => ({
...state,
count: state.count + 1
})),
on(CounterActions.decrement, state => ({
...state,
count: state.count - 1
}))
);
on函数就收两个参数,一个是action的名字,第二个参数是回调函数,用来处理state,先拿到原来的state,然后修改state.count加1
State转换不会修改原始状态,而是使用扩展运算符返回一个新的State对象。扩展语法会将当前状态的属性复制到新对象中,从而创建一个新的引用。这确保每次状态变化都会生成一个新状态,从而保持状态变化的纯粹性。同时也有助于引用完整性,确保状态变化时旧的引用被丢弃。
在上面的例子中reducer函数里我们hardcode每次只增加1,下面我们修改代码,将要增加的数值作为参数传进去
app.component.ts
1
2
3
increment(){
this.store.dispatch(CounterActions.increment({count: 5}));
}
我们给increment这个action传递一个Object,里面带了count:5。下面修改counter.actions.ts
1
2
3
4
5
6
7
export const CounterActions = createActionGroup({
source: 'Counter',
events: {
'increment': props<{ count: number }>(),
'decrement': emptyProps()
}
});
使用props
定义传入的参数类型
在counter.reducer.ts
中,将action
作为第二个参数传入,我们就可以使用action
中的count
1
2
3
4
on(CounterActions.increment, (state, action) => ({
...state,
count: state.count + action.count
})),
MetaReducer
MetaReducer是Action->Reducer之间的钩子,允许开发者对Action进行预处理。MetaReducer会在普通Reducer函数调用之前调用。
注意MetaReducer就是一个普通的函数,没有特定的名字。
在store\index.ts
中,我们定义一个MetaReducer logger
, 它接收一个Reducer, 并返回一个Reducer,在返回的过程中我们可以加一些处理,比如把前后的State都打印出来:
1
2
3
4
5
6
7
8
9
10
function logger(reducer: ActionReducer<AppState>): ActionReducer<AppState> {
return function(state, action) {
let result = reducer(state, action);
console.log("latest state: ", result);
console.log("last state: ", state);
console.log("action: ", action);
return result;
}
}
export const metaReducers: MetaReducer<AppState>[] = isDevMode() ? [logger] : [];
root state 与 feature state(需要更新)
-
根状态 root state
应用程序的状态定义为一个大型对象。注册 reducer 函数来管理部分状态,只是定义了该对象中的键及其对应的值。要在应用程序中注册全局 Store,请使用 StoreModule.forRoot() 方法,并传入一个键值对映射来定义你的状态。StoreModule.forRoot() 会注册应用程序的全局提供者,包括你在组件和服务中注入的 Store 服务,用于派发动作和选择状态片段。
1 2 3 4 5 6 7 8 9 10
import { NgModule } from '@angular/core'; import { StoreModule } from '@ngrx/store'; import { scoreboardReducer } from './reducers/scoreboard.reducer'; @NgModule({ imports: [ StoreModule.forRoot({ game: scoreboardReducer }) ], }) export class AppModule {}
使用 StoreModule.forRoot() 注册状态可以确保这些状态在应用程序启动时就被定义。通常你会注册那些需要在整个应用中立即可用的根状态。
-
注册特性状态(Registering feature state)
feature状态的行为方式与根状态相同,但它允许你在应用程序中为特定功能区域定义状态。你的整个状态是一个大型对象,而特性状态会在该对象中注册额外的键和值。通过查看一个示例状态对象,可以看到特性状态如何让你的状态逐步构建。我们先从一个空状态对象开始。
1 2 3 4 5 6 7 8 9
import { NgModule } from '@angular/core'; import { StoreModule } from '@ngrx/store'; @NgModule({ imports: [ StoreModule.forRoot({}) ], }) export class AppModule {}
这会将你的应用注册为一个空的根状态对象:
1
{}
现在使用 scoreboardReducer 和一个名为 ScoreboardModule 的Module 来注册额外的状态。
scoreboard.reducer.ts1
export const scoreboardFeatureKey = 'game';
scoreboard.module.ts
1 2 3 4 5 6 7 8 9 10
import { NgModule } from '@angular/core'; import { StoreModule } from '@ngrx/store'; import { scoreboardFeatureKey, scoreboardReducer } from './reducers/scoreboard.reducer'; @NgModule({ imports: [ StoreModule.forFeature(scoreboardFeatureKey, scoreboardReducer) ], }) export class ScoreboardModule {}
将 ScoreboardModule 添加到 AppModule 中,以便在应用启动时立即加载该状态:
1 2 3 4 5 6 7 8 9 10 11
import { NgModule } from '@angular/core'; import { StoreModule } from '@ngrx/store'; import { ScoreboardModule } from './scoreboard/scoreboard.module'; @NgModule({ imports: [ StoreModule.forRoot({}), ScoreboardModule ], }) export class AppModule {}
加载特性后,game 键就会成为状态对象中的一个属性,并由状态管理:
1 2 3
{ game: { home: 0, away: 0 } }
Selectors
选择器(Selectors)是用于从 Store 状态中获取数据, selector是为了把获取数据的复杂过程从component代码中分离出来,简化数据获取的流程,component只需要访问selector就能获取数据。Selectors在选择状态时具备以下优势:
- 可移植性(Portability)
- 记忆化(Memoization)
- 组合性(Composition)
- 可测试性(Testability)
- 类型安全(Type Safety)
当使用 createSelector 或 createFeatureSelector 函数时,@ngrx/store 会跟踪你选择器函数最近调用时的参数, 当参数匹配时可以直接返回上一次的结果,而无需重新执行选择器函数。这种做法称为 记忆化(Memoization),在处理计算开销较大的选择器时尤其有性能优势。
使用以下命令生成selector
1
ng g selector store/selectors/counter
修改store/selectors/counter.selector.ts
代码,从根状态AppState
中选择count
state,然后创建Selector,返回count
state中的数值count
1
2
3
4
5
6
7
8
9
10
11
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { counterFeatureKey, State } from '../reducers/counter.reducer';
import { AppState } from '..';
// 直接从根状态选择 counter 特性State
export const selectFeatureCounter = (state: AppState) => state[counterFeatureKey];
export const selectCount = createSelector(
selectFeatureCounter,
(state: State) => state.count
);
在app.component.ts
中我们可以获取这个状态
1
2
3
4
5
6
7
8
import { selectCount } from './store/selectors/counter.selectors';
export class AppComponent {
count$: Observable<number>;
constructor(private store: Store<AppState>){
this.count$ = this.store.pipe(select(selectCount))
}
}
在app.component.html
中显示
1
2
3
<button (click)="increment()">+1</button>
<p>8</p>
<button (click)="decrement()">-1</button>
使用选择器获取多个State
createSelector 可以基于多个状态片段从状态中选择数据。它最多可以接受 8 个选择器函数,用于更完整的状态选择。
例如,假设你在状态中有一个 selectedUser 对象,还有一个 allBooks 数组。你希望展示当前用户的所有图书。
你可以使用 createSelector 来实现这一目标。即使你更新了 allBooks,你的可见图书也会始终保持最新。如果有选中的用户,它只显示该用户的图书;如果没有选中用户,则显示所有图书。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import { createSelector } from '@ngrx/store';
export interface User {
id: number;
name: string;
}
export interface Book {
id: number;
userId: number;
name: string;
}
export interface AppState {
selectedUser: User;
allBooks: Book[];
}
export const selectUser = (state: AppState) => state.selectedUser;
export const selectAllBooks = (state: AppState) => state.allBooks;
export const selectVisibleBooks = createSelector(
selectUser,
selectAllBooks,
(selectedUser: User, allBooks: Book[]) => {
if (selectedUser && allBooks) {
return allBooks.filter((book: Book) => book.userId === selectedUser.id);
} else {
return allBooks;
}
}
);
重置记忆化选择器(Resetting Memoized Selectors)
通过 createSelector 或 createFeatureSelector 创建的选择器函数,初始时其记忆化值为 null。当选择器首次被调用时,它会计算结果并将该值存储在内存中。之后如果使用相同参数再次调用,它会直接返回记忆化值,而不会重新计算。如果参数发生变化,它会重新计算并更新记忆化值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { createSelector } from '@ngrx/store';
export interface State {
counter1: number;
counter2: number;
}
export const selectCounter1 = (state: State) => state.counter1;
export const selectCounter2 = (state: State) => state.counter2;
export const selectTotal = createSelector(
selectCounter1,
selectCounter2,
(counter1, counter2) => counter1 + counter2
);
let state = { counter1: 3, counter2: 4 };
selectTotal(state); // 计算 3 + 4,返回 7,并将 7 存为记忆化值
selectTotal(state); // 返回记忆化值 7,不重新计算
state = { ...state, counter2: 5 };
selectTotal(state); // 计算 3 + 5,返回 8,并更新记忆化值为 8
选择器的记忆化值会一直保留在内存中。如果该值是一个不再需要的大型数据集,可以通过调用选择器的 release 方法将其重置为 null,以释放内存。
1
2
selectTotal(state); // 返回 8
selectTotal.release(); // 清除记忆化值,变为 null
选择器使你能够为应用程序状态构建读取模型。在 CQRS 架构模式中,NgRx 将读取模型(selectors)与写入模型(reducers)分离。
Effects
在基于服务的 Angular 应用中,组件通常直接通过服务与外部资源交互。而使用 Effects,可以将这些服务的交互逻辑从组件中分离出来。Effects 主要用于处理诸如数据获取、产生多个事件的长时间任务,以及其他组件无需显式了解的外部交互。
核心概念
- Effects 将副作用与组件隔离,使组件更纯粹,仅负责选择状态
Select States
和派发动作Dispatch Actions
。 - Effects 是长时间运行的服务,它监听 Store 中派发的所有动作的可观察流。
- Effects 会根据动作类型进行过滤,只处理感兴趣的动作(通过操作符实现)。
- Effects 执行任务(同步或异步),并返回新的动作。
app.component.html
添加新的button
1
<button (click)="delayAdd()">Delay add</button>
counter.actions
中注册一个新的action
1
2
3
4
5
6
7
8
export const CounterActions = createActionGroup({
source: 'Counter',
events: {
'increment': props<{ count: number }>(),
'decrement': emptyProps(),
'delayAdd': emptyProps()
}
});
app.component.ts
中分发这个action
1
2
3
delayAdd(){
this.store.dispatch(CounterActions.delayAdd());
}
使用以下命令行创建effect
1
ng g effect store/effects/counter --root --module ../../app.module.ts
生成的counter.effects.ts
1
2
3
4
5
6
7
import { Injectable } from '@angular/core';
import { Actions, createEffect } from '@ngrx/effects';
@Injectable()
export class CounterEffects {
constructor(private actions$: Actions) {}
}
app.module.ts
中自动注册了EffectModule
1
2
3
4
5
imports: [
StoreModule.forRoot(reducers, { metaReducers }),
StoreDevtoolsModule.instrument(),
EffectsModule.forRoot([CounterEffects])
],
修改counter.effect.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { CounterActions } from '../actions/counter.actions';
import { map, mergeMap, timer } from 'rxjs';
@Injectable()
export class CounterEffects {
delayAdd$;
constructor(private actions$: Actions) {
this.delayAdd$ = createEffect(() =>
this.actions$.pipe(
ofType(CounterActions.delayAdd),
mergeMap(() => timer(2000).pipe(map(() => CounterActions.increment({ count: 10 }))))
)
);
}
}
Effects 其实是可注入的服务类,具有以下几个关键部分:
- 一个可注入的 Actions 服务,它提供一个可观察流,包含每次状态更新后派发的动作。
- 使用 createEffect 函数为可观察流附加元数据。该元数据用于注册这些流,使其订阅 Store。任何从 effect 流中返回的动作都会再次派发到 Store。
- 使用可管道的 ofType 操作符对动作进行过滤。ofType 接收一个或多个动作类型作为参数,用于筛选需要处理的动作。
- Effects 会订阅 Store 的动作流。
- 可通过注入服务来与外部 API 交互并处理流。
与组件内副作用Component-Based Side Effects的对比
在基于服务Service
的应用中,组件通过多个服务来获取数据,这些服务可能依赖其他服务来管理不同的数据集。组件使用这些服务来执行任务,因此承担了许多职责。
假设你的应用用于管理电影,下面是一个用于获取并展示电影列表的组件:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { Component, inject, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
template: `
<li *ngFor="let movie of movies">
</li>
`,
imports: [CommonModule],
})
export class MoviesPageComponent implements OnInit {
private moviesService = inject(MoviesService);
protected movies: Movie[] = [];
ngOnInit() {
this.movieService.getAll()
.subscribe(movies => this.movies = movies);
}
}
对应的服务如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
@Injectable({
providedIn: 'root',
})
export class MoviesService {
private http = inject(HttpClient);
getAll(): Observable {
return this.http.get('/movies');
}
}
该组件承担了多个职责:
- 管理电影的状态
- 使用服务执行副作用(调用外部 API 获取电影)
- 在组件内部修改电影状态
使用 Effects 简化组件职责
当 Effects 与 Store 一起使用时,可以显著减少组件的职责。在大型应用中,这尤为重要,因为你可能有多个数据源、多个服务来获取数据,甚至服务之间也存在依赖关系。
Effects 负责处理外部数据和交互,使服务变得更轻量,仅专注于执行外部任务。接下来我们重构组件,将共享的电影数据放入 Store 中,由 Effects 负责获取电影数据。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { Component, inject, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
template: `
<div *ngFor="let movie of movies$ | async">
</div>
`,
imports: [CommonModule],
})
export class MoviesPageComponent implements OnInit {
private store = inject(Store<{ movies: Movie[] }>);
protected movies$ = this.store.select(state => state.movies);
ngOnInit() {
this.store.dispatch({ type: '[Movies Page] Load Movies' });
}
}
电影数据仍然通过 MoviesService 获取,但组件不再关心具体的获取和加载过程。它只负责声明加载意图(declaring its intent to load movies
),并通过选择器访问电影列表数据。异步获取电影的逻辑由 Effects 处理。这样组件更容易测试,也更专注于展示数据而非管理数据。
使用 Effects 来加载电影:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { Injectable, inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { EMPTY } from 'rxjs';
import { map, exhaustMap, catchError } from 'rxjs/operators';
import { MoviesService } from './movies.service';
@Injectable()
export class MoviesEffects {
private actions$ = inject(Actions);
private moviesService = inject(MoviesService);
loadMovies$ = createEffect(() => {
return this.actions$.pipe(
ofType('[Movies Page] Load Movies'),
exhaustMap(() => this.moviesService.getAll()
.pipe(
map(movies => ({ type: '[Movies API] Movies Loaded Success', payload: movies })),
catchError(() => EMPTY)
))
);
});
}
这个 loadMovies$
effect 会监听所有派发的动作,但只对 [Movies Page] Load Movies
这个Action感兴趣(通过 ofType
过滤)。动作流随后通过 exhaustMap 展平并映射为新的可观察流。MoviesService.getAll()
方法返回一个 observable,在成功时将电影数据映射为新的动作[Movies API] Movies Loaded Success
;如果发生错误,则返回一个空的 observable。该动作会被派发到 Store,由 reducer 处理状态更新。
注册 Effects
Effect 类通过 provideEffects
方法进行注册。
-
根级注册
在应用程序配置的 providers 数组中注册根级 Effects。
Effects 在实例化后会立即开始运行,以确保尽早监听所有相关动作。1 2 3 4 5 6 7 8 9 10 11 12 13 14
import { bootstrapApplication } from '@angular/platform-browser'; import { provideStore } from '@ngrx/store'; import { provideEffects } from '@ngrx/effects'; import { AppComponent } from './app.component'; import { MoviesEffects } from './effects/movies.effects'; import * as actorsEffects from './effects/actors.effects'; bootstrapApplication(AppComponent, { providers: [ provideStore(), provideEffects(MoviesEffects, actorsEffects), ], });
-
注册Feature-level effects
特性级 Effects 在路由配置的 providers 数组中注册。使用 provideEffects() 方法注册特性 Effects。
即使在不同的懒加载特性中多次注册同一个 Effects 类,也不会导致该 Effect 多次运行。1 2 3 4 5 6 7 8 9 10 11 12 13 14
import { Route } from '@angular/router'; import { provideEffects } from '@ngrx/effects'; import { MoviesEffects } from './effects/movies.effects'; import * as actorsEffects from './effects/actors.effects'; export const routes: Route[] = [ { path: 'movies', providers: [ provideEffects(MoviesEffects, actorsEffects) ] } ];
另一种注册方式
你也可以使用 USER_PROVIDED_EFFECTS 提供器来注册根级或特性级 Effects:1 2 3 4 5 6 7 8
providers: [ MoviesEffects, { provide: USER_PROVIDED_EFFECTS, multi: true, useValue: [MoviesEffects], }, ]
more
-
整合状态(Incorporating State)
如果执行 Effect 所需的元数据除了动作类型之外还有其他内容,应通过 props 方法在动作创建器中传递。
例如,以下是一个带有额外元数据(用户凭证)的登录动作:1 2 3 4 5 6 7
import { createAction, props } from '@ngrx/store'; import { Credentials } from '../models/user'; export const login = createAction( '[Login Page] Login', props<{ credentials: Credentials }>() );
auth.effects.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
import { Injectable, inject } from '@angular/core'; import { Actions, ofType, createEffect } from '@ngrx/effects'; import { of } from 'rxjs'; import { catchError, exhaustMap, map } from 'rxjs/operators'; import { LoginPageActions, AuthApiActions, } from '../actions'; import { Credentials } from '../models/user'; import { AuthService } from '../services/auth.service'; @Injectable() export class AuthEffects { private actions$ = inject(Actions); private authService = inject(AuthService); login$ = createEffect(() => { return this.actions$.pipe( ofType(LoginPageActions.login), exhaustMap(action => this.authService.login(action.credentials).pipe( map(user => AuthApiActions.loginSuccess({ user })), catchError(error => of(AuthApiActions.loginFailure({ error }))) ) ) ); }); }
该登录动作包含额外的 credentials 元数据,并传递给服务以完成登录操作。
-
从状态中获取元数据
有时所需的元数据只能从状态中获取。这时可以使用 RxJS 的 withLatestFrom 或 NgRx 的 concatLatestFrom 操作符来提供状态。
以下示例展示了 addBookToCollectionSuccess$ Effect 根据收藏书籍数量显示不同的提示:1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
import { Injectable, inject } from '@angular/core'; import { Store } from '@ngrx/store'; import { Actions, ofType, createEffect, concatLatestFrom } from '@ngrx/effects'; import { tap } from 'rxjs/operators'; import { CollectionApiActions } from '../actions'; import * as fromBooks from '../reducers'; @Injectable() export class CollectionEffects { private actions$ = inject(Actions); private store = inject(Store<fromBooks.State>); addBookToCollectionSuccess$ = createEffect( () => { return this.actions$.pipe( ofType(CollectionApiActions.addBookSuccess), concatLatestFrom(_action => this.store.select(fromBooks.getCollectionBookIds)), tap(([_action, bookCollection]) => { if (bookCollection.length === 1) { window.alert('恭喜你添加了第一本书!'); } else { window.alert('你已添加第 ' + bookCollection.length + ' 本书'); } }) ); }, { dispatch: false }); }
性能提示:使用 concatLatestFrom 等展平操作符可以避免选择器在动作未触发时提前执行。
-
使用其他可观察源创建 Effects
由于 Effects 本质上是可观察流的消费者,因此它们可以在不依赖动作(actions)和 ofType 操作符的情况下使用。这对于那些无需监听特定动作,而是监听其他可观察源的 Effects 非常有用。例如,假设我们希望追踪用户的点击事件,并将这些数据发送到监控服务器。我们可以创建一个监听 document 的点击事件的 Effect,并将事件数据发送到服务器。
user-activity.effects.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
import { Injectable, inject } from '@angular/core'; import { Observable, fromEvent } from 'rxjs'; import { concatMap } from 'rxjs/operators'; import { createEffect } from '@ngrx/effects'; import { UserActivityService } from '../services/user-activity.service'; @Injectable() export class UserActivityEffects { private userActivityService = inject(UserActivityService); trackUserActivity$ = createEffect(() => { return fromEvent(document, 'click').pipe( concatMap(event => this.userActivityService.trackUserActivity(event)), ); }, { dispatch: false }); }
这个 Effect 不依赖任何动作类型,而是直接监听浏览器的点击事件,并通过服务将事件数据发送出去。
Entity
Entity译为实体,实体就是集合中的一条数据。
NgRx中提供了实体适配器对象, 在实体适配器对象下面提供了各种操作集合中实体的方法,目的就是提高开发者操作实体的效率。
Entity主要用在Reducer上,通过提供一系列工具方法,简化我们的操作。
-
生成一个新的Angular project
1
ng g demo_entity --standalone=false
-
下载NgRx schematics
1
npm install @ngrx/schematics --save-dev
-
配置NgRx CLI
1
ng config cli.schematicCollections "[\"@ngrx/schematics\"]"
-
下载NgRx
1
npm install @ngrx/store @ngrx/effects @ngrx/entity @ngrx/router-store @ngrx/store-devtools
-
使用以下命令行创建store
1
ng generate store State --root --state-path store --module app.module.ts --state-interface AppState
-
使用以下命令行创建action
1
ng g action store/actions/todo
-
修改todo.actions.ts代码,添加两个actions
1 2 3 4 5 6 7 8 9
import { createActionGroup, emptyProps, props } from '@ngrx/store'; export const TodoActions = createActionGroup({ source: 'Todo', events: { 'addTodo': props<{ title: string }>(), 'deleteTodo': props<{ id: string }>(), } });
-
使用以下命令行创建reducer
1
ng g reducer store/reducers/todo --reducers=../index.ts
-
修改reducer
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
import { createReducer, on } from '@ngrx/store'; import { TodoActions } from '../actions/todo.actions'; import { v4 as uuidv4 } from 'uuid'; export const todoFeatureKey = 'todo'; export interface Todo { id: string; title: string; } export interface State { todos: Todo[]; } export const initialState: State = { todos: [], }; export const reducer = createReducer( initialState, on(TodoActions.addTodo, (state, action) => ({ ...state, todos: [ ...state.todos, { id: uuidv4(), title: action.title }, ] })), on(TodoActions.deleteTodo, (state, action) => ({ ...state, todos: state.todos.filter(todo => todo.id !== action.id) })) );
-
修改
App.component.ts
, 当用户在input中敲击回车时,派发 addTodo action.1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
export class AppComponent implements AfterViewInit{ @ViewChild('AddTodoInput') AddTodoInput!: ElementRef<HTMLInputElement>; constructor(private store: Store<AppState>){} ngAfterViewInit(): void { this.AddTodoInput.nativeElement.addEventListener('keyup', (event: KeyboardEvent) => { if (event.key === 'Enter') { const inputValue = this.AddTodoInput.nativeElement.value.trim(); if(inputValue){ this.store.dispatch(TodoActions.addTodo({ title: inputValue })); this.AddTodoInput.nativeElement.value = ''; } } }); } }
-
使用以下命令生成selector
1
ng g selector store/selectors/todo
-
修改
todo.selector.ts
1 2 3 4 5 6 7 8 9 10
import { createFeatureSelector, createSelector } from '@ngrx/store'; import { AppState } from '..'; import { todoFeatureKey } from '../reducers/todo.reducer'; const selectTodo = (state: AppState) => state[todoFeatureKey]; export const selectTodos = createSelector( selectTodo, (todoState) => todoState.todos );
-
修改
app.component.ts
,添加selector1 2 3 4
todos$: Observable<Todo[]>; constructor(private store: Store<AppState>){ this.todos$ = this.store.select(selectTodos); }
-
修改页面
app.component.html
,显示我们已经添加的Todo1 2 3 4 5 6 7 8 9 10
<div class="container mt-4"> <input type="text" class="form-control" placeholder="Enter todo title" #AddTodoInput /> <ul class="list-group mt-3"> <li class="list-group-item" *ngFor="let todo of (todos$ | async)"> <button class="btn btn-danger btn-sm float-end" >Delete</button> </li> </ul> </div>
-
给Delete按钮添加click事件
1
<button class="btn btn-danger btn-sm float-end" (click)="deleteTodo(todo.id)">Delete</button>
-
修改app.component.ts,添加delete函数去派发delete action
1 2 3
deleteTodo(id: string) { this.store.dispatch(TodoActions.deleteTodo({ id })) }
-
前面准备了这么多,下面我们才进入Entity,要使用Entity,我们在store中存储的数据必须符合NgRx的要求。NgRx要求存储的数据必须是以下格式
1 2 3 4
export interface EntityState<T> { ids: string[] | number[]; entities: Dictionary<T>; }
来看一个实例
1 2 3 4 5 6 7
{ ids: [1, 2], entities: { 1: { id: 1, title: "Hello Angular"}, 2: { id: 2, title: "Hello NgRx" }, } }
entities里存放的是我们以前存在Store中的数据,NgRx要求我们必须以上面这种方式包装一下,才能使用Entity
-
修改todo.reducer.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14
export interface State extends EntityState<Todo> {} export const adapter: EntityAdapter<Todo> = createEntityAdapter<Todo>() export const initialState: State = adapter.getInitialState() export const reducer = createReducer( initialState, on(TodoActions.addTodo, (state, action) => adapter.addOne({ id: uuidv4(), title: action.title }, state) ), on(TodoActions.deleteTodo, (state, action) => adapter.removeOne(action.id, state) ) );
State接口继承EntityState, 初始化一个adapter。修改initialState和Reducer中的on方法。以下是代码修改前后的对比, Entity的
addOne
,removeOne
等方法简化了我们的代码:
-
修改
todo.selector.ts
1 2 3
const {selectIds, selectEntities, selectAll, selectTotal} = adapter.getSelectors(); export const selectTodos = createSelector(selectTodo, selectAll);
修改代码前后对比:
-
运行我们的工程,在Redux插件中,可以看到我们在Store中存储的数据
更多的工具方法可以参考
Adapter Collection Methods
Demo
-
生成一个新项目
1
ng new my-angular-app --standalone=false --routing --style=css
-
安装依赖
1
npm install @ngrx/store --save
-
在app下创建一个名为counter.actions.ts的新文件,以描述增加、减少和重置其值的计数器操作。
1 2 3 4 5
import { createAction } from '@ngrx/store'; export const increment = createAction('[Counter Component] Increment'); export const decrement = createAction('[Counter Component] Decrement'); export const reset = createAction('[Counter Component] Reset');
-
在app下创建一个名为counter.reducer.ts的新文件,定义一个 reducer 函数,根据提供的 action 来处理计数器值的变化。
1 2 3 4 5 6 7 8 9 10 11
import { createReducer, on } from '@ngrx/store'; import { increment, decrement, reset } from './counter.actions'; export const initialState = 0; export const counterReducer = createReducer( initialState, on(increment, (state) => state + 1), on(decrement, (state) => state - 1), on(reset, (state) => 0) );
-
在
app.module.ts
中导入源StoreModule文件@ngrx/store和counter.reducer文件。1 2
import { StoreModule } from '@ngrx/store'; import { counterReducer } from './counter.reducer';
-
在 AppModule 的 imports 数组中添加 StoreModule.forRoot 函数,并传入一个包含 count 和用于管理计数器状态的 counterReducer 的对象。StoreModule.forRoot() 方法会注册全局的提供者,使整个应用程序都能访问 Store。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
import { BrowserModule } from '@angular/platform-browser'; import { NgModule } from '@angular/core'; import { AppComponent } from './app.component'; import { StoreModule } from '@ngrx/store'; import { counterReducer } from './counter.reducer'; @NgModule({ declarations: [AppComponent], imports: [BrowserModule, StoreModule.forRoot({ count: counterReducer })], providers: [], bootstrap: [AppComponent], }) export class AppModule {}
-
在 app 文件夹中创建一个名为 my-counter 的新文件夹,并在其中新建一个名为 my-counter.component.ts 的文件,用于定义一个名为 MyCounterComponent 的新组件。该组件将渲染按钮,允许用户更改计数状态。同时,在同一文件夹中创建一个名为 my-counter.component.html 的文件,用于定义该组件的模板页面。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
import { Component } from '@angular/core'; import { Observable } from 'rxjs'; @Component({ selector: 'app-my-counter', standalone: false, templateUrl: './my-counter.component.html', styleUrl: './my-counter.component.css' }) export class MyCounterComponent { count$: Observable<number> constructor() { // TODO: Connect `this.count$` stream to the current store `count` state } increment() { // TODO: Dispatch an increment action } decrement() { // TODO: Dispatch a decrement action } reset() { // TODO: Dispatch a reset action } }
my-counter.component.html
1 2 3 4 5 6 7
<button (click)="increment()">Increment</button> <div>Current Count: 8</div> <button (click)="decrement()">Decrement</button> <button (click)="reset()">Reset Counter</button>
-
将新组件添加到 AppModule 的声明中并在模板app.component.html中声明它:
1
<app-my-counter></app-my-counter>
app.module.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
import { BrowserModule } from '@angular/platform-browser'; import { NgModule } from '@angular/core'; import { AppComponent } from './app.component'; import { StoreModule } from '@ngrx/store'; import { counterReducer } from './counter.reducer'; import { MyCounterComponent } from './my-counter/my-counter.component'; @NgModule({ declarations: [AppComponent, MyCounterComponent], imports: [BrowserModule, StoreModule.forRoot({ count: counterReducer })], providers: [], bootstrap: [AppComponent], }) export class AppModule {}
-
将 store 注入到 MyCounterComponent 中,并将 count$ 连接到 store 的 count 状态。通过向 store 分发 action 来实现 increment(递增)、decrement(递减)和 reset(重置)方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
import { Component } from '@angular/core'; import { Store } from '@ngrx/store'; import { Observable } from 'rxjs'; import { decrement, increment, reset } from '../counter.actions'; @Component({ selector: 'app-my-counter', standalone: false, templateUrl: './my-counter.component.html', styleUrl: './my-counter.component.css' }) export class MyCounterComponent { count$: Observable<number>; constructor(private store: Store<{ count: number }>) { this.count$ = store.select('count'); } increment() { this.store.dispatch(increment()); } decrement() { this.store.dispatch(decrement()); } reset() { this.store.dispatch(reset()); } }
Demo 2
-
生成一个新项目
1
ng new book --standalone=false --routing --style=css
-
安装依赖
1
npm install @ngrx/store --save
-
生成
BookList
组件1
ng g c BookList
-
在
book-list
文件夹中添加一个books.model.ts
文件来定义Book接口1 2 3 4 5 6 7
export interface Book { id: string; volumeInfo: { title: string; authors: Array<string>; }; }
-
在app下创建
state
文件夹来管理状态.创建books.actions.ts
来描述book相关的actions。Book actions包括图书列表的获取、添加图书和移除图书操作。1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
import { createActionGroup, props } from '@ngrx/store'; import { Book } from '../book-list/books.model'; export const BooksActions = createActionGroup({ source: 'Books', events: { 'Add Book': props<{ bookId: string }>(), 'Remove Book': props<{ bookId: string }>(), }, }); export const BooksApiActions = createActionGroup({ source: 'Books API', events: { 'Retrieved Book List': props<{ books: ReadonlyArray<Book> }>(), }, });
-
在State下创建
books.reducer.ts
在此文件中,定义一个 reducer 函数,用于处理从State中获取图书列表的操作,并相应地更新状态state。1 2 3 4 5 6 7 8 9 10 11
import { createReducer, on } from '@ngrx/store'; import { BooksApiActions } from './books.actions'; import { Book } from '../book-list/books.model'; export const initialState: ReadonlyArray<Book> = []; export const booksReducer = createReducer( initialState, on(BooksApiActions.retrievedBookList, (_state, { books }) => books) );
-
在 state 文件夹中创建另一个名为
collection.reducer.ts
的文件,用于处理更改图书收藏collection的相关操作。定义一个 reducer 函数来处理添加操作,通过将图书的 ID 添加到收藏中。使用同一个 reducer 函数处理移除操作,通过根据图书 ID 过滤收藏数组来实现移除。1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
import { createReducer, on } from '@ngrx/store'; import { BooksActions } from './books.actions'; export const initialState: ReadonlyArray<string> = []; export const collectionReducer = createReducer( initialState, on(BooksActions.removeBook, (state, { bookId }) => state.filter((id) => id !== bookId) ), on(BooksActions.addBook, (state, { bookId }) => { if (state.indexOf(bookId) > -1) return state; return [...state, bookId]; }) );
-
修改app.module.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
import { NgModule } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; import { AppRoutingModule } from './app-routing.module'; import { AppComponent } from './app.component'; import { BookListComponent } from './book-list/book-list.component'; import { HttpClientModule } from '@angular/common/http'; import { booksReducer } from './state/books.reducer'; import { collectionReducer } from './state/collection.reducer'; import { StoreModule } from '@ngrx/store'; @NgModule({ declarations: [ AppComponent, BookListComponent ], imports: [ BrowserModule, AppRoutingModule, StoreModule.forRoot({ books: booksReducer, collection: collectionReducer }), HttpClientModule, ], providers: [], bootstrap: [AppComponent] }) export class AppModule { }
-
创建图书列表和收藏选择器
books.selectors.ts
,以确保我们能够从 store 中获取正确的信息。如你所见,selectBookCollection 选择器通过组合另外两个选择器来构建其返回值。1 2 3 4 5 6 7 8 9 10 11 12 13 14
import { createSelector, createFeatureSelector } from '@ngrx/store'; import { Book } from '../book-list/books.model'; export const selectBooks = createFeatureSelector<ReadonlyArray<Book>>('books'); export const selectCollectionState = createFeatureSelector<ReadonlyArray<string>>('collection'); export const selectBookCollection = createSelector( selectBooks, selectCollectionState, (books, collection) => { return collection.map((id) => books.find((book) => book.id === id)!); } );
-
在 book-list 文件夹中,创建服务
books.service.ts
,用于从 API 获取图书列表所需的数据。该服务将调用 Google Books API 并返回图书列表。1
ng g s book-list\Book
books.service.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { Book } from './books.model'; @Injectable({ providedIn: 'root' }) export class BookService { constructor(private http: HttpClient) {} getBooks(): Observable<Array<Book>> { return this.http.get<{ items: Book[] }>( 'https://www.googleapis.com/books/v1/volumes?maxResults=5&orderBy=relevance&q=oliver%20sacks' ) .pipe(map((books) => books.items || [])); } }
-
更新
book-list.component.html
以派发添加(add)事件。1 2 3 4 5 6 7 8 9 10
<div class="book-item" *ngFor="let book of books" > <p></p><span> by </span> <button (click)="add.emit(book.id)" data-test="add-button" >Add to Collection</button> </div>
-
更新
book-list.component.ts
1 2 3 4 5 6 7 8 9 10 11 12 13
import { Component, EventEmitter, Input, Output } from '@angular/core'; import { Book } from './books.model'; @Component({ selector: 'app-book-list', standalone: false, templateUrl: './book-list.component.html', styleUrl: './book-list.component.css' }) export class BookListComponent { @Input() books: ReadonlyArray<Book> = []; @Output() add = new EventEmitter<string>(); }
-
创建BookCollection组件
1
ng g c BookCollection
-
更新book-collection.component.html
1 2 3 4 5 6 7 8 9 10
<div class="book-item" *ngFor="let book of books" > <p></p><span> by </span> <button (click)="remove.emit(book.id)" data-test="remove-button" >Remove from Collection</button> </div>
-
更新book-collection.component.ts
1 2 3 4 5 6 7 8 9 10 11 12 13
import { Component, EventEmitter, Input, Output } from '@angular/core'; import { Book } from '../book-list/books.model'; @Component({ selector: 'app-book-collection', standalone: false, templateUrl: './book-collection.component.html', styleUrl: './book-collection.component.css' }) export class BookCollectionComponent { @Input() books: ReadonlyArray<Book> = []; @Output() remove = new EventEmitter<string>(); }
-
将 BookListComponent 和 BookCollectionComponent 添加到 AppComponent 的模板中,并在 app.module.ts 中的 declarations 中进行声明
1 2 3 4 5 6
<h2>Books</h2> <app-book-list class="book-list" [books]="(books$ | async)!" (add)="onAdd($event)"></app-book-list> <h2>My Collection</h2> <app-book-collection class="book-collection" [books]="(bookCollection$ | async)!" (remove)="onRemove($event)"> </app-book-collection>
-
在 AppComponent 类中,添加选择器selector以及在调用添加或移除方法时需要派发的对应 action。然后订阅 Google Books API,以便更新状态。(这部分通常应该由 NgRx Effects 处理)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
import { Component, OnInit } from '@angular/core'; import { Store } from '@ngrx/store'; import { selectBookCollection, selectBooks } from './state/books.selectors'; import { BooksActions, BooksApiActions } from './state/books.actions'; import { BookService } from './book-list/book.service'; @Component({ selector: 'app-root', templateUrl: './app.component.html', standalone: false, styleUrl: './app.component.css' }) export class AppComponent implements OnInit { books$: any; bookCollection$: any; constructor(private booksService: BookService, private store: Store) {} ngOnInit() { this.books$ = this.store.select(selectBooks); this.bookCollection$ = this.store.select(selectBookCollection); this.booksService .getBooks() .subscribe((books) => this.store.dispatch(BooksApiActions.retrievedBookList({ books })) ); } onAdd(bookId: string) { this.store.dispatch(BooksActions.addBook({ bookId })); } onRemove(bookId: string) { this.store.dispatch(BooksActions.removeBook({ bookId })); } }
Demo 3 Undo,Redo
Reference
NgRx
Angular与NgRx状态管理: 最佳实践解析
Level Up Your NgRx Skills With 10 Time-Tested Best Practices
NgRx — Best Practices for Enterprise Angular Applications
Simplify State Management with NgRx in Angular | NgRx Guide
Angular Ngrx Undo Redo Demo