使用 RxJS 在 Angular 中进行简单而强大的状态管理
TLDR让我们仅使用 RxJS/BehaviorSubject 创建我们自己的状态管理类(受到一些众所周知的状态管理库的启发)。
使用 RxJS BehaviorSubject 管理状态
Angular 中有很多优秀的状态管理库,例如 NgRx、Akita 或 NgXs。它们都有一个共同点:它们基于 RxJS Observable,并且状态存储在一种特殊的 Observable 中:BehaviorSubject。
为什么选择 RxJS Observables?
- 可观察对象是 Angular 中的“一等公民”。Angular 的许多核心功能都由 RxJS 实现(例如 HttpClient、Forms、Router 等等)。使用可观察对象管理状态可以与 Angular 生态系统的其他部分完美集成。
- 使用 Observable 对象可以轻松地将状态变化通知给组件。组件可以订阅保存状态的 Observable 对象。这些“State” Observable 对象会在状态发生变化时发出新的值。
BehaviorSubject 有什么特别之处?
- BehaviorSubject 将其最后发出的值发送给新的/迟到的订阅者
- 它有一个初始值
- 可以通过
getValue
方法访问其当前值 next
可以使用方法发出新值- BehaviorSubject 是多播的:它内部保存着所有订阅者的列表。所有订阅者共享同一个 Observable 执行。当 BehaviorSubject 发出新值时,相同的值会被推送给所有订阅者。
使用 BehaviorSubject 进行我们自己的状态管理
因此,如果所有大型状态管理库都使用 RxJS BehaviorSubject,并且 Angular 开箱即用 RxJS......我们可以仅使用 Angular Services 和 BehaviorSubject 创建自己的状态管理吗?
让我们创建一个简单但功能强大的状态管理类,可以通过 Angular 服务进行扩展。
主要目标是:
- 能够定义状态接口并设置初始状态
- 直接使用 API 来更新状态和选择状态:
setState
,select
- 选中状态应以 Observable 形式返回。选中状态发生变化时,Observable 会发出相应信号。
- 能够
ChangeDetectionStrategy.OnPush
在我们的组件中使用以获得更好的性能(在此处阅读有关 OnPush 的更多信息:“Angular onPush 变化检测策略综合指南”)。
解决方案:
import { BehaviorSubject, Observable } from 'rxjs';
import { distinctUntilChanged, map } from 'rxjs/operators';
export class StateService<T> {
private state$: BehaviorSubject<T>;
protected get state(): T {
return this.state$.getValue();
}
constructor(initialState: T) {
this.state$ = new BehaviorSubject<T>(initialState);
}
protected select<K>(mapFn: (state: T) => K): Observable<K> {
return this.state$.asObservable().pipe(
map((state: T) => mapFn(state)),
distinctUntilChanged()
);
}
protected setState(newState: Partial<T>) {
this.state$.next({
...this.state,
...newState,
});
}
}
让我们仔细看看上面的代码:
- StateService 需要一个
T
表示状态接口的泛型类型。此类型在扩展 StateService 时传递。 get state()
返回当前状态快照- 构造函数采用初始状态并初始化 BehaviorSubject。
select
state$
接受一个回调函数。该函数在发出新状态时被调用。在 RxJS 中map
,回调函数将返回一个状态片段。distinctUntilChanged
将跳过状态的发出,直到所选状态片段拥有新的值/对象引用。this.state$.asObservable()
确保该select
方法返回一个 Observable(而不是AnonymousSubject
)。setState
接受一个部分类型。这允许我们偷懒,只传递更大状态接口的部分属性。在state$.next
方法内部,部分状态会与完整状态对象合并。最终,BehaviorSubjectthis.state$
会发出一个全新的状态对象。
用法
必须管理某些状态的 Angular 服务可以简单地扩展 StateService 来选择和更新状态。
世界上只有一件事需要管理:TODOS!:) 让我们创建一个 TodosStateService。
interface TodoState {
todos: Todo[];
selectedTodoId: number;
}
const initialState: TodoState = {
todos: [],
selectedTodoId: undefined
};
@Injectable({
providedIn: 'root'
})
export class TodosStateService extends StateService<TodoState>{
todos$: Observable<Todo[]> = this.select(state => state.todos);
selectedTodo$: Observable<Todo> = this.select((state) => {
return state.todos.find((item) => item.id === state.selectedTodoId);
});
constructor() {
super(initialState);
}
addTodo(todo: Todo) {
this.setState({todos: [...this.state.todos, todo]})
}
selectTodo(todo: Todo) {
this.setState({ selectedTodoId: todo.id });
}
}
我们来看一下 TodosStateService 代码:
- TodosStateService 扩展
StateService
并传递状态接口TodoState
- 构造函数需要调用
super()
并传递初始状态 - 公共的可观察对象
todos$
,并将selectedTodo$
相应的状态数据暴露给感兴趣的消费者,如组件或其他服务 - 公共方法
addTodo
并selectTodo
公开公共 API 来更新状态。
与组件和后端 API 的交互
让我们看看如何将 TodosStateService 与 Angular 组件和后端 API 集成:
- 组件调用 TodosStateService 的公共方法来更新状态
- 对状态感兴趣的组件只需订阅 TodosStateService 公开的相应公共 Observable。
- API 调用与状态密切相关。API 响应通常会直接更新状态。因此,API 调用由 TodosStateService 触发。API 调用完成后,可以使用以下方式立即更新状态:
setState
演示
查看使用 TodosStateService 的完整 TODOs 应用程序:
Stackblitz - Angular State Manager
笔记
不可变数据
为了在组件中受益,ChangeDetectionStrategy.OnPush
我们必须确保不改变状态。
我们有责任始终将新对象传递给setState
方法。如果我们要更新包含对象/数组的嵌套属性,那么我们也必须分配一个新的对象/数组。
有关不可变状态更新的更多示例,请参阅完整的TodosStateService(在 Stackblitz 上) 。
仅供参考,
有一些库可以帮助您保持状态数据不变:
Immer
ImmutableJS
具有双向数据绑定的模板驱动表单
关于不可变数据……在将状态推送到表单输入使用 的模板驱动表单时,我们必须小心[(ngModel)]
。当用户更改表单输入值时,状态对象将直接变异……
但我们希望保持不变,并且仅使用 显式更改状态setState
。因此,使用响应式表单是更好的选择。如果必须使用模板驱动表单,那么仍然有一个很好的折衷方案:单向数据绑定[ngModel]
。另一个选择是(深度)克隆表单数据……在这种情况下,您仍然可以使用[(ngModel)]
。
async
订阅管道
大多数情况下,组件应该使用模板中的管道订阅“State”Observable async
。异步管道会为我们订阅,并在组件销毁时自动处理取消订阅。
异步管道还有一个好处:
当组件使用 OnPush 变更检测策略时,它们只会在以下情况下自动更新其视图:
- 如果
@Input
收到一个新的值/对象引用 - 如果组件或其子组件之一触发了 DOM 事件
在某些情况下,组件既没有 DOM 事件,也没有发生更改的 @Input。如果该组件订阅的状态在组件类内部发生变化,那么 Angular 变更检测将无法知道在观察到的状态发出后,视图需要更新。
您可以使用 来修复它ChangeDetectorRef.markForCheck()
。它告诉 ChangeDetector 无论如何都要检查状态变化(在当前或下一个变更检测周期中),并在必要时更新视图。
@Component({
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TodoShellComponent {
todos: Todo[];
constructor(
private todosState: TodosStateService,
private cdr: ChangeDetectorRef
) {
this.todosState.todos$.subscribe(todos => {
this.todos = todos;
this.cdr.markForCheck(); // Fix View not updating
});
}
}
但我们也可以使用async
模板中的管道。它会调用ChangeDetectorRef.markForCheck
我们。请参阅 Angular 源代码:async_pipe
更短更漂亮:
<todo-list [todos]="todos$ | async"></todo-list>
异步管道功能强大,订阅、取消订阅、markForCheck 等功能应有尽有。让我们尽可能地利用它。
在演示中查看异步管道的实际运行:todo-shell.component.html
select
回调经常被调用
我们应该意识到,传递给该select
方法的回调函数需要在每次调用时执行setState
。
因此,select 回调函数不应该包含繁重的计算。
多播已消失
如果该方法返回的 Observable 有多个订阅者,select
那么我们会看到一些有趣的现象:BehaviorSubject 的多播功能消失了……select
当状态发生变化时,传递给该方法的回调函数会被多次调用。Observable 会针对每个订阅者执行。
这是因为我们使用 将 BehaviorSubject 转换为 Observable。Observablethis.state$.asObservable()
不支持多播。
幸运的是,RxJS 提供了一个(多播)操作符来使 Observable 进行多播:shareReplay
。
我建议只在需要的地方使用 shareReplay 操作符。假设 Observable 有多个订阅者todos$
,那么我们可以像这样进行多播:
todos$: Observable<Todo[]> = this.select(state => state.todos).pipe(
shareReplay({refCount: true, bufferSize: 1})
);
使用它refCount: true
可以避免内存泄漏,这一点很重要。bufferSize: 1
它将确保延迟订阅者仍然能够获得最后发出的值。
在此处阅读有关多播运算符的更多信息:RXJS 共享运算符的魔力及其差异
外观模式
还有一件好事。状态管理服务促进了外观模式:select
和setState
是受保护的函数。因此它们只能在 内部调用TodosStateService
。这有助于保持组件的精简和整洁,因为它们无法直接使用setState
/select
方法(例如在注入的 TodosStateService 中)。状态实现细节保留在 TodosStateService 内部。
外观模式使得将 TodosStateService 重构为其他状态管理解决方案(例如 NgRx)变得很容易——如果你愿意的话 :)
谢谢
特别感谢您审阅此博客文章:
对我有所启发的文章:
- 仅使用 Services 和 RxJS 在 Angular 中进行简单的状态管理,作者:Aslan Vatsaev
- 非常相似的方法:Ben Nadel在Angular 6.1.10 中使用 RxJS BehaviorSubject 创建一个简单的 setState() 存储
MiniRx
我将自己在响应式状态管理方面的经验融入到这个非常酷的库中:MiniRx Store。
你可以轻松地将本文中的 StateService 重构为 MiniRx Feature Store。MiniRx Feature Store也支持setState
和select
,但你还可以获得额外的好处,例如 Redux DevTools 支持、撤销、强制不变性、效果、记忆化选择器等等。