重新订阅NGRX Effect时重复加载旧数据问题排查
问题现象
使用NGRX结合路由时,出现异常:重新访问已浏览过的页面并订阅Effect时,数据会加载两次;仅当中间访问的页面未执行dispatch操作时才会触发该问题。
场景复现
- 页面A:订阅action并执行dispatch
- 页面B:订阅action并执行dispatch
- 页面C:仅订阅action但不执行dispatch
访问顺序对比:
- 「页面A→页面B→页面A」:无异常,数据正常加载一次
- 「页面A→页面C→页面A」:第二次进入页面A时,数据被加载两次(一次来自旧action触发,一次来自新API请求)
具体表现
组件中this.forumCategories.push(...action.categories);会先被旧的getCategoriesSuccess action触发调用,之后dispatch新请求,又被新数据触发调用,最终导致数据重复。该问题仅在特定路由跳转场景下出现,首次加载或中间访问过执行dispatch的页面时无此问题。
相关代码片段
服务代码
@Injectable({ providedIn: 'root', }) export class ForumService { private store = inject(Store); private httpClient = inject(HttpClient); getCategories(page: number): Observable<PaginatedResponse<ForumCategoryResponse, 'categories'>> { let token: string | null = null; this.store .select(selectToken) .pipe(take(1)) .subscribe((t) => { token = t; }); return this.httpClient.get<PaginatedResponse<ForumCategoryResponse, 'categories'>>( `${environment.baseApiUrl}/forum/categories?page=${page}`, { headers: { Authorization: `Bearer ${token}`, }, } ); } }
Effects代码
@Injectable() export class ForumEffects { private actions$ = inject(Actions); private forumService = inject(ForumService); getCategories$ = createEffect(() => { return this.actions$.pipe( ofType(getCategories), exhaustMap((action) => { return this.forumService .getCategories(action.page) .pipe( map((response) => getCategoriesSuccess(response)), catchError((error: ApiError) => of(getCategoriesFailure(error))), ); }) ); }); }
组件代码
@Component({ selector: 'app-forum-categories', imports: [ContainerComponent, HorizontalRuleComponent, NgFor, RouterLink], templateUrl: './forum-categories.component.html', }) export class ForumCategoriesComponent implements OnInit, OnDestroy { private readonly store = inject(Store); private readonly actionsSubject = inject(ActionsSubject); destroy$ = new Subject<boolean>(); forumCategories: ForumCategoryResponse[] = []; currentPage = 1; morePages = true; ngOnDestroy() { this.destroy$.next(true); this.destroy$.complete(); } ngOnInit(): void { this.actionsSubject .pipe(ofType(getCategoriesSuccess)) .pipe(takeUntil(this.destroy$)) .subscribe((action) => { if (action.categories !== null) { this.forumCategories.push(...action.categories); } this.currentPage = action.page; this.morePages = action.morePages }); this.store.dispatch(getCategories({ page: 1 })); } }
问题根源分析
ActionsSubject的重放特性:NGRX的
ActionsSubject本质是一个ReplaySubject(默认重放最近1个action)。当中间访问未执行dispatch的页面C时,ActionsSubject中留存的最后一个action仍是页面A首次触发的getCategoriesSuccess。当再次进入页面A时,组件订阅ActionsSubject会立即收到这个旧的success action,触发数据push;之后dispatch的新请求完成后,又会收到新的success action,导致两次数据加载。而当中间访问页面B时,B会dispatch其他类型的action,覆盖
ActionsSubject中留存的旧值,回到A时订阅不会收到之前的getCategoriesSuccess,仅会处理新请求的action,因此无异常。非标准的NGRX用法:未将数据存入Store,而是在组件内部维护状态并直接订阅
ActionsSubject,违背了NGRX“单一数据源”的设计原则,也放大了action流的副作用。服务中token获取的异步问题:当前服务中通过subscribe同步获取token的方式存在竞态,可能导致请求发送时token尚未被赋值,引发未授权错误。
解决方案
方案1:遵循NGRX最佳实践,将数据存入Store
这是最推荐的解决方式,彻底规避action订阅带来的问题:
- 定义Reducer与状态:
export interface ForumState { categories: ForumCategoryResponse[]; currentPage: number; morePages: boolean; loading: boolean; error: ApiError | null; } const initialState: ForumState = { categories: [], currentPage: 1, morePages: true, loading: false, error: null, }; export const forumReducer = createReducer( initialState, on(getCategories, state => ({ ...state, loading: true })), on(getCategoriesSuccess, (state, action) => ({ ...state, categories: action.categories ? [...state.categories, ...action.categories] : state.categories, currentPage: action.page, morePages: action.morePages, loading: false, error: null, })), on(getCategoriesFailure, (state, action) => ({ ...state, loading: false, error: action.error, })) );
- 组件从Store中选择数据:
@Component({ selector: 'app-forum-categories', imports: [ContainerComponent, HorizontalRuleComponent, NgFor, RouterLink], templateUrl: './forum-categories.component.html', }) export class ForumCategoriesComponent implements OnInit, OnDestroy { private readonly store = inject(Store); destroy$ = new Subject<boolean>(); // 直接从Store选择状态 forumCategories$ = this.store.select(selectForumCategories); currentPage$ = this.store.select(selectForumCurrentPage); morePages$ = this.store.select(selectForumMorePages); loading$ = this.store.select(selectForumLoading); ngOnDestroy() { this.destroy$.next(true); this.destroy$.complete(); } ngOnInit(): void { // 若需要重新加载数据,可在此dispatch action this.store.dispatch(getCategories({ page: 1 })); } }
- 修改组件模板,使用async管道:
在模板中通过async管道订阅Observable,无需手动管理订阅:
<div *ngIf="loading$ | async">加载中...</div> <div *ngFor="let category of forumCategories$ | async"> <!-- 渲染分类内容 --> </div>
方案2:修复ActionsSubject订阅问题(临时方案)
若暂时不想修改状态管理方式,可通过过滤action或调整订阅时机避免重复触发:
- 在组件中添加唯一标识匹配:
给action添加correlationId,组件只处理自己触发的action:
// 修改action定义 export const getCategories = createAction( '[Forum] Get Categories', props<{ page: number; correlationId: string }>() ); export const getCategoriesSuccess = createAction( '[Forum] Get Categories Success', props<{ categories: ForumCategoryResponse[] | null; page: number; morePages: boolean; correlationId: string }>() ); // 修改Effects getCategories$ = createEffect(() => { return this.actions$.pipe( ofType(getCategories), exhaustMap((action) => { return this.forumService .getCategories(action.page) .pipe( map((response) => getCategoriesSuccess({ ...response, correlationId: action.correlationId })), catchError((error: ApiError) => of(getCategoriesFailure(error))), ); }) ); }); // 修改组件 ngOnInit(): void { const correlationId = uuid(); // 使用uuid生成唯一标识 this.actionsSubject .pipe( ofType(getCategoriesSuccess), filter(action => action.correlationId === correlationId), takeUntil(this.destroy$) ) .subscribe((action) => { if (action.categories !== null) { this.forumCategories.push(...action.categories); } this.currentPage = action.page; this.morePages = action.morePages }); this.store.dispatch(getCategories({ page: 1, correlationId })); }
方案3:修复服务中的token获取问题
无论采用哪种方案,都需修复服务中异步获取token的竞态问题:
getCategories(page: number): Observable<PaginatedResponse<ForumCategoryResponse, 'categories'>> { return this.store.select(selectToken).pipe( take(1), switchMap(token => { const headers = token ? { Authorization: `Bearer ${token}` } : {}; return this.httpClient.get<PaginatedResponse<ForumCategoryResponse, 'categories'>>( `${environment.baseApiUrl}/forum/categories?page=${page}`, { headers } ); }) ); }
内容的提问来源于stack exchange,提问作者jProg2015

