20个例子掌握RxJS——第十三章使用 interval 和 scan 实现定时器

15 阅读5分钟

RxJS 实战:使用 interval 和 scan 实现定时器

概述

定时器是一个常见的功能,用于测量经过的时间。在 Web 开发中,我们经常需要实现秒表、倒计时等功能。本章将介绍如何使用 RxJS 的 intervalscantakeUntil 操作符实现一个功能完整的定时器。

定时器的基本概念

定时器用于测量从某个时间点开始经过的时间。常见的定时器场景包括:

  • 秒表功能:测量经过的时间
  • 倒计时器:从指定时间倒计时到 0
  • 任务计时:记录任务执行时间
  • 游戏计时:游戏中的计时功能

为什么使用 RxJS?

使用 RxJS 实现定时器有以下优势:

  1. 响应式编程:使用 Observable 流处理时间,代码更清晰
  2. 易于控制:可以轻松实现开始、暂停、重置等功能
  3. 自动清理:使用 takeUntil 可以优雅地取消订阅
  4. 组合性强:可以轻松与其他 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$ 发出值时,停止发出值

实战场景:实现一个秒表

假设我们需要实现一个秒表,具有开始、暂停、重置功能。

实现思路

  1. 使用 interval(1000) 每秒发出一个值
  2. 使用 startWith(0) 立即开始
  3. 使用 scan 累加时间
  4. 使用 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();

注意事项

  1. 内存泄漏:确保在组件销毁时取消订阅
  2. 变更检测:在 Angular 中,可能需要手动触发变更检测
  3. 浏览器环境:使用 isPlatformBrowser 检查,避免 SSR 问题
  4. 暂停和重置:需要创建新的 Subject,确保可以重新启动
  5. 精度问题interval 不是完全精确的,可能受到浏览器性能影响

总结

使用 RxJS 实现定时器是一个优雅的解决方案,它通过响应式编程的方式:

  • 代码清晰:使用 Observable 流处理时间,逻辑清晰
  • 易于控制:可以轻松实现开始、暂停、重置等功能
  • 自动清理:使用 takeUntil 可以优雅地取消订阅
  • 组合性强:可以轻松与其他 RxJS 操作符组合

记住:定时器是响应式编程的典型应用场景,使用 RxJS 可以让代码更加优雅和可维护

码云地址:gitee.com/leeyamaster…