You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

重新订阅NGRX Effect时重复加载旧数据问题排查

NGRX路由场景下数据重复加载问题

问题现象

使用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 }));
  }
}

问题根源分析

  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,因此无异常。

  2. 非标准的NGRX用法:未将数据存入Store,而是在组件内部维护状态并直接订阅ActionsSubject,违背了NGRX“单一数据源”的设计原则,也放大了action流的副作用。

  3. 服务中token获取的异步问题:当前服务中通过subscribe同步获取token的方式存在竞态,可能导致请求发送时token尚未被赋值,引发未授权错误。


解决方案

方案1:遵循NGRX最佳实践,将数据存入Store

这是最推荐的解决方式,彻底规避action订阅带来的问题:

  1. 定义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,
  }))
);
  1. 组件从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 }));
  }
}
  1. 修改组件模板,使用async管道:
    在模板中通过async管道订阅Observable,无需手动管理订阅:
<div *ngIf="loading$ | async">加载中...</div>
<div *ngFor="let category of forumCategories$ | async">
  <!-- 渲染分类内容 -->
</div>

方案2:修复ActionsSubject订阅问题(临时方案)

若暂时不想修改状态管理方式,可通过过滤action或调整订阅时机避免重复触发:

  1. 在组件中添加唯一标识匹配:
    给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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.14 01:50:56