手写简单倒计时

105 阅读4分钟

需求

  1. 参数要求可传入倒计时间,获取倒计时步长(多久获取一次当前剩余倒计时)
  2. 要求监听倒计时时间
  3. 要求可以调用启动倒计时,暂停倒计时,重置倒计时

准备工作

  1. 使用eventemitter3提供事件监听的能力
  2. 准备返回值的 parseRemainDate 方法,用以格式化返回值
  3. 准备startstopreset方法,用于操作定时器
  4. 准备countDown方法,用于倒计时主要逻辑

倒计时原理

image.png

由这个图我们得知

  1. 首先我们要规定好 倒计时运行的时间范围 [lastTimeStamp, endTimeStamp],中间的区间范围差值就为初始的倒计时时间,随着时间不断推移,currentTimeStamp会向右移动(时间戳增加),因此就会不断减少剩余倒计时
  2. 假如我们在 [lastTimeStamp, endTimeStamp]中的任意一个位置暂停了,那么下一次我们恢复倒计时的时候,倒计时的运行范围就应该在 [第二次开始运行的时间点, 第二次开始运行时间点 + 第一次剩余的倒计时时间]

倒计时实现

类型声明

// 提供事件监听能力
import { EventEmitter } from "eventemitter3";

// 计时器内部状态
enum CountDownStatus {
  running,
  stopped,
}

// 事件枚举
export enum CountDownEventName {
  START = "start",
  STOP = "stop",
  RUNNING = "running",
  RESET = "reset",
  INIT = "init",
}

// 定义监听事件返回值
export type RemainTimeData = {
  days: number;
  hours: number;
  minutes: number;
  seconds: number;
  counts: number;
};

// 监听事件,以及其回调值
interface CountdownEventMap {
  [CountDownEventName.START]: [];
  [CountDownEventName.STOP]: [];
  [CountDownEventName.RESET]: [RemainTimeData];
  [CountDownEventName.RUNNING]: [RemainTimeData];
  [CountDownEventName.INIT]: [RemainTimeData];
}

变量初始化

export default class CountDown extends EventEmitter<CountdownEventMap> {
    // 基于毫秒时间,算出其他时间单位和毫秒的关系,方便将后面的时间戳转换成需要暴露的格式
    private static COUNT_IN_MILLSECOND: number = 1 * 100; // 1毫秒
    private static SECOND_IN_MILLSECOND: number = CountDown.COUNT_IN_MILLSECOND * 10; // 1秒
    private static MINUTE_IN_MILLSECOND: number = CountDown.SECOND_IN_MILLSECOND * 60; // 1分钟
    private static HOUR_IN_MILLSECOND: number = CountDown.MINUTE_IN_MILLSECOND * 60; // 1小时
    private static DAY_IN_MILLSECOND: number = CountDown.HOUR_IN_MILLSECOND * 24; // 1天

    private lastTimeStamp: number; // 上一次开始计时的时间戳
    private endTimeStamp: number; // 结束时的时间戳

    private countDownTime: number; // 倒计时间

    private remainTime: number; // 当前剩余倒计时秒数
    private step: number;
    private timer: number;

    private status: CountDownStatus;

    constructor(countDownTime: number, step = 1) {
    // 传入的单位都为秒,需要都转换为毫秒初始值
    super();

    // 初始化
    this.lastTimeStamp = Date.now(); // 上一次倒计时的时间戳
    this.countDownTime = countDownTime * 1000; // 初始化倒计时长
    this.remainTime = this.countDownTime; // 初始化当前剩余时间
    this.endTimeStamp = this.lastTimeStamp + this.countDownTime; // 初始化结束时间戳
    this.step = step * 1000; // 定时器步长
    this.status = CountDownStatus.stopped; // 定时器状态
    this.timer = setTimeout(() => {
      // 展示初始的倒计时,并在监听事件的回调函数中获取初始值
      this.emit(CountDownEventName.INIT, this.parseRemainDate(this.remainTime));
    }, 0);
  }
}

操作倒计时的方法

// 开启倒计时
public start() {
  this.status = CountDownStatus.running;

  // 创建倒计时本次运行的区间范围,并执行倒计时处理
  this.lastTimeStamp = Date.now();
  this.endTimeStamp = this.lastTimeStamp + this.remainTime;
  this.countDown();
}

// 停止倒计时
public stop() {
  // 改变计时器状态,停止倒计时
  this.status = CountDownStatus.stopped;
  this.emit(CountDownEventName.STOP);
  clearTimeout(this.timer);
}

// 重置倒计时
public reset() {
  if (this.status === CountDownStatus.stopped) {
    this.emit(
      CountDownEventName.RESET,
      this.parseRemainDate(this.countDownTime)
    );
  }
}

处理倒计时的核心方法 - countDown

private countDown() {
  // 若倒计时正在运行则不处理
  if (this.status !== CountDownStatus.running) return;

  // 通过结束时间和currentTimeStamp的差值,获取当前剩余的倒计时时间
  const currentTimeStamp = Date.now()
  this.remainTime = Math.max(this.endTimeStamp - currentTimeStamp, 0);
  this.emit(
    CountDownEventName.RUNNING,
    this.parseRemainDate(this.remainTime)
  );

  if (this.remainTime > 0) {
    if (this.timer) clearTimeout(this.timer);

    this.timer = setTimeout(() => {
      this.countDown();
    }, this.step);
  } else {
    // 倒计时结束
    this.stop();
  }
}

格式化剩余倒计时

private parseRemainDate(remainTime: number): RemainTimeData {
    let time = remainTime;
    const days = Math.floor(time / CountDown.DAY_IN_MILLSECOND);
    time = time % CountDown.DAY_IN_MILLSECOND;
    const hours = Math.floor(time / CountDown.HOUR_IN_MILLSECOND);
    time = time % CountDown.HOUR_IN_MILLSECOND;
    const minutes = Math.floor(time / CountDown.MINUTE_IN_MILLSECOND);
    time = time % CountDown.MINUTE_IN_MILLSECOND;
    const seconds = Math.floor(time / CountDown.SECOND_IN_MILLSECOND);
    time = time % CountDown.SECOND_IN_MILLSECOND;
    const counts = Math.floor(time / CountDown.COUNT_IN_MILLSECOND);

    return {
      days,
      hours,
      minutes,
      seconds,
      counts,
    };
  }

倒计时使用

// 实例化倒计时对象
const countDownTime = new MyCountDown(360, 0.1); // 360秒倒计时,并且每0.1秒给与剩余倒计时长

// 监听初始事件
countDownRef.current.on(CountDownEventName.INIT, (remainTimeData: RemainTimeData) => {
   // ...
});

// 监听重置事件
countDownRef.current.on(CountDownEventName.RESET, (remainTimeData: RemainTimeData) => {
    // ...
});

// 监听运行事件
countDownRef.current.on(CountDownEventName.RUNNING, (remainTimeData:RemainTimeData) => {
    // ...
});

注意

因为浏览器中的JS线程和GUI线程是互为阻塞的,因此,使用setTimeout可能会因为复杂渲染任务的阻塞导致其结果存在误差。

  1. 可以参考使用web worker的方式,通过启动单独的线程处理倒计时任务
  2. 当然也可以使用 requestAnimationFrame 替代setTimeout来处理倒计时计算,这是因为requestAnimationFrame只会在页面的下一次渲染前执行,页面渲染频率和屏幕的刷新率(60Hz, 120Hz, 144Hz)有关,可以保持在一定的频率下执行逻辑,可以参考

作者:天天鸭 《作为一个前端你连requestAnimationFrame的用法、优势和应用场景都搞不清楚?》