
有时,最简单的功能实现最终会带来弊大于利的问题,只会增加其他地方的复杂性。 最终结果是一个没人希望接触的锯齿状架构。
翻译笔记这篇文章写于2017年,但与今天有关。 它面向有RxJS和Ngrx经验的人员,或者想在Angular中尝试Redux的人员。
这些代码片段是根据当前的RxJS语法进行更新的,并进行了少许修改以提高可读性和易懂性。
Ngrx / store是一个Angular库,有助于包含单个函数的复杂性。 原因之一是ngrx / store包含函数式编程,这限制了可以在函数内部执行的操作,以在函数外部实现更多的合理性。 在ngrx / store中,诸如reducers(以下称为reducers),选择器(以下称为Selector)和RxJS运算符之类的东西都是纯函数。
纯函数更易于测试,调试,分析,并行化和组合。 在以下情况下,函数是干净的:
- 使用相同的输入,它总是返回相同的输出;
- 没有副作用。
副作用是无法避免的,但是它们被隔离在ngrx / store中,因此应用程序的其余部分可能由纯函数组成。
副作用
当用户提交表单时,我们需要在服务器上进行更改。 更改服务器并响应客户端是一个副作用。 可以在组件中处理:
this.store.dispatch({ type: 'SAVE_DATA', payload: data, }); this.saveData(data)
如果我们可以在用户提交表单并在其他地方处理副作用时,就可以在组件内部分派动作(以下简称为动作),那就太好了。
Ngrx / effect是用于处理ngrx / store中的副作用的中间件。 它在可观察线程中侦听提交的操作,执行副作用,并立即或异步返回新操作。 返回的动作将传递给减速器。
以RxJS方式处理副作用的能力使代码更整洁。 从组件发送初始动作SAVE_DATA
之后,您将创建一个效果类来处理其余的动作:
@Effect() saveData$ = this.actions$.pipe( ofType('SAVE_DATA'), pluck('payload'), switchMap(data => this.saveData(data)), map(res => ({ type: 'DATA_SAVED' })), );
这简化了组件的操作,仅在发送操作和订阅可观察的操作之前。
易于滥用Ngrx /效果
Ngrx / effects是一个非常强大的解决方案,因此很容易滥用。 这是Ngrx / effect简化的一些常见ngrx / store反模式:
1.重复状态
假设您正在某种多媒体应用程序上工作,并且在状态树中具有以下属性:
export interface State { mediaPlaying: boolean; audioPlaying: boolean; videoPlaying: boolean; }
由于音频是一种媒体,因此只要audioPlaying
为true, mediaPlaying
也应为true。 所以这是一个问题:“如何在audioPlaying更新时确保mediaPlaying更新?”
无效答案 :使用Ngrx /特效!
@Effect() playMediaWithAudio$ = this.actions$.pipe( ofType('PLAY_AUDIO'), map(() => ({ type: 'PLAY_MEDIA' })), );
正确的答案是 :如果mediaPlaying
的状态完全由状态树的另一部分预测,则这不是真实状态。 这是一个派生状态。 它属于选择器,而不是商店。
audioPlaying$ = this.store.select('audioPlaying'); videoPlaying$ = this.store.select('videoPlaying'); mediaPlaying$ = combineLatest(this.audioPlaying$, this.videoPlaying$).pipe( map(([audioPlaying, videoPlaying]) => audioPlaying || videoPlaying), );
现在我们的病情可以保持清洁和正常化 ,并且我们不会将Ngrx /特效用于非副作用。
2.与减速器的连锁动作
想象一下,您在状态树中具有以下属性:
export interface State { items: { [index: number]: Item }; favoriteItems: number[]; }
然后,用户删除该项目。 返回删除请求后,将发送DELETE_ITEM_SUCCESS
操作以更新应用程序的状态。 在items
缩减器中,单个Item
从items
对象中删除。 但是,如果此元素标识符位于favoriteItems
数组中,则它所引用的元素将不存在。 所以问题是,在发送DELETE_ITEM_SUCCESS
操作时,如何确保从favoriteItems
删除标识符?
无效答案 :使用Ngrx /特效!
@Effect() removeFavoriteItemId$ = this.actions$.pipe( ofType('DELETE_ITEM_SUCCESS'), map(() => ({ type: 'REMOVE_FAVORITE_ITEM_ID' })), );
因此,现在我们将有两个动作一个接一个地发送,两个减速器一个接一个地返回新状态。
正确答案 : DELETE_ITEM_SUCCESS
可以由items
reducer和favoriteItems
reducer进行处理。
export function favoriteItemsReducer(state = initialState, action: Action) { switch (action.type) { case 'REMOVE_FAVORITE_ITEM': case 'DELETE_ITEM_SUCCESS': const itemId = action.payload; return state.filter(id => id !== itemId); default: return state; } }
该行动的目标是将发生的情况与状态应如何变化分开。 发生了什么事DELETE_ITEM_SUCCESS
。 减速器的任务是引起状态的相应变化。
从favoriteItems
Item
中删除标识符并不是删除Item
副作用。 整个过程是完全同步的,可以由减速器处理。 不需要Ngrx /特效。
3.请求组件数据
您的组件需要从存储中获取数据,但是首先您需要从服务器获取数据。 问题是,如何将数据放入存储中,以便组件可以接收数据?
痛苦的方式 :使用Ngrx /特效!
在组件中,我们通过发送操作来发起请求:
ngOnInit() { this.store.dispatch({ type: 'GET_USERS' }); }
在效果类中,我们听GET_USERS
:
@Effect getUsers$ = this.actions$.pipe( ofType('GET_USERS'), withLatestFrom(this.userSelectors.needUsers$), filter(([action, needUsers]) => needUsers), switchMap(() => this.getUsers()), map(users => ({ type: 'RECEIVE_USERS', users })), );
现在,假设用户认为某条路线的加载时间太长,那么他将从一条路线切换到另一条路线。 为了提高效率并避免加载不必要的数据,我们希望取消此请求。 当组件被销毁时,我们将通过发送操作退订请求:
ngOnDestroy() { this.store.dispatch({ type: 'CANCEL_GET_USERS' }); }
现在,在effects类中,我们听两种动作:
@Effect getUsers$ = this.actions$.pipe( ofType('GET_USERS', 'CANCEL_GET_USERS'), withLatestFrom(this.userSelectors.needUsers$), filter(([action, needUsers]) => needUsers), map(([action, needUsers]) => action), switchMap( action => action.type === 'CANCEL_GET_USERS' ? of() : this.getUsers().pipe(map(users => ({ type: 'RECEIVE_USERS', users }))), ), );
好啊 现在,另一个开发人员添加了一个需要相同HTTP请求的组件(我们不会对其他组件做任何假设)。 组件在相同的位置发送相同的动作。 如果两个组件同时处于活动状态,则第一个组件将发起HTTP请求以对其进行初始化。 当第二个组件初始化时,不会发生任何多余的事情,因为needUsers
将为false
。 太好了!
然后,当第一个组件被破坏时,它将发送CANCEL_GET_USERS
。 但是第二个组件仍然需要此数据。 我们如何防止请求被取消? 也许我们将启动所有订阅者的计数器? 我不会实现这一点,但是我想您理解这一点。 我们开始怀疑有更好的方法来管理这些数据依赖项。
现在假设出现了另一个组件,它取决于在users
数据出现在存储中之前无法检索的数据。 这可能是用于聊天的Web套接字的连接,有关某些用户的其他信息或其他内容。 我们不知道在向users
订阅其他两个组件之前或之后将初始化此组件。
对于这种特殊情况,我发现的最佳帮助就是这篇很棒的文章 。 在他的示例中, callApiY
要求callApiX
已经完成。 我删除了评论以使其看起来不那么吓人,但请随时阅读原始文章以了解更多信息:
@Effect() actionX$ = this.actions$.pipe( ofType('ACTION_X'), map(toPayload), switchMap(payload => this.api.callApiX(payload).pipe( map(data => ({ type: 'ACTION_X_SUCCESS', payload: data })), catchError(err => of({ type: 'ACTION_X_FAIL', payload: err })), ), ), ); @Effect() actionY$ = this.actions$.pipe( ofType('ACTION_Y'), map(toPayload), withLatestFrom(this.store.select(state => state.someBoolean)), switchMap(([payload, someBoolean]) => { const callHttpY = v => { return this.api.callApiY(v).pipe( map(data => ({ type: 'ACTION_Y_SUCCESS', payload: data, })), catchError(err => of({ type: 'ACTION_Y_FAIL', payload: err, }), ), ); }; if (someBoolean) { return callHttpY(payload); } return of({ type: 'ACTION_X', payload }).merge( this.actions$.pipe( ofType('ACTION_X_SUCCESS', 'ACTION_X_FAIL'), first(), switchMap(action => { if (action.type === 'ACTION_X_FAIL') { return of({ type: 'ACTION_Y_FAIL', payload: 'Because ACTION_X failed.', }); } return callHttpY(payload); }), ), ); }), );
现在添加一个要求,即当组件不再需要HTTP请求时,应将其取消,这将变得更加复杂。
。 。 。
那么,当RxJS真正使它变得简单时,为什么数据依赖管理存在这么多问题?
尽管从技术上来说,来自服务器的数据是一个副作用,但在我看来,Ngrx /效果并不是处理此问题的最佳方法。
组件是用户输入/输出接口。 它们显示数据并发送他执行的动作。 加载组件时,它不会发送该用户执行的任何操作。 他想显示数据。 这更像是订阅,而不是副作用。
很多时候,您会看到使用动作来启动数据请求的应用程序。 这些应用程序实现了一个特殊的界面,可以通过副作用进行观察。 而且,正如我们所看到的,此界面可能会变得非常不便和麻烦。 订阅,退订和连接可观察的对象要容易得多。
。 。 。
减轻痛苦的方式 :组件将通过可观察的方式订阅数据,从而对数据感兴趣。
我们将创建可观察的对象,其中包含必要的HTTP请求。 我们将看到使用纯RxJS而不是通过效果来管理彼此依赖的多个订阅和查询链要容易得多。
在服务中创建以下可观察到的内容:
requireUsers$ = this.store.pipe( select(selectNeedUser), filter(needUsers => needUsers), tap(() => this.store.dispatch({ type: 'GET_USERS' })), switchMap(() => this.getUsers()), tap(users => this.store.dispatch({ type: 'RECEIVE_USERS', users })), finalize(() => this.store.dispatch({ type: 'CANCEL_GET_USERS' })), share(), ); users$ = muteFirst( this.requireUsers$.pipe(startWith(null)), this.store.pipe(select(selectUsers)), );
对users$
订阅将同时发送到requireUsers$
和this.store.pipe(select(selectUsers))
,但是仅从this.store.pipe(select(selectUsers))
接收数据( muteFirst
实现muteFirst
和固定muteFirst
经过她的测试 。)
在组件中:
ngOnInit() { this.users$ = this.userService.users$; }
由于现在可以轻松观察到这种数据依赖关系,因此我们可以使用async
管道在模板中进行订阅和退订,并且不再需要发送操作。 如果应用程序留下了最后一个为数据签名的组件的路由,则会取消HTTP请求或关闭Web套接字。
数据依赖链可以这样处理:
requireUsers$ = this.store.pipe( select(selectNeedUser), filter(needUsers => needUsers), tap(() => this.store.dispatch({ type: 'GET_USERS' })), switchMap(() => this.getUsers()), tap(users => this.store.dispatch({ type: 'RECEIVE_USERS', users })), share(), ); users$ = muteFirst( this.requireUsers$.pipe(startWith(null)), this.store.pipe(select(selectUsers)), ); requireUsersExtraData$ = this.users$.pipe( withLatestFrom(this.store.pipe(select(selectNeedUsersExtraData))), filter(([users, needData]) => Boolean(users.length) && needData), tap(() => this.store.dispatch({ type: 'GET_USERS_EXTRA_DATA' })), switchMap(() => this.getUsers()), tap(users => this.store.dispatch({ type: 'RECEIVE_USERS_EXTRA_DATA', users, }), ), share(), ); public usersExtraData$ = muteFirst( this.requireUsersExtraData$.pipe(startWith(null)), this.store.pipe(select(selectUsersExtraData)), );
这是上述方法与此方法的平行比较:

使用纯可观察的代码需要更少的代码行,并且自动取消了整个链中对数据的依赖。 (为了使比较更容易理解,我跳过了最初包含的finalize
语句,但是即使没有它们,查询也将相应地被取消。)

结论
Ngrx /特效是一个很棒的工具! 但是在使用它之前,请考虑以下问题:
- 这真的是副作用吗?
- Ngrx / effects是执行此操作的最佳方法吗?