20个例子掌握RxJS——第九章使用 exhaustMap 实现轮询机制

8 阅读6分钟

RxJS 实战:使用 exhaustMap 实现轮询机制

概述

轮询(Polling)是一种定期检查数据更新的技术,常用于实时性要求不高的场景,比如检查任务状态、获取最新数据等。本章将介绍如何使用 RxJS 的 timerexhaustMap 操作符实现优雅的轮询机制。

轮询的基本概念

轮询是指定期(如每 3 秒)发起请求,检查数据是否有更新。常见的轮询场景包括:

  • 任务状态检查:定期检查后台任务是否完成
  • 数据同步:定期从服务器获取最新数据
  • 消息通知:定期检查是否有新消息

为什么使用 exhaustMap?

在轮询场景中,如果前一个请求还没完成,新的轮询周期又到了,我们通常希望:

  • 忽略新的请求:等待前一个请求完成
  • 避免请求堆积:防止多个请求同时进行

exhaustMap 正是为此设计的:它会忽略新的值,直到当前的内部 Observable 完成。

exhaustMap vs 其他操作符

操作符行为适用场景
mergeMap并发执行所有请求需要所有请求的结果
concatMap按顺序执行请求需要保证顺序
switchMap取消之前的请求只需要最新结果
exhaustMap忽略新的请求避免请求堆积(轮询)

实战场景:定期轮询 API

假设我们需要每 3 秒轮询一次 API,获取最新数据。如果前一个请求还没完成,应该忽略新的轮询周期。

实现思路

  1. 使用 timer(0, 3000) 创建定时器(立即执行第一次,然后每 3 秒执行一次)
  2. 使用 exhaustMap 确保前一个请求完成后再执行下一个
  3. 使用 catchError 处理单个请求的错误
  4. 记录每次轮询的结果

核心代码

// 轮询间隔(毫秒)
readonly pollInterval = 3000; // 3秒

// 轮询订阅
private pollSubscription?: Subscription;

// 轮询状态
isPolling = false;

// 开始轮询
startPolling(): void {
  // 如果已经在轮询,先停止
  if (this.isPolling) {
    return;
  }
  
  this.isPolling = true;
  
  // 使用 timer(0, 3000) 立即执行第一次请求,然后每3秒执行一次
  // 使用 exhaustMap 确保前一个请求完成后再执行下一个,避免请求堆积
  this.pollSubscription = timer(0, this.pollInterval)
    .pipe(
      exhaustMap(() => {
        const recordId = ++this.recordCounter;
        const startTime = new Date().toISOString();
        
        // 创建记录(先标记为 pending,实际在响应中更新)
        return this.http.get<PollApiResponse>(`${this.apiBaseUrl}${this.pollApiUrl}`)
          .pipe(
            catchError(error => {
              // 错误处理
              const errorRecord: PollRecord = {
                id: recordId,
                timestamp: startTime,
                status: 'error',
                error: error.message || '请求失败'
              };
              this.pollRecords.unshift(errorRecord);
              // 限制记录数量,最多保留50条
              if (this.pollRecords.length > 50) {
                this.pollRecords = this.pollRecords.slice(0, 50);
              }
              this.cdr.detectChanges();
              return of(null);
            })
          );
      })
    )
    .subscribe({
      next: (response) => {
        if (response) {
          const record: PollRecord = {
            id: this.recordCounter,
            timestamp: new Date().toISOString(),
            response,
            status: response.success ? 'success' : 'error'
          };
          this.pollRecords.unshift(record);
          // 限制记录数量,最多保留50条
          if (this.pollRecords.length > 50) {
            this.pollRecords = this.pollRecords.slice(0, 50);
          }
          this.cdr.detectChanges();
        }
      },
      error: (error) => {
        console.error('轮询错误:', error);
        this.stopPolling();
      }
    });
}

// 停止轮询
stopPolling(): void {
  if (this.pollSubscription) {
    this.pollSubscription.unsubscribe();
    this.pollSubscription = undefined;
  }
  this.isPolling = false;
  this.cdr.detectChanges();
}

关键点解析

1. timer 操作符

timer(0, 3000) 的含义:

  • 第一个参数(0):延迟时间,0 表示立即执行第一次
  • 第二个参数(3000):间隔时间,每 3000 毫秒(3 秒)执行一次

2. exhaustMap 的作用

exhaustMap 确保:

  • 如果前一个请求还在进行,忽略新的轮询周期
  • 只有当前一个请求完成后,才会处理下一个轮询周期
  • 避免请求堆积,减少服务器压力

3. 执行流程示例

假设 API 响应时间为 2 秒:

  1. 0 秒:timer 发出第一个值 → exhaustMap 发起请求 A(2 秒)
  2. 3 秒:timer 发出第二个值 → exhaustMap 忽略(请求 A 还在进行)
  3. 4 秒:请求 A 完成 → 可以处理下一个值
  4. 6 秒:timer 发出第三个值 → exhaustMap 发起请求 B(2 秒)
  5. 8 秒:请求 B 完成

