NGRX Effect中switchMap与exhaustMap/concatMap的差异及异常问题
我编写了如下NGRX Effect:
public SetProperTab$ = createEffect( () => { return this.actions$.pipe( ofType(actions.SetProperTab), switchMap((action) => this.store$.select(selectors.GetHasLogicalProductInStore, { id: action.logicalProductId }).pipe( switchMap((hasLogicalProductInStore) => { if (hasLogicalProductInStore) { return of(true); } else { return this.actions$.pipe(ofType(actions.GetLogicalProductDetailsSuccess), take(1)); } }), concatLatestFrom(() => this.store$.select(selectors.GetProperSpecifiedLogicalProduct, { logicalProductId: action.logicalProductId }), ), concatMap(([_, properCurrent]) => { if (properCurrent?.ProductsLite.length) { const lastProductConfig: RestProductLiteEntity | undefined = properCurrent.ProductsLite.at(-1); if (lastProductConfig) { this.router.navigate([`/main/product-editor/${action.logicalProductId}/product-config/${lastProductConfig.ID}`]); } } else { this.router.navigate([`/main/product-editor/${action.logicalProductId}/global`]); } return EMPTY; }), ), ), ); }, { dispatch: false }, );
该Effect的逻辑是:用户点击kendo-grid列表项后触发,先检查store中是否存在该条目(及其大数据集);若不存在,则等待GetLogicalProductDetailsSuccess动作完成(该动作在后端数据下载完成后触发),之后执行对应的导航逻辑,目前使用switchMap时功能正常。
但我无法理解的是:将第一个switchMap替换为exhaustMap或concatMap后,该Effect仅在首次点击列表项时触发,后续用户点击列表项时,尽管SetProperTab动作持续分发(如下代码所示),但Effect不再响应。
public onSelectedChanged(value: ID | null): void { if (value) { this.productEditorService.onSelectedChanged(value); this.store$.dispatch(SetProperTab({ logicalProductId: value.toString() })); } }
我原本预期它的表现与switchMap一致,因为每次点击仅分发一次SetProperTab动作,请问这是为什么?
原因分析与解释
核心问题:内部Observable未完成导致阻塞
exhaustMap和concatMap的核心特性是必须等待当前内部Observable完全完成后,才会处理下一个外部的SetProperTab动作,而你的Effect内部存在Observable无法及时完成的场景:
- 当store中已有目标数据时,返回的
of(true)会立即完成,不会有问题; - 当store中无目标数据时,你返回的是
this.actions$.pipe(ofType(actions.GetLogicalProductDetailsSuccess), take(1))——这个Observable只有在收到GetLogicalProductDetailsSuccess动作时才会完成。如果这个动作迟迟未触发(比如后端请求超时、或者该动作是针对其他ID的),这个内部Observable会一直处于活跃状态,导致exhaustMap直接忽略后续的SetProperTab动作,concatMap则把后续动作积压在队列里无法执行。
而switchMap的特性是只要新的SetProperTab动作到来,就会立即取消当前正在执行的内部Observable,转而处理新动作,所以不会出现被阻塞的情况,这就是两者表现差异的根源。
为什么“单次点击分发动作”仍有差异?
你关注的是外部动作的分发次数,但exhaustMap/concatMap的判断依据是内部Observable是否完成,而不是外部动作的数量。只要第一个内部Observable还在等待(未收到对应的GetLogicalProductDetailsSuccess),后续的SetProperTab动作要么被忽略,要么被排队,直到前面的内部Observable完成才会被处理。
快速验证方法
可以在内部Observable的返回语句后添加tap操作,打印完成日志:
// 已有数据时 return of(true).pipe(tap(() => console.log(`内部Observable完成:${action.logicalProductId}`))); // 无数据时 return this.actions$.pipe( ofType(actions.GetLogicalProductDetailsSuccess), take(1), tap(() => console.log(`内部Observable完成:${action.logicalProductId}`)) );
如果某些操作后没有打印日志,说明对应的内部Observable未完成,直接导致了exhaustMap/concatMap的阻塞。
内容的提问来源于stack exchange,提问作者Bartek

