停止为此使用Ngrx /特效

半身像

有时,最简单的功能实现最终会带来弊大于利的问题,只会增加其他地方的复杂性。 最终结果是一个没人希望接触的锯齿状架构。

翻译笔记

这篇文章写于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) // POST    .pipe(map(res => this.store.dispatch({ type: 'DATA_SAVED' }))) .subscribe(); 

如果我们可以在用户提交表单并在其他地方处理副作用时,就可以在组件内部分派动作(以下简称为动作),那就太好了。


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缩减器中,单个Itemitems对象中删除。 但是,如果此元素标识符位于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是执行此操作的最佳方法吗?

Source: https://habr.com/ru/post/zh-CN448858/


All Articles