20个例子掌握RxJS——第四章使用 switchMap 处理请求竞态条件

10 阅读3分钟

RxJS 实战:使用 switchMap 处理请求竞态条件

概述

在用户交互频繁的应用中,经常会遇到竞态条件(Race Condition)问题。比如用户快速点击按钮,或者输入框快速输入,可能会触发多个请求,而这些请求的响应顺序是不确定的。本章将介绍如何使用 RxJS 的 switchMap 操作符来优雅地解决这个问题。

什么是竞态条件?

竞态条件是指多个异步操作同时进行,但它们的完成顺序不确定,导致最终结果可能不是我们期望的。在 Web 开发中,常见的竞态条件场景包括:

  1. 快速点击按钮:用户快速点击"搜索"按钮,触发多个搜索请求
  2. 输入框输入:用户快速输入,每次输入都触发请求
  3. 标签页切换:用户快速切换标签页,每个标签页都发起数据请求

switchMap 操作符简介

switchMap 是 RxJS 中处理竞态条件的利器。它的特点是:当新的值到达时,会自动取消之前未完成的内部 Observable

基本语法

source$.pipe(
  switchMap(value => createObservable(value))
)

关键特性

  1. 自动取消:新的值到达时,自动取消之前的 Observable
  2. 只保留最新:只处理最新的请求,忽略之前的请求
  3. 避免竞态:确保最终结果对应最新的操作

实战场景:处理快速点击请求

假设我们有一个按钮,点击后会随机调用不同的 API(delay1、delay2、delay3)。如果用户快速点击,我们希望只处理最后一次点击的请求。

实现思路

  1. 使用 Subject 作为请求触发器
  2. 使用 switchMap 处理请求,自动取消之前的请求
  3. 记录请求历史,展示哪些请求被取消了

核心代码

// 请求触发 Subject
private requestTrigger$ = new Subject<string>();

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

ngOnInit(): void {
  // 使用 switchMap 处理请求竞态条件
  this.requestTrigger$
    .pipe(
      switchMap((apiUrl) => {
        const api = this.apis.find(a => a.url === apiUrl);
        const apiName = api?.name || 'unknown';
        
        // 创建请求记录
        const recordId = ++this.requestCounter;
        const record: RequestRecord = {
          id: recordId,
          apiName,
          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.currentApiName = apiName;
        this.cdr.detectChanges();
        
        return this.http.get<DelayApiResponse>(`${this.apiBaseUrl}${apiUrl}`)
          .pipe(
            catchError(err => {
              // 捕获错误
              console.error(`请求 ${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) => {
        // 找到最新的 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();
      }
    });
}

// 触发请求
triggerRequest(): void {
  const randomApi = this.apis[Math.floor(Math.random() * this.apis.length)];
  this.requestTrigger$.next(randomApi.url);
}

关键点解析

1. switchMap 的取消机制

requestTrigger$ 发出新值时,switchMap 会:

  1. 取消之前未完成的 HTTP 请求(如果可能)
  2. 开始新的请求
  3. 只处理最新请求的响应

2. 请求记录管理

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

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

3. 错误处理

使用 catchError 确保单个请求的错误不会中断整个流,而是返回一个错误响应对象。

与其他操作符的对比

switchMap vs mergeMap

特性switchMapmergeMap
行为取消之前的请求并发执行所有请求
适用场景只需要最新结果需要所有结果
资源占用低(只保留一个请求)高(可能多个请求)

switchMap vs concatMap

特性switchMapconcatMap
行为取消之前的请求按顺序执行
适用场景只需要最新结果需要保证顺序
执行方式中断之前的请求等待之前的请求完成

实际应用场景

1. 搜索输入框

// 用户输入时,只处理最新的搜索请求
searchInput.valueChanges.pipe(
  debounceTime(300), // 防抖
  distinctUntilChanged(), // 去重
  switchMap(query => 
    this.searchService.search(query)
  )
).subscribe(results => {
  // 只显示最新搜索的结果
  this.searchResults = results;
});

2. 标签页切换

// 切换标签时,取消之前标签的数据请求
tabSwitch$.pipe(
  switchMap(tabId => 
    this.loadTabData(tabId)
  )
).subscribe(data => {
  // 只显示当前标签的数据
  this.currentTabData = data;
});

3. 路由参数变化

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

性能优化建议

1. 结合防抖使用

对于输入框场景,可以结合 debounceTime 使用:

input$.pipe(
  debounceTime(300),
  switchMap(value => this.request(value))
)

2. 添加加载状态

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

switchMap(apiUrl => {
  this.loading = true;
  return this.http.get(apiUrl).pipe(
    finalize(() => this.loading = false)
  );
})

3. 错误重试

对于可能失败的请求,可以添加重试:

switchMap(apiUrl => 
  this.http.get(apiUrl).pipe(
    retry(2),
    catchError(err => of(null))
  )
)

注意事项

  1. 取消请求的限制:HTTP 请求的取消取决于浏览器和 HTTP 客户端的支持
  2. 副作用处理:如果请求有副作用(如创建资源),需要谨慎使用 switchMap
  3. 用户体验:频繁取消请求可能会让用户困惑,需要适当的 UI 反馈

总结

switchMap 是处理竞态条件的强大工具,它通过自动取消之前的请求来确保只处理最新的操作。在实际项目中,合理使用 switchMap 可以:

  • 避免竞态条件:确保结果对应最新的操作
  • 节省资源:取消不必要的请求,减少服务器压力
  • 提升用户体验:只显示最新的结果,避免混乱

记住:当你只需要最新结果时,使用 switchMap;当你需要所有结果时,使用 mergeMapforkJoin

码云地址:gitee.com/leeyamaster…