20个例子掌握RxJS——第五章使用 switchMap 处理标签页切换

13 阅读4分钟

RxJS 实战:使用 switchMap 处理标签页切换

概述

在标签页(Tab)组件中,用户快速切换标签时,每个标签页都可能发起数据请求。如果不做处理,可能会导致:

  1. 多个请求同时进行,浪费资源
  2. 旧标签页的请求完成后覆盖新标签页的数据
  3. 用户体验差,数据混乱

本章将介绍如何使用 switchMap 在标签页切换时自动取消之前的请求,确保只显示当前标签页的数据。

问题场景

假设我们有一个标签页组件,包含 3 个标签页,每个标签页需要加载不同的数据:

  • 标签页 1:调用 /api/delay1(延迟 1 秒)
  • 标签页 2:调用 /api/delay2(延迟 2 秒)
  • 标签页 3:调用 /api/delay3(延迟 3 秒)

如果用户快速切换标签页,可能会出现以下问题:

  1. 用户点击"标签页 1" → 发起请求 A(1 秒)
  2. 用户立即点击"标签页 2" → 发起请求 B(2 秒)
  3. 用户立即点击"标签页 3" → 发起请求 C(3 秒)
  4. 请求 A 先完成 → 显示标签页 1 的数据(错误!)
  5. 请求 B 完成 → 显示标签页 2 的数据(错误!)
  6. 请求 C 完成 → 显示标签页 3 的数据(正确,但已经晚了)

switchMap 解决方案

使用 switchMap 可以完美解决这个问题:当切换标签页时,自动取消之前未完成的请求,只处理最新标签页的请求。

实现思路

  1. 使用 Subject 作为标签页切换触发器
  2. 使用 switchMap 处理标签页切换,自动取消之前的请求
  3. 记录请求历史,展示哪些请求被取消了
  4. 在组件销毁时取消所有订阅

核心代码

// 标签页列表
tabs: Tab[] = [
  { id: 'tab1', name: '标签页 1', apiUrl: '/api/delay1', apiName: 'delay1' },
  { id: 'tab2', name: '标签页 2', apiUrl: '/api/delay2', apiName: 'delay2' },
  { id: 'tab3', name: '标签页 3', apiUrl: '/api/delay3', apiName: 'delay3' }
];

// 当前激活的标签页
activeTabId: string = this.tabs[0].id;

// 标签页切换 Subject
private tabSwitch$ = new Subject<string>();

// 销毁 Subject
private destroy$ = new Subject<void>();

ngOnInit(): void {
  // 使用 switchMap 处理标签页切换时的请求取消
  this.tabSwitch$
    .pipe(
      switchMap((tabId) => {
        const tab = this.tabs.find(t => t.id === tabId);
        if (!tab) {
          return of(null);
        }
        
        // 创建请求记录
        const recordId = ++this.requestCounter;
        const record: RequestRecord = {
          id: recordId,
          tabId: tab.id,
          tabName: tab.name,
          apiName: tab.apiName,
          apiUrl: tab.apiUrl,
          startTime: Date.now(),
          status: 'pending'
        };
        
        // 将之前的 pending 请求标记为 cancelled(切换标签时取消)
        this.requestRecords.forEach(r => {
          if (r.status === 'pending') {
            r.status = 'cancelled';
            r.endTime = Date.now();
          }
        });
        
        this.requestRecords.unshift(record);
        this.loading = true;
        this.currentResult = null;
        this.cdr.detectChanges();
        
        return this.http.get<DelayApiResponse>(`${this.apiBaseUrl}${tab.apiUrl}`)
          .pipe(
            catchError(err => {
              // 捕获错误
              console.error(`请求 ${tab.apiUrl} 失败:`, err);
              return of({
                success: false,
                message: err.message || '请求失败',
                data: {
                  delay: null as any,
                  timestamp: new Date().toISOString(),
                  info: '请求失败'
                }
              } as DelayApiResponse);
            })
          );
      }),
      takeUntil(this.destroy$) // 路由切换时取消所有订阅
    )
    .subscribe({
      next: (response) => {
        if (!response) {
          return;
        }
        
        // 找到最新的 pending 请求记录
        const latestRecord = this.requestRecords.find(r => r.status === 'pending');
        if (latestRecord) {
          latestRecord.status = 'completed';
          latestRecord.endTime = Date.now();
          latestRecord.response = response;
        }
        
        this.currentResult = response;
        this.loading = false;
        this.cdr.detectChanges();
      },
      error: (err) => {
        // 处理错误
        const latestRecord = this.requestRecords.find(r => r.status === 'pending');
        if (latestRecord) {
          latestRecord.status = 'completed';
          latestRecord.endTime = Date.now();
          latestRecord.error = err.message || '请求失败';
        }
        
        this.loading = false;
        this.cdr.detectChanges();
      }
    });
  
  // 初始化时加载第一个标签页的数据
  this.switchTab(this.activeTabId);
}

// 切换标签页
switchTab(tabId: string): void {
  this.activeTabId = tabId;
  this.tabSwitch$.next(tabId);
}

ngOnDestroy(): void {
  // 路由切换时,取消所有订阅和请求
  this.destroy$.next();
  this.destroy$.complete();
  
  // 将未完成的请求标记为 cancelled
  this.requestRecords.forEach(r => {
    if (r.status === 'pending') {
      r.status = 'cancelled';
      r.endTime = Date.now();
    }
  });
}

