使用异步和循环实现一个计时器

使用异步和循环实现一个计时器

今天跟小盆友们讨论如何使用 setInterval 来实现计时器,发现小朋友对如何使用循环和异步实现计时器没有概念。 所以今天就打算用比较直观的实现来实现一个支持promise,以及相对准确的计时器的方案

这个计时器需要支持如下功能

  • 支持设置计时器时长(废话,基础功能)
  • 支持按照 interval: number 来为 onTick: (remaining: number) => void 进行进度通知

常规实现

一般情况,我们使用一个类,来实现计时器,原理也是比较简单。 设置一个 setTimeoutsetInterval 来实现计时器的功能。 在timeout的回调中,我们会清除interval,在interval的回调中,我们会调用onTick。

typescript 复制代码
type Timeout = ReturnType<typeof setTimeout>;
class Timer {
  #timeout: Timeout | null = null;
  #intervalTimeout: Timeout | null = null;
  #duration: number;
  #interval: number;
  #onTick: (remaining: number) => void;
  constructor(
    onTick: (remaining: number) => void, 
    {
        duration = Infinity,
        interval = 1000,
    } : {
        duration?: number,
        interval?: number,
    } = {}
  ) {
    this.#duration = duration;
    this.#interval = interval;
    this.#onTick = onTick;
  }
  // 开始计时
  start() {
    const startTime = Date.now();
    let remaining = duration;
    const interval = this.#interval;
    // 设置timeout,用于清除interval
    this.#timeout = setTimeout(() => {
      this.onTick(0);
      this.clear();
    }, remaining);
    // 设置interval,用于通知进度
    this.#intervalTimeout = setInterval(() => {
        // 计算剩余时间
        remaining -= interval;
        this.onTick(remaining);
    }, interval);
  }
  // 清除timeout和interval
  clear() {
    if (this.#timeout) {
        clearTimeout(this.timeout);
        this.#timeout = null;
    }
    if (this.#intervalTimeout) {
        clearInterval(this.#intervalTimeout);
        this.#intervalTimeout = null;
    }
  }
}

使用例子

typescript 复制代码
const timer = new Timer((remaining) => {
    console.log(`剩余时间: ${remaining / 1000}秒`);
}, {
    duration: 10000,
    interval: 1000,
});
timer.start();

这个实现比较普遍, 但是有三个问题,

  • 我们的计时器并不是很准确,因为我们的计时器是基于时间间隔来计算的,而不是基于时间点来计算的。 众所周知,在浏览器中,setTimeoutsetInterval 并不是很准确的,因为浏览器的事件循环机制,以及浏览器的性能问题,会导致这两个函数的执行时间并不是很准确。所以我们应该使用时间点来计算,而不是时间间隔来计算。
  • 我们缺少计算器完成的通知,我们只能在计时器完成的时候,调用onTick,但是我们无法知道计时器是否完成。
  • 代码不够直观。setTimeoutsetInterval 的使用,会导致代码的可读性变差。实际上,我们的计时器,只需要一个 setTimeout 就可以实现了。

使用Promise来实现

我们可以利用对 setTimeout 的封装,来实现一个支持promise的 setTimeout。 在这个基础上,我们可以实现一个支持promise的计时器。

首先是 setTimeout 的封装,我们利用 new Promise 来封装,这是一个比较常见的做法。

typescript 复制代码
function timeout(duration: number) {
  return new Promise((resolve) => {
    setTimeout<void>(resolve, duration);
  });
}

然后我们就可以实现一个支持promise的计时器了。这次我们为了简化逻辑,不再使用类,而是使用函数来实现。

typescript 复制代码
async function timer(
  onTick: (remaining: number) => void, 
  {
      duration = Infinity,
      interval = 1000,
  } : {
      duration?: number,
      interval?: number,
  } = {}
) {
    const startTime = Date.now();
    let remaining = duration;
    // 判断是否剩余时间
    while (remaining > 0) {
      // 等待一个interval
      await timeout(interval);
      // 计算剩余时间
      remaining -= interval;
      // 进度通知
      onTick(remaining);
    }
    onTick(0);
}

使用例子

typescript 复制代码
await timer((remaining) => {
    console.log(`剩余时间: ${remaining / 1000}秒`);
}, {
    duration: 10000,
    interval: 1000,
});

console.log('计时器完成');

目前为止,我们已经实现了一个支持promise的计时器,而且我们的计时器返回的promise,会在计时器完成的时候,resolve。这样我们就可以知道计时器是否完成了。 而且我们的代码也比较直观了,我们只需要一个 setTimeout 就可以实现了。

但是我们的计时器不准确的问题还是存在的,因为我们的计时器还是基于时间间隔来计算的,而不是基于时间点来计算的。

使用时间点来计算

我们可以让 timeout 的时间变短,并且在每次 timeout 的时候,计算剩余时间,这样我们就可以基于时间点来计算了。 至于这个时间应该多长,我们可以按照 interval 来计算,确保 timeout 调度的次数不会太多,而且时间点也相对准确。

