RxJS 实战:使用 interval 和 scan 实现定时器
概述
定时器是一个常见的功能,用于测量经过的时间。在 Web 开发中,我们经常需要实现秒表、倒计时等功能。本章将介绍如何使用 RxJS 的 interval、scan 和 takeUntil 操作符实现一个功能完整的定时器。
定时器的基本概念
定时器用于测量从某个时间点开始经过的时间。常见的定时器场景包括:
- 秒表功能:测量经过的时间
- 倒计时器:从指定时间倒计时到 0
- 任务计时:记录任务执行时间
- 游戏计时:游戏中的计时功能
为什么使用 RxJS?
使用 RxJS 实现定时器有以下优势:
- 响应式编程:使用 Observable 流处理时间,代码更清晰
- 易于控制:可以轻松实现开始、暂停、重置等功能
- 自动清理:使用
takeUntil可以优雅地取消订阅 - 组合性强:可以轻松与其他 RxJS 操作符组合
核心操作符
1. interval
interval 创建一个按固定时间间隔发出递增数字的 Observable。
interval(1000) // 每秒发出一个值:0, 1, 2, 3...
2. scan
scan 类似数组的 reduce,但会发出每次累加的结果。
scan((acc, value) => acc + value, 0)
// 输入:0, 1, 2, 3...
// 输出:0, 1, 3, 6, 10...
3. startWith
startWith 在 Observable 开始前发出指定的值。
interval(1000).pipe(startWith(0))
// 立即发出 0,然后每秒发出 1, 2, 3...
4. takeUntil
takeUntil 当另一个 Observable 发出值时,完成当前 Observable。
interval(1000).pipe(takeUntil(stop$))
// 当 stop$ 发出值时,停止发出值
实战场景:实现一个秒表
假设我们需要实现一个秒表,具有开始、暂停、重置功能。
实现思路
- 使用
interval(1000)每秒发出一个值 - 使用
startWith(0)立即开始 - 使用
scan累加时间 - 使用
takeUntil控制停止、暂停和重置
核心代码
// 定时器状态
isRunning = false;
currentTime = 0;
// 销毁 Subject
private destroySubject$ = new Subject<void>();
// 暂停/继续控制 Subject
private pauseSubject$ = new Subject<void>();
// 重置控制 Subject
private resetSubject$ = new Subject<void>();
// 开始定时器
private startTimer(): void {
if (this.isRunning) {
return;
}
this.isRunning = true;
// 使用 interval(1000) 每秒发出一个值
// 使用 scan 累加时间
// 使用 startWith 从当前时间开始
// 使用 takeUntil 控制停止
interval(1000)
.pipe(
startWith(0),
scan((acc) => acc + 1, this.currentTime),
takeUntil(this.destroySubject$),
takeUntil(this.pauseSubject$),
takeUntil(this.resetSubject$)
)
.subscribe({
next: (time) => {
this.currentTime = time;
this.cdr.detectChanges();
},
complete: () => {
// 如果是暂停,保持状态
if (!this.pauseSubject$.closed) {
this.isRunning = false;
this.cdr.detectChanges();
}
}
});
}
// 暂停定时器
private pauseTimer(): void {
if (!this.isRunning) {
return;
}
this.pauseSubject$.next();
this.isRunning = false;
this.cdr.detectChanges();
}
// 重置定时器
resetTimer(): void {
// 如果正在运行,先停止
if (this.isRunning) {
this.pauseSubject$.next();
}
// 重置时间
this.currentTime = 0;
this.isRunning = false;
// 创建新的 pauseSubject 和 resetSubject
this.pauseSubject$ = new Subject<void>();
this.resetSubject$ = new Subject<void>();
this.cdr.detectChanges();
}
关键点解析
1. interval 的使用
interval(1000) 每秒发出一个值,从 0 开始:
- 0 秒:发出 0
- 1 秒:发出 1
- 2 秒:发出 2
- ...
2. scan 累加时间
scan((acc) => acc + 1, this.currentTime) 的作用:
- 从
this.currentTime开始累加 - 每次收到新值,累加 1
- 如果从 10 秒开始,会输出:10, 11, 12, 13...
3. startWith 的作用
startWith(0) 确保:
- 立即发出初始值,不等待第一个 interval
- 定时器可以立即开始计时
4. takeUntil 的多重控制
使用多个 takeUntil 可以灵活控制定时器的停止:
takeUntil(this.destroySubject$):组件销毁时停止takeUntil(this.pauseSubject$):暂停时停止takeUntil(this.resetSubject$):重置时停止
5. 暂停和重置的实现
暂停和重置需要创建新的 Subject,确保可以重新启动:
// 暂停后,创建新的 Subject
this.pauseSubject$ = new Subject<void>();
// 重置后,创建新的 Subject
this.resetSubject$ = new Subject<void>();
时间格式化
定时器通常需要将秒数格式化为 HH:MM:SS 格式:
formatTime(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
return [
hours.toString().padStart(2, '0'),
minutes.toString().padStart(2, '0'),
secs.toString().padStart(2, '0')
].join(':');
}
与其他方案的对比
方案 1:使用 setInterval(不推荐)
// ❌ 不推荐:难以控制,容易导致内存泄漏
let interval: any;
let currentTime = 0;
function startTimer() {
interval = setInterval(() => {
currentTime++;
updateDisplay();
}, 1000);
}
function pauseTimer() {
clearInterval(interval);
}
function resetTimer() {
clearInterval(interval);
currentTime = 0;
updateDisplay();
}
问题:
- 需要手动管理 interval ID
- 容易忘记清理,导致内存泄漏
- 代码不够优雅
方案 2:使用 RxJS(推荐)✅
// ✅ 推荐:响应式编程,易于控制
interval(1000)
.pipe(
startWith(0),
scan((acc) => acc + 1, this.currentTime),
takeUntil(this.pauseSubject$)
)
.subscribe(time => {
this.currentTime = time;
});
优势:
- 响应式编程,代码清晰
- 自动管理订阅,避免内存泄漏
- 易于扩展和维护
高级用法
1. 倒计时器
实现从指定时间倒计时到 0:
const initialTime = 60; // 60秒倒计时
interval(1000)
.pipe(
startWith(0),
scan((acc) => acc - 1, initialTime),
takeWhile(time => time >= 0),
takeUntil(this.destroySubject$)
)
.subscribe({
next: (time) => {
this.currentTime = time;
if (time === 0) {
this.onCountdownComplete();
}
}
});
2. 多段计时
记录多个时间段:
interface TimeSegment {
id: number;
startTime: number;
endTime?: number;
duration?: number;
}
private segments: TimeSegment[] = [];
private currentSegmentId = 0;
startSegment(): void {
const segment: TimeSegment = {
id: ++this.currentSegmentId,
startTime: this.currentTime
};
this.segments.push(segment);
}
endSegment(segmentId: number): void {
const segment = this.segments.find(s => s.id === segmentId);
if (segment) {
segment.endTime = this.currentTime;
segment.duration = segment.endTime - segment.startTime;
}
}
3. 精确计时
使用更小的间隔实现更精确的计时:
// 每 100 毫秒更新一次(精确到 0.1 秒)
interval(100)
.pipe(
startWith(0),
scan((acc) => acc + 0.1, 0),
takeUntil(this.pauseSubject$)
)
.subscribe(time => {
this.currentTime = Math.round(time * 10) / 10; // 保留一位小数
});
4. 条件停止
根据条件自动停止:
interval(1000)
.pipe(
startWith(0),
scan((acc) => acc + 1, 0),
takeWhile(time => time < 60), // 60秒后自动停止
takeUntil(this.destroySubject$)
)
.subscribe({
next: (time) => {
this.currentTime = time;
},
complete: () => {
this.onTimerComplete();
}
});
实际应用场景
1. 秒表功能
// 测量经过的时间
startStopwatch(): void {
interval(1000)
.pipe(
startWith(0),
scan((acc) => acc + 1, 0),
takeUntil(this.pauseSubject$)
)
.subscribe(time => {
this.elapsedTime = time;
});
}
2. 任务计时
// 记录任务执行时间
startTaskTimer(taskId: string): void {
const startTime = Date.now();
interval(1000)
.pipe(
map(() => Math.floor((Date.now() - startTime) / 1000)),
takeUntil(this.taskComplete$)
)
.subscribe(time => {
this.taskTimes[taskId] = time;
});
}
3. 游戏计时
// 游戏中的计时功能
startGameTimer(): void {
interval(1000)
.pipe(
startWith(0),
scan((acc) => acc + 1, 0),
takeUntil(this.gameOver$)
)
.subscribe(time => {
this.gameTime = time;
this.updateGameUI();
});
}
性能优化建议
1. 使用 ChangeDetectorRef
在 Angular 中,使用 ChangeDetectorRef 手动触发变更检测,避免不必要的检查:
.subscribe({
next: (time) => {
this.currentTime = time;
this.cdr.detectChanges(); // 手动触发变更检测
}
});
2. 限制更新频率
如果不需要每秒更新,可以降低更新频率:
// 每 5 秒更新一次
interval(5000)
.pipe(
startWith(0),
scan((acc) => acc + 5, 0)
)
3. 在页面不可见时暂停
使用 Page Visibility API 在页面不可见时暂停定时器:
fromEvent(document, 'visibilitychange')
.pipe(
switchMap(() => {
if (document.hidden) {
this.pauseTimer();
return EMPTY;
} else {
// 页面可见时可以选择恢复
return EMPTY;
}
})
)
.subscribe();
注意事项
- 内存泄漏:确保在组件销毁时取消订阅
- 变更检测:在 Angular 中,可能需要手动触发变更检测
- 浏览器环境:使用
isPlatformBrowser检查,避免 SSR 问题 - 暂停和重置:需要创建新的 Subject,确保可以重新启动
- 精度问题:
interval不是完全精确的,可能受到浏览器性能影响
总结
使用 RxJS 实现定时器是一个优雅的解决方案,它通过响应式编程的方式:
- 代码清晰:使用 Observable 流处理时间,逻辑清晰
- 易于控制:可以轻松实现开始、暂停、重置等功能
- 自动清理:使用
takeUntil可以优雅地取消订阅 - 组合性强:可以轻松与其他 RxJS 操作符组合
记住:定时器是响应式编程的典型应用场景,使用 RxJS 可以让代码更加优雅和可维护。