RxJS 实战:使用 exhaustMap 实现轮询机制
概述
轮询(Polling)是一种定期检查数据更新的技术,常用于实时性要求不高的场景,比如检查任务状态、获取最新数据等。本章将介绍如何使用 RxJS 的 timer 和 exhaustMap 操作符实现优雅的轮询机制。
轮询的基本概念
轮询是指定期(如每 3 秒)发起请求,检查数据是否有更新。常见的轮询场景包括:
- 任务状态检查:定期检查后台任务是否完成
- 数据同步:定期从服务器获取最新数据
- 消息通知:定期检查是否有新消息
为什么使用 exhaustMap?
在轮询场景中,如果前一个请求还没完成,新的轮询周期又到了,我们通常希望:
- 忽略新的请求:等待前一个请求完成
- 避免请求堆积:防止多个请求同时进行
exhaustMap 正是为此设计的:它会忽略新的值,直到当前的内部 Observable 完成。
exhaustMap vs 其他操作符
| 操作符 | 行为 | 适用场景 |
|---|---|---|
mergeMap | 并发执行所有请求 | 需要所有请求的结果 |
concatMap | 按顺序执行请求 | 需要保证顺序 |
switchMap | 取消之前的请求 | 只需要最新结果 |
exhaustMap | 忽略新的请求 | 避免请求堆积(轮询) |
实战场景:定期轮询 API
假设我们需要每 3 秒轮询一次 API,获取最新数据。如果前一个请求还没完成,应该忽略新的轮询周期。
实现思路
- 使用
timer(0, 3000)创建定时器(立即执行第一次,然后每 3 秒执行一次) - 使用
exhaustMap确保前一个请求完成后再执行下一个 - 使用
catchError处理单个请求的错误 - 记录每次轮询的结果
核心代码
// 轮询间隔(毫秒)
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 秒:
- 0 秒:timer 发出第一个值 → exhaustMap 发起请求 A(2 秒)
- 3 秒:timer 发出第二个值 → exhaustMap 忽略(请求 A 还在进行)
- 4 秒:请求 A 完成 → 可以处理下一个值
- 6 秒:timer 发出第三个值 → exhaustMap 发起请求 B(2 秒)
- 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();
注意事项
- 内存泄漏:确保在组件销毁时取消订阅
- 服务器压力:合理设置轮询间隔,避免给服务器造成过大压力
- 网络消耗:轮询会持续消耗网络资源,考虑使用 WebSocket 替代
- 用户体验:给用户适当的反馈,告知正在轮询
总结
使用 exhaustMap 实现轮询机制是一个优雅的解决方案,它通过忽略新的请求来确保:
- 避免请求堆积:前一个请求完成后再执行下一个
- 资源节约:不会同时发起多个请求
- 代码简洁:使用 RxJS 操作符,代码清晰易读
- 易于管理:可以轻松启动和停止轮询
记住:轮询是一种简单但有效的实时数据获取方式,但对于实时性要求高的场景,考虑使用 WebSocket 或 Server-Sent Events。