这一篇继续整理 NgRx 里更常用的进阶内容。
默认你已经看过前一篇基础篇,至少对 Action、Reducer、Selector、Effect 这几个核心概念有基本认识。
基础篇解决的是“NgRx 到底在做什么”,这一篇更偏向“项目里通常会怎么用”。
很多内容单独看都不难,难的是把它们放回真实项目里理解。
这一篇就按项目里最常见的四块内容来整理:
- Store
- Selectors
- Effects
- Entity
Store
NgRx Store 是整个状态管理体系的中心。
它负责统一保存应用状态,并规定状态只能通过明确的流程发生变化。
可以把 Store 理解成:
应用中一个统一的数据仓库。
当组件越来越多、页面之间共享的数据越来越复杂时,把状态集中放进 Store,会比各个组件自己维护更容易追踪,也更容易维护。
Store 更适合放这类状态:
- 多个组件共享的数据
- 页面切换后仍要保留的数据
- 需要从后端加载的数据
- 会被多个动作共同修改的数据
- 希望能够统一追踪变化过程的数据
如果只是组件内部的临时 UI 状态,比如:
- 弹窗是否打开
- 输入框当前内容
- hover 状态
- 一个局部 tab 的切换状态
通常没必要放进 Store。
初始化 Store
先看怎么初始化一个 Store:
1
ng generate store State --root --state-path store --module app.module.ts --state-interface AppState
常见参数含义:
--root:创建根 Store--state-path:指定状态文件目录--module:注册到哪个模块--state-interface:指定全局状态接口名称
生成后通常会有类似结构。
app.module.ts
1
2
3
4
5
6
7
@NgModule({
imports: [
StoreModule.forRoot(reducers, { metaReducers }),
StoreDevtoolsModule.instrument()
],
})
export class AppModule {}
src/app/store/index.ts
1
2
3
4
5
6
7
import { ActionReducerMap } from '@ngrx/store';
export interface AppState {
}
export const reducers: ActionReducerMap<AppState> = {
};
这里的核心思想其实很直接:
AppState描述整个应用状态长什么样reducers决定每一块状态交给哪个 reducer 管理
MetaReducer
初始化完 Store 之后,接下来可以看一个经常在项目里碰到的概念:MetaReducer。
MetaReducer 可以理解成:
在 Action 到 Reducer 之间再加一层统一处理逻辑。
它常见的用途有:
- 打日志
- 统一错误处理
- 清空状态
- 状态持久化
- 仅开发环境下输出调试信息
MetaReducer 本质上是一个函数。
它接收一个 reducer,再返回一个新的 reducer。
例如一个简单的日志 MetaReducer:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { ActionReducer, MetaReducer } from '@ngrx/store';
import { isDevMode } from '@angular/core';
export function logger(reducer: ActionReducer<AppState>): ActionReducer<AppState> {
return function(state, action) {
const nextState = reducer(state, action);
console.log('last state:', state);
console.log('action:', action);
console.log('next state:', nextState);
return nextState;
};
}
export const metaReducers: MetaReducer<AppState>[] =
isDevMode() ? [logger] : [];
这样每次 dispatch action 时,都能看到:
- 旧状态
- 当前 action
- 新状态
对学习和排查问题都很有帮助。
root state 与 feature state
再往下就是 Store 的组织方式。
在 NgRx 中,整个应用状态本质上是一个大对象,这个大对象通常会被拆成两类状态:
- root state:应用启动时就存在的全局状态
- feature state:某个功能模块单独挂载的状态
1. root state
通过 StoreModule.forRoot() 注册的是根状态:
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 {}
这表示应用启动后,Store 中立刻有一块 game 状态。
例如:
1
2
3
4
5
6
{
game: {
home: 0,
away: 0
}
}
适合放在 root state 的,一般是:
- 应用启动就要用的数据
- 全局共享的数据
- 整个应用生命周期都常驻的数据
2. feature state
通过 StoreModule.forFeature() 注册的是特性状态。
例如先让根 Store 为空:
1
2
3
4
5
6
@NgModule({
imports: [
StoreModule.forRoot({})
],
})
export class AppModule {}
在某个功能模块中注册自己的状态:
scoreboard.reducer.ts
1
export const scoreboardFeatureKey = 'game';
scoreboard.module.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
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 {}
再把这个模块引入到 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 {}
这样最终 Store 就会扩展出:
1
2
3
4
5
6
{
game: {
home: 0,
away: 0
}
}
root state 与 feature state 的区别
可以简单理解为:
forRoot():定义应用级别的基础状态forFeature():给某个具体业务模块挂载自己的状态
在中大型项目里,通常会把大多数业务状态拆成 feature state,这样结构会更清晰,也更便于维护。
Store 这一部分更偏向“状态怎么组织”。
接下来再看 Selectors,也就是“状态怎么读”。
Selectors
前面已经知道 Selector 是“从状态中取数据”的工具。
到了进阶场景里,它的价值会更明显。
它的作用不只是“取值”,还包括:
- 组合多个状态片段
- 生成派生数据
- 屏蔽状态结构细节
- 通过记忆化提升性能
- 统一组件的数据读取入口
创建基础 Selector
先看最基础的 selector 写法。
可以通过命令生成 selector:
1
ng g selector store/selectors/counter
示例:
1
2
3
4
5
6
7
8
9
10
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { counterFeatureKey, State } from '../reducers/counter.reducer';
export const selectFeatureCounter =
createFeatureSelector<State>(counterFeatureKey);
export const selectCount = createSelector(
selectFeatureCounter,
(state: State) => state.count
);
组件中使用:
1
2
3
4
5
6
7
8
9
10
11
12
import { select, Store } from '@ngrx/store';
import { Observable } from 'rxjs';
import { AppState } from './store';
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));
}
}
这样组件只关心自己要什么数据,不用关心这个数据在状态树的第几层。
组合多个状态
createSelector 可以接收多个 selector,一起生成新的派生数据。
例如状态中有:
- 当前选中的用户
selectedUser - 全部图书
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 | null;
allBooks: Book[];
}
export const selectSelectedUser = (state: AppState) => state.selectedUser;
export const selectAllBooks = (state: AppState) => state.allBooks;
export const selectVisibleBooks = createSelector(
selectSelectedUser,
selectAllBooks,
(selectedUser, allBooks) => {
if (!selectedUser) {
return allBooks;
}
return allBooks.filter(book => book.userId === selectedUser.id);
}
);
这样做有几个好处:
- 组件里不需要自己写过滤逻辑
- 业务规则集中在 selector 中
- 更容易复用
- 更容易测试
记忆化(Memoization)
createSelector 的一个很重要特性是:
它会缓存上一次计算结果。
如果输入没变,就直接返回上一次结果,而不会重新执行计算逻辑。
这就是 Memoization。
例如:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { createSelector } from '@ngrx/store';
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
);
使用时:
1
2
3
4
5
6
7
8
let state = { counter1: 3, counter2: 4 };
selectTotal(state); // 结果 7
selectTotal(state); // 直接复用缓存结果 7
state = { ...state, counter2: 5 };
selectTotal(state); // 重新计算,结果 8
它特别适合:
- 列表过滤
- 总数统计
- 聚合计算
- 昂贵的数据转换
释放缓存
Selector 的缓存会一直保留在内存中。
在少数情况下,如果你明确希望释放缓存,可以调用 release()。
1
2
selectTotal(state);
selectTotal.release();
这不是最常用的功能,不过了解它有助于理解 selector 的工作方式。
Selectors 与 CQRS
NgRx 很强调“读”和“写”的分离:
- Reducer 负责写入状态
- Selector 负责读取状态
这和 CQRS(Command Query Responsibility Segregation)的思想很接近:
- Command:写
- Query:读
所以可以简单记成:
Reducer 负责“怎么改”,Selector 负责“怎么读”。
如果说 Selector 解决的是“怎么把状态读出来”,那 Effects 解决的就是“异步逻辑放在哪里”。
Effects
如果说 Reducer 负责纯粹的状态变化,那 Effects 负责的就是:
- API 请求
- 延迟任务
- 路由跳转
- 打日志
- 访问浏览器对象
- 其他副作用
它的核心目标是:
把异步逻辑和副作用从组件中拆出去。
这样组件就可以更专注于展示数据和 dispatch action。
一个简单的 Effect 示例
先看一个最小的 effect 例子。
先在页面上加一个按钮:
1
<button (click)="delayAdd()">Delay add</button>
定义 action:
1
2
3
4
5
6
7
8
9
10
import { createActionGroup, emptyProps, props } from '@ngrx/store';
export const CounterActions = createActionGroup({
source: 'Counter',
events: {
'Increment': props<{ count: number }>(),
'Decrement': emptyProps(),
'Delay Add': emptyProps()
}
});
组件中 dispatch:
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
8
9
10
11
12
13
14
15
16
17
18
19
20
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { map, mergeMap, timer } from 'rxjs';
import { CounterActions } from '../actions/counter.actions';
@Injectable()
export class CounterEffects {
delayAdd$ = createEffect(() =>
this.actions$.pipe(
ofType(CounterActions.delayAdd),
mergeMap(() =>
timer(2000).pipe(
map(() => CounterActions.increment({ count: 10 }))
)
)
)
);
constructor(private actions$: Actions) {}
}
这个 effect 的流程就是:
- 监听
Delay Add - 等待 2 秒
- dispatch 一个
Increment({ count: 10 })
Effects 与组件内副作用的对比
很多 Angular 项目里,组件会直接注入 service 并发请求。
这样当然也能工作,但项目一大,组件就很容易变重。
例如组件可能同时负责:
- 发请求
- 处理 loading
- 处理错误
- 更新列表
- 写订阅逻辑
这时组件的职责就会越来越混乱。
Effects 的思路是:
- 组件只 dispatch action
- Effect 处理异步逻辑
- Reducer 更新状态
- Selector 把结果提供给组件
例如电影列表示例中,组件只表达“我要加载电影”:
1
2
3
ngOnInit() {
this.store.dispatch(loadMovies());
}
真正请求 API 的逻辑放在 effect 中:
1
2
3
4
5
6
7
8
9
10
11
loadMovies$ = createEffect(() =>
this.actions$.pipe(
ofType(loadMovies),
exhaustMap(() =>
this.moviesService.getAll().pipe(
map(movies => moviesLoadedSuccess({ movies })),
catchError(() => EMPTY)
)
)
)
);
这样组件会轻很多,也更容易测试。
注册方式
1. NgModule 方式
在传统模块化 Angular 中:
1
EffectsModule.forRoot([CounterEffects])
或者:
1
EffectsModule.forFeature([FeatureEffects])
2. Standalone 方式
如果项目使用 standalone API:
1
2
3
4
5
6
7
import { provideEffects } from '@ngrx/effects';
bootstrapApplication(AppComponent, {
providers: [
provideEffects([MoviesEffects])
]
});
feature 级别也可以在路由里注册:
1
2
3
4
5
6
7
8
9
10
11
import { Route } from '@angular/router';
import { provideEffects } from '@ngrx/effects';
export const routes: Route[] = [
{
path: '',
providers: [
provideEffects([MoviesEffects])
]
}
];
在 Action 中携带数据
有时候 effect 不只是关心 action 类型,还需要额外参数。
这时就可以通过 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 }>()
);
effect 中使用:
1
2
3
4
5
6
7
8
9
10
login$ = createEffect(() =>
this.actions$.pipe(
ofType(login),
exhaustMap(action =>
this.authService.login(action.credentials).pipe(
map(user => loginSuccess({ user }))
)
)
)
);
从 Store 中读取额外状态
有时 effect 处理逻辑还需要结合当前 Store 状态。
这时可以配合 concatLatestFrom 使用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { concatLatestFrom } from '@ngrx/operators';
addBookToCollectionSuccess$ = createEffect(
() =>
this.actions$.pipe(
ofType(CollectionApiActions.addBookSuccess),
concatLatestFrom(() =>
this.store.select(fromBooks.getCollectionBookIds)
),
tap(([_action, bookCollection]) => {
if (bookCollection.length === 1) {
window.alert('恭喜你添加了第一本书!');
} else {
window.alert('你已添加第 ' + bookCollection.length + ' 本书');
}
})
),
{ dispatch: false }
);
如果 effect 只是做副作用,不再 dispatch 新 action,就加上:
1
{ dispatch: false }
不依赖 Action 的 Effect
虽然大多数 effect 都是监听 action,但本质上 effect 也是 Observable 流。
所以它也可以监听别的可观察源。
例如监听页面点击:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { Injectable } from '@angular/core';
import { createEffect } from '@ngrx/effects';
import { fromEvent } from 'rxjs';
import { tap } from 'rxjs/operators';
@Injectable()
export class UserActivityEffects {
trackUserActivity$ = createEffect(
() =>
fromEvent(document, 'click').pipe(
tap(event => this.analyticsService.track(event))
),
{ dispatch: false }
);
constructor(private analyticsService: AnalyticsService) {}
}
这类写法不算最常见,但它能帮助理解:
Effect 本质上是“响应流中的事件并执行副作用”的地方,不一定只能响应 action。
Effects 解决的是副作用的问题,Entity 解决的则是另一类很常见的问题:列表型数据怎么管理得更顺手。
Entity
在 NgRx 里,当你需要管理一组同类型数据时,比如:
- 用户列表
- 商品列表
- 文章列表
- 评论列表
如果完全手写 state、reducer、selector,通常会出现很多重复代码。
例如你可能要自己处理:
- 列表新增
- 列表更新
- 列表删除
- 按 id 查找单条数据
- 维护有序数组
- 避免重复项
这时候就可以使用 NgRx Entity。
先用一句话概括:
NgRx Entity 是一套专门用来管理“集合型状态”的工具。
它帮你把“列表数据的增删改查”这类高频操作标准化了。
为什么需要 Entity?
先看一个很常见的列表状态写法:
1
2
3
4
export interface UserState {
users: User[];
selectedUserId: number | null;
}
这种写法当然可以用,但数据量一大、操作一多,就会遇到一些问题:
1. 按 id 查找效率不高
如果你要找 id 为 3 的用户,通常要写:
1
const user = state.users.find(user => user.id === 3);
每次都要遍历数组。
2. 更新某一项代码比较繁琐
比如更新某个用户名称,往往要写一堆 map:
1
2
3
users: state.users.map(user =>
user.id === updatedUser.id ? updatedUser : user
)
3. 很多集合操作逻辑重复
不同模块里都在重复写:
- add one
- add many
- update one
- remove one
- remove many
而这些逻辑其实都是套路化的。
Entity 的核心思想
NgRx Entity 推荐把集合状态整理成下面这种结构:
1
2
3
4
5
6
7
8
{
ids: [1, 2, 3],
entities: {
1: { id: 1, name: 'Tom' },
2: { id: 2, name: 'Jerry' },
3: { id: 3, name: 'Alice' }
}
}
这是一种“字典 + id 数组”的结构:
ids:保存顺序entities:按 id 存放实体对象,方便快速查找
这样设计有两个直接好处:
1. 查找快
1
const user = state.entities[2];
不需要遍历数组。
2. 增删改统一
EntityAdapter 已经把常见操作封装好了。
Entity 中几个最核心的概念
1. EntityState
EntityState<T> 是 NgRx Entity 提供的标准集合状态接口。
例如:
1
2
3
4
5
6
7
8
9
10
11
import { EntityState } from '@ngrx/entity';
export interface User {
id: number;
name: string;
}
export interface UserState extends EntityState<User> {
selectedUserId: number | null;
loading: boolean;
}
这里的意思是:
UserState继承了 Entity 的标准结构- 同时还能额外加自己的业务字段
所以最终 state 不只是 ids 和 entities,还可以有:
selectedUserIdloadingerrorloaded
这里有个很重要的点:
Entity 只是帮你管理集合结构,不会限制你扩展自己的业务状态。
2. EntityAdapter
EntityAdapter 可以理解成一个“集合操作工具箱”。
它会帮你生成很多常用方法,比如:
addOneaddManysetAllupdateOneupdateManyremoveOneremoveManyremoveAllupsertOneupsertMany
你不需要每次都自己手写数组操作。
3. getInitialState
它用于生成初始状态。
例如:
1
2
3
4
const initialState: UserState = adapter.getInitialState({
selectedUserId: null,
loading: false
});
它会自动补上:
ids: []entities: {}
再加上你自己的额外字段。
一个完整的 Entity 示例
下面用“文章列表”做一个最小示例。
假设每篇文章长这样:
1
2
3
4
5
export interface Article {
id: number;
title: string;
content: string;
}
1. 定义 State 与 Adapter
创建 article.reducer.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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
import { createReducer, on } from '@ngrx/store';
import {
EntityState,
EntityAdapter,
createEntityAdapter
} from '@ngrx/entity';
import * as ArticleActions from './article.actions';
export interface Article {
id: number;
title: string;
content: string;
}
export interface ArticleState extends EntityState<Article> {
selectedArticleId: number | null;
loading: boolean;
}
export const adapter: EntityAdapter<Article> =
createEntityAdapter<Article>();
export const initialState: ArticleState = adapter.getInitialState({
selectedArticleId: null,
loading: false
});
export const articleReducer = createReducer(
initialState,
on(ArticleActions.loadArticlesSuccess, (state, { articles }) =>
adapter.setAll(articles, {
...state,
loading: false
})
),
on(ArticleActions.addArticleSuccess, (state, { article }) =>
adapter.addOne(article, state)
),
on(ArticleActions.updateArticleSuccess, (state, { update }) =>
adapter.updateOne(update, state)
),
on(ArticleActions.deleteArticleSuccess, (state, { id }) =>
adapter.removeOne(id, state)
),
on(ArticleActions.selectArticle, (state, { id }) => ({
...state,
selectedArticleId: id
}))
);
这个 reducer 的重点不在语法,而是要看出 Entity 带来的简化:
- 加一篇文章:
adapter.addOne - 批量设置文章:
adapter.setAll - 更新一篇文章:
adapter.updateOne - 删除一篇文章:
adapter.removeOne
相比自己手写数组处理,代码会整洁很多。
2. 定义 Actions
article.actions.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 { createAction, props } from '@ngrx/store';
import { Update } from '@ngrx/entity';
import { Article } from './article.reducer';
export const loadArticlesSuccess = createAction(
'[Article API] Load Articles Success',
props<{ articles: Article[] }>()
);
export const addArticleSuccess = createAction(
'[Article API] Add Article Success',
props<{ article: Article }>()
);
export const updateArticleSuccess = createAction(
'[Article API] Update Article Success',
props<{ update: Update<Article> }>()
);
export const deleteArticleSuccess = createAction(
'[Article API] Delete Article Success',
props<{ id: number }>()
);
export const selectArticle = createAction(
'[Article Page] Select Article',
props<{ id: number }>()
);
这里有个值得注意的地方:
更新时使用的是 Update<T>:
1
2
3
4
5
6
{
id: 1,
changes: {
title: '新标题'
}
}
这表示:
- 哪条数据要更新
- 具体改哪些字段
这比直接传整个对象更灵活。
3. 使用 Adapter 自动生成 Selectors
NgRx Entity 很方便的一点是:
它可以直接帮你生成一组常用 selector。
article.selectors.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
29
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { adapter, ArticleState } from './article.reducer';
export const selectArticleState =
createFeatureSelector<ArticleState>('articles');
const {
selectIds,
selectEntities,
selectAll,
selectTotal
} = adapter.getSelectors(selectArticleState);
export const selectArticleIds = selectIds;
export const selectArticleEntities = selectEntities;
export const selectAllArticles = selectAll;
export const selectArticleTotal = selectTotal;
export const selectSelectedArticleId = createSelector(
selectArticleState,
state => state.selectedArticleId
);
export const selectCurrentArticle = createSelector(
selectArticleEntities,
selectSelectedArticleId,
(entities, selectedId) =>
selectedId !== null ? entities[selectedId] ?? null : null
);
这里自动得到的几个 selector 很常用:
selectIds:所有 idselectEntities:实体字典selectAll:数组形式的全部数据selectTotal:总数
这意味着:
state 内部虽然是
ids + entities结构,但组件依然可以很方便地拿到普通数组。
所以不用太担心 Entity 会让组件使用起来变复杂。
4. 组件中如何使用
例如:
1
2
3
4
5
6
7
8
9
10
11
export class ArticleListComponent {
articles$ = this.store.select(selectAllArticles);
total$ = this.store.select(selectArticleTotal);
currentArticle$ = this.store.select(selectCurrentArticle);
constructor(private store: Store) {}
selectArticle(id: number) {
this.store.dispatch(selectArticle({ id }));
}
}
组件并不需要知道底层是 entities 还是数组。
它只通过 selector 拿自己想要的数据。
这也是 NgRx 的一个核心思路:
组件只消费数据,不关心状态内部实现细节。
EntityAdapter 常见方法整理
下面把常用方法简单过一遍。
1. addOne
新增一条数据:
1
adapter.addOne(article, state)
2. addMany
新增多条数据:
1
adapter.addMany(articles, state)
3. setAll
用新数据整体替换旧集合:
1
adapter.setAll(articles, state)
适合“重新加载整个列表”的场景。
4. updateOne
更新单条数据:
1
2
3
4
5
6
7
adapter.updateOne(
{
id: 1,
changes: { title: '新标题' }
},
state
)
5. upsertOne
如果存在就更新,不存在就新增:
1
adapter.upsertOne(article, state)
6. removeOne
删除单条数据:
1
adapter.removeOne(id, state)
7. removeAll
清空整个集合:
1
adapter.removeAll(state)
自定义主键 selectId
默认情况下,Entity 会把 id 当作主键。
但有些数据的唯一标识不是 id,比如是 uuid 或 code。
这时可以在创建 adapter 时指定:
1
2
3
4
5
6
7
8
export interface Product {
code: string;
name: string;
}
export const productAdapter = createEntityAdapter<Product>({
selectId: (product) => product.code
});
这样 Entity 就会把 code 作为唯一标识。
排序 sortComparer
有些时候你希望集合始终保持某种顺序,比如按名称排序、按时间排序。
这时可以使用 sortComparer。
1
2
3
export const articleAdapter = createEntityAdapter<Article>({
sortComparer: (a, b) => a.title.localeCompare(b.title)
});
这样 selectAll 返回的数据就会按 title 排序。
再比如按创建时间倒序:
1
sortComparer: (a, b) => b.createdAt - a.createdAt
所以:
entities负责快速查找ids负责顺序维护sortComparer负责定义顺序规则
Entity 特别适合哪些场景?
Entity 特别适合下面这些情况:
1. 管理列表型数据
例如:
- 用户列表
- 商品列表
- 订单列表
- 评论列表
2. 经常根据 id 查单条数据
比如详情页、编辑页、选中项场景。
3. 需要频繁增删改
如果这个集合操作很多,Entity 能明显减少样板代码。
4. 需要统一列表状态结构
大型项目里,不同模块都用类似的集合结构,会更规范。
Entity 不一定适合哪些场景?
虽然 Entity 很方便,但也不是所有状态都要用它。
例如这些情况,可能没必要:
1. 非集合型状态
比如:
- 当前主题色
- 登录状态
- loading 开关
- 表单草稿
2. 很简单、一次性的小数组
如果只是一个很短的小列表,而且几乎不会修改,用普通数组就够了。
3. 明显是树形或高度嵌套结构
Entity 更擅长“扁平集合”。
如果是复杂树结构,可能需要先做 normalize,或者用更合适的建模方式。
Entity 与普通数组 state 的对比
普通数组写法
1
2
3
interface State {
articles: Article[];
}
优点:
- 直观
- 上手简单
缺点:
- 按 id 查找不方便
- 更新/删除要手写数组逻辑
- 重复代码多
Entity 写法
1
2
3
interface State extends EntityState<Article> {
selectedArticleId: number | null;
}
优点:
- 标准化
- 查找高效
- 增删改代码少
- selector 可复用性强
缺点:
- 初学时会觉得比数组多一层抽象
- 需要理解
ids + entities结构
所以也可以这样记:
如果只是简单列表,数组就够。
如果是“真正需要管理”的集合,Entity 往往更合适。
Entity 和后端 API 的配合
在真实项目里,一个典型流程通常是:
- 组件 dispatch
loadArticles - Effect 调 API
- API 返回文章数组
- dispatch
loadArticlesSuccess({ articles }) - reducer 中用
adapter.setAll(articles, state)
例如:
1
2
3
4
5
6
7
8
9
10
loadArticles$ = createEffect(() =>
this.actions$.pipe(
ofType(loadArticles),
exhaustMap(() =>
this.articleService.getAll().pipe(
map(articles => loadArticlesSuccess({ articles }))
)
)
)
);
然后 reducer:
1
2
3
4
5
6
on(loadArticlesSuccess, (state, { articles }) =>
adapter.setAll(articles, {
...state,
loading: false
})
)
这样整个列表管理流程会非常清晰:
- Effect 负责拿数据
- Entity 负责存集合
- Selector 负责读集合
一句话理解 Entity
如果看了很多代码还是有点绕,可以先只记住这句话:
NgRx Entity = 用标准方式管理“按 id 组织的列表数据”。
它做的事情本质上就是:
- 帮你规范 state 结构
- 帮你减少 reducer 样板代码
- 帮你快速生成常用 selector
- 让列表数据管理更统一
Entity 小结
Entity 里最重要的几个点可以总结为:
EntityState<T>:标准集合状态结构createEntityAdapter():创建集合适配器adapter.getInitialState():生成初始状态adapter.addOne / updateOne / removeOne / setAll:简化 reduceradapter.getSelectors():快速生成 selector
如果你项目里有大量“列表型业务状态”,那 Entity 基本是非常值得掌握的。
小结
到这里,NgRx 里几个常见的进阶主题基本都过了一遍:
- Store
- MetaReducer
- root state / feature state
- Selectors
- Effects
- Entity
如果把最开始的 Counter 示例看作“入门骨架”,那这些进阶内容就是把骨架往真实项目的方向继续搭起来。
最后再用一句话把它们串起来:
- Store:统一保存状态
- Reducer:根据 Action 计算新状态
- Selector:从状态中读取数据
- Effect:处理异步和副作用
- Entity:更高效地管理列表型数据
Demo 3:Undo / Redo
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