同时,我们需要设置一个变量,用于额外辅助判断 onTick 是否需要被调用。

typescript 复制代码
async function timer(
  onTick: (remaining: number) => void, 
  {
      duration = Infinity,
      interval = 1000,
  } : {
      duration?: number,
      interval?: number,
  } = {}
) {
    const startTime = Date.now();
    let remaining = duration;
    let lastTickTime;
    while (remaining > 0) {
        // 计算timeout的时间
        // 确保timeout调用的次数不会太多,我们以interval的20分之一为timeout的时间
        // 同时,我们也确保timeout的时间不会太长,我们以4ms为timeout的时间
        await timeout(Math.max(interval / 20, 4));
        // 我们改用performance.now()来计算剩余时间, 因为performance.now()的精度更高
        const now = performance.now();
        remaining = startTime + duration - now;
        // 如果上次调用onTick的时间,距离现在的时间,大于interval,那么我们就调用onTick
        if (now - lastTickTime > interval) {
            onTick(remaining);
            lastTickTime = now;
        }
    }
    onTick(0);
}

我们需要确保 timeout 的时间不能太短。 因为浏览器的实现,在 setTimeout 嵌套超过5层的时候,浏览器会将最少的 setTimeout 调度时间设置为4ms。

目前为止,我们的实现已经比较完善了。我们应该注意到,timeout 在这段代码中,相当于一个心跳脉搏的作用, 我们可以将这个部分抽象出来,作为一个 heartbeat 对象,在多个timer中共享,避免重复创建timeout。

抽离heartbeat

我们利用异步生成器来实现这个 heartbeat 对象。

typescript 复制代码
async function* createHeartbeat(interval: number) {
    while (true) {
        await timeout(interval);
        // 返回当前时间点
        yield performance.now();
    }
}

然后我们就可以使用这个 createHeartbeat 对象来实现我们的计时器了。

typescript 复制代码
async function timer(
  onTick: (remaining: number) => void, 
  {
      duration = Infinity,
      interval = 1000,
      heartbeat = createHeartbeat(Math.max(interval / 20, 4)),
  } : {
      duration?: number,
      interval?: number,
      heartbeat?: AsyncGenerator<void>,
  } = {}
) {
    const startTime = Date.now();
    let remaining = duration;
    let lastTickTime;
    while (remaining > 0) {
        // 调用heartbeat.next(),来获取下一个心跳,同时等待下一个心跳
        const { value: now } = await heartbeat.next();
        remaining = startTime + duration - now;
        if (now - lastTickTime > interval) {
            onTick(remaining);
            lastTickTime = now;
        }
    }
    onTick(0);
}

使用例子

typescript 复制代码
const heartbeat = createHeartbeat(13);

const t1 = timer((remaining) => {
    console.log(`剩余时间: ${remaining / 1000}秒`);
}, {
    duration: 10000,
    interval: 1000,
    heartbeat,
});

const t2 = timer((remaining) => {
    console.log(`剩余时间: ${remaining / 1000}秒`);
}, {
    duration: 10000,
    interval: 1000,
    heartbeat,
});

await Promise.all([t1, t2]);

console.log('计时器完成');

当然,我们的 heartbeat 对象,也可以用于其他场合,比如动画,或者其他需要定时的场合。 同时,我们也可以用除了 setTimeout 以外的其他定时器,来实现这个 heartbeat 对象。

总结

我们使用异步和循环,实现了一个支持promise,以及相对准确的计时器。 这个计时器的实现,可以用于浏览器和nodejs环境,而且代码也比较直观,可以很容易的理解。 同时,我们的计时器也支持多个计时器共享一个 heartbeat 对象,这样可以避免重复创建timeout。

参考

相关推荐
gnip3 小时前
企业级配置式表单组件封装
前端·javascript·vue.js
一只叫煤球的猫4 小时前
写代码很6,面试秒变菜鸟?不卖课,面试官视角走心探讨
前端·后端·面试
excel5 小时前
Three.js 材质(Material)详解 —— 区别、原理、场景与示例
前端
掘金安东尼5 小时前
抛弃自定义模态框:原生Dialog的实力
前端·javascript·github
hj5914_前端新手9 小时前
javascript基础- 函数中 this 指向、call、apply、bind
前端·javascript
薛定谔的算法9 小时前
低代码编辑器项目设计与实现:以JSON为核心的数据驱动架构
前端·react.js·前端框架
Hilaku9 小时前
都2025年了,我们还有必要为了兼容性,去写那么多polyfill吗?
前端·javascript·css
yangcode9 小时前
iOS 苹果内购 Storekit 2
前端
LuckySusu10 小时前
【js篇】JavaScript 原型修改 vs 重写:深入理解 constructor的指向问题
前端·javascript
LuckySusu10 小时前
【js篇】如何准确获取对象自身的属性?hasOwnProperty深度解析
前端·javascript