结果:每 3-4 秒执行一次请求,不会堆积。

4. 错误处理

使用 catchError 确保单个请求的错误不会中断整个轮询流程:

catchError(error => {
  // 记录错误,但继续轮询
  this.handleError(error);
  return of(null);
})

与其他方案的对比

方案 1:使用 setInterval(不推荐)

// ❌ 不推荐:无法优雅地取消,容易导致内存泄漏
const interval = setInterval(() => {
  this.http.get('/api/data').subscribe();
}, 3000);

// 需要手动清理
clearInterval(interval);

方案 2:使用 mergeMap(有问题)

// ⚠️ 问题:可能同时发起多个请求
timer(0, 3000).pipe(
  mergeMap(() => this.http.get('/api/data'))
)

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

// ✅ 推荐:避免请求堆积
timer(0, 3000).pipe(
  exhaustMap(() => this.http.get('/api/data'))
)

高级用法

1. 条件轮询

根据条件决定是否继续轮询:

timer(0, 3000).pipe(
  exhaustMap(() => this.http.get('/api/task-status')),
  takeWhile(response => response.status !== 'completed'), // 任务完成时停止轮询
  finalize(() => console.log('轮询结束'))
)

2. 动态调整轮询间隔

根据响应结果动态调整轮询间隔:

let pollInterval = 3000;

timer(0, pollInterval).pipe(
  exhaustMap(() => this.http.get('/api/data')),
  tap(response => {
    // 根据响应调整轮询间隔
    if (response.hasUpdate) {
      pollInterval = 1000; // 有更新时加快轮询
    } else {
      pollInterval = 5000; // 无更新时减慢轮询
    }
  })
)

3. 指数退避轮询

如果请求失败,逐渐增加轮询间隔:

let pollInterval = 3000;
let consecutiveErrors = 0;

timer(0, pollInterval).pipe(
  exhaustMap(() => 
    this.http.get('/api/data').pipe(
      tap(() => {
        consecutiveErrors = 0; // 成功时重置错误计数
        pollInterval = 3000; // 重置间隔
      }),
      catchError(error => {
        consecutiveErrors++;
        pollInterval = Math.min(3000 * Math.pow(2, consecutiveErrors), 30000); // 指数退避
        return of(null);
      })
    )
  )
)

实际应用场景

1. 任务状态检查

// 检查后台任务是否完成
startPollingTaskStatus(taskId: string): void {
  timer(0, 2000).pipe(
    exhaustMap(() => this.getTaskStatus(taskId)),
    takeWhile(status => status !== 'completed' && status !== 'failed'),
    finalize(() => {
      // 任务完成,停止轮询
      this.onTaskComplete();
    })
  ).subscribe();
}

2. 数据同步

// 定期同步数据
startDataSync(): void {
  timer(0, 5000).pipe(
    exhaustMap(() => this.syncData()),
    catchError(error => {
      console.error('同步失败:', error);
      return of(null); // 继续轮询
    })
  ).subscribe();
}

3. 消息通知

// 定期检查新消息
startMessagePolling(): void {
  timer(0, 3000).pipe(
    exhaustMap(() => this.checkNewMessages()),
    tap(messages => {
      if (messages.length > 0) {
        this.showNotifications(messages);
      }
    })
  ).subscribe();
}

性能优化建议

1. 合理设置轮询间隔

根据业务需求设置合理的轮询间隔:

  • 实时性要求高:1-3 秒
  • 一般场景:3-5 秒
  • 实时性要求低:10-30 秒

2. 限制记录数量

对于轮询结果,限制记录数量,避免内存占用过大:

if (this.pollRecords.length > 50) {
  this.pollRecords = this.pollRecords.slice(0, 50);
}

3. 在页面不可见时暂停轮询

使用 Page Visibility API 在页面不可见时暂停轮询:

fromEvent(document, 'visibilitychange').pipe(
  switchMap(() => {
    if (document.hidden) {
      this.stopPolling();
      return EMPTY;
    } else {
      this.startPolling();
      return EMPTY;
    }
  })
).subscribe();

注意事项

  1. 内存泄漏:确保在组件销毁时取消订阅
  2. 服务器压力:合理设置轮询间隔,避免给服务器造成过大压力
  3. 网络消耗:轮询会持续消耗网络资源,考虑使用 WebSocket 替代
  4. 用户体验:给用户适当的反馈,告知正在轮询

总结

使用 exhaustMap 实现轮询机制是一个优雅的解决方案,它通过忽略新的请求来确保:

  • 避免请求堆积:前一个请求完成后再执行下一个
  • 资源节约:不会同时发起多个请求
  • 代码简洁:使用 RxJS 操作符,代码清晰易读
  • 易于管理:可以轻松启动和停止轮询

记住:轮询是一种简单但有效的实时数据获取方式,但对于实时性要求高的场景,考虑使用 WebSocket 或 Server-Sent Events

码云地址:gitee.com/leeyamaster…