关键点解析

1. switchMap 的自动取消机制

tabSwitch$ 发出新的标签页 ID 时:

  1. switchMap 自动取消之前未完成的 HTTP 请求
  2. 开始新的请求
  3. 只处理最新标签页的响应

2. 请求记录管理

通过维护请求记录列表,我们可以:

  • 追踪每个请求的状态(pending、completed、cancelled)
  • 展示请求历史,帮助调试
  • 分析哪些请求被取消了

3. 组件销毁时的清理

ngOnDestroy 中:

  1. 使用 destroy$ 取消所有订阅
  2. 将未完成的请求标记为 cancelled
  3. 防止内存泄漏

4. 初始化加载

ngOnInit 中调用 switchTab,确保第一个标签页的数据会被加载。

执行流程示例

假设用户的操作序列如下:

  1. 初始化:加载标签页 1 的数据

    • 发起请求 A(delay1,1 秒)
    • 状态:pending
  2. 用户点击标签页 2(请求 A 还未完成)

    • switchMap 取消请求 A
    • 请求 A 状态变为:cancelled
    • 发起请求 B(delay2,2 秒)
    • 状态:pending
  3. 用户点击标签页 3(请求 B 还未完成)

    • switchMap 取消请求 B
    • 请求 B 状态变为:cancelled
    • 发起请求 C(delay3,3 秒)
    • 状态:pending
  4. 请求 C 完成

    • 请求 C 状态变为:completed
    • 显示标签页 3 的数据 ✅

最终结果:只显示标签页 3 的数据,请求 A 和 B 都被取消了。

与其他方案的对比

方案 1:不使用 switchMap(有问题)

// ❌ 错误示例:多个请求可能同时完成,导致数据混乱
switchTab(tabId: string): void {
  this.activeTabId = tabId;
  this.loadTabData(tabId).subscribe(data => {
    this.currentResult = data; // 可能显示旧标签页的数据
  });
}

方案 2:手动取消订阅(复杂)

// ⚠️ 可行但复杂:需要手动管理订阅
private currentSubscription?: Subscription;

switchTab(tabId: string): void {
  // 取消之前的订阅
  if (this.currentSubscription) {
    this.currentSubscription.unsubscribe();
  }
  
  this.activeTabId = tabId;
  this.currentSubscription = this.loadTabData(tabId).subscribe(data => {
    this.currentResult = data;
  });
}

方案 3:使用 switchMap(推荐)✅

// ✅ 推荐:简洁、自动管理
this.tabSwitch$.pipe(
  switchMap(tabId => this.loadTabData(tabId))
).subscribe(data => {
  this.currentResult = data; // 只显示最新标签页的数据
});

实际应用场景

1. 多标签页数据加载

// 标签页组件
tabs = ['用户', '订单', '商品'];
activeTab = '用户';

tabChange$.pipe(
  switchMap(tab => this.loadTabData(tab))
).subscribe(data => {
  this.tabData = data;
});

2. 路由参数变化

// 路由参数变化时,取消之前的数据请求
route.params.pipe(
  switchMap(params => this.loadData(params.id))
).subscribe(data => {
  this.data = data;
});

3. 模态框内容加载

// 打开不同模态框时,取消之前的内容加载
modalOpen$.pipe(
  switchMap(modalType => this.loadModalContent(modalType))
).subscribe(content => {
  this.modalContent = content;
});

性能优化建议

1. 添加缓存机制

对于不经常变化的数据,可以添加缓存:

private tabDataCache = new Map<string, any>();

switchMap(tabId => {
  if (this.tabDataCache.has(tabId)) {
    return of(this.tabDataCache.get(tabId));
  }
  return this.loadTabData(tabId).pipe(
    tap(data => this.tabDataCache.set(tabId, data))
  );
})

2. 预加载相邻标签页

可以在用户切换到某个标签页时,预加载相邻标签页的数据:

switchTab(tabId: string): void {
  this.tabSwitch$.next(tabId);
  // 预加载相邻标签页
  this.preloadAdjacentTabs(tabId);
}

3. 添加加载状态

通过维护加载状态,给用户更好的反馈:

switchMap(tabId => {
  this.loading = true;
  return this.loadTabData(tabId).pipe(
    finalize(() => this.loading = false)
  );
})

注意事项

  1. 副作用处理:如果请求有副作用(如创建资源),需要谨慎使用 switchMap
  2. 用户体验:频繁取消请求可能会让用户困惑,需要适当的 UI 反馈
  3. 错误处理:确保每个请求都有适当的错误处理
  4. 内存泄漏:确保在组件销毁时取消所有订阅

总结

使用 switchMap 处理标签页切换是一个优雅的解决方案,它通过自动取消之前的请求来确保:

  • 数据一致性:只显示当前标签页的数据
  • 资源节约:取消不必要的请求,减少服务器压力
  • 代码简洁:不需要手动管理订阅和取消逻辑
  • 用户体验:避免数据混乱,提供流畅的交互体验

记住:当你需要在切换时取消之前的操作时,使用 switchMap 是最佳选择。

码云地址:gitee.com/leeyamaster…