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

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

今天跟小盆友们讨论如何使用 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。

参考

相关推荐
JELEE.2 分钟前
Django登录注册完整代码(图片、邮箱验证、加密)
前端·javascript·后端·python·django·bootstrap·jquery
TeleostNaCl2 小时前
解决 Chrome 无法访问网页但无痕模式下可以访问该网页 的问题
前端·网络·chrome·windows·经验分享
前端大卫4 小时前
为什么 React 中的 key 不能用索引?
前端
你的人类朋友4 小时前
【Node】手动归还主线程控制权:解决 Node.js 阻塞的一个思路
前端·后端·node.js
小李小李不讲道理5 小时前
「Ant Design 组件库探索」五:Tabs组件
前端·react.js·ant design
毕设十刻5 小时前
基于Vue的学分预警系统98k51(程序 + 源码 + 数据库 + 调试部署 + 开发环境配置),配套论文文档字数达万字以上,文末可获取,系统界面展示置于文末
前端·数据库·vue.js
mapbar_front6 小时前
在职场生存中如何做个不好惹的人
前端
牧杉-惊蛰6 小时前
纯flex布局来写瀑布流
前端·javascript·css
一袋米扛几楼988 小时前
【软件安全】什么是XSS(Cross-Site Scripting,跨站脚本)?
前端·安全·xss
向上的车轮8 小时前
Actix Web适合什么类型的Web应用?可以部署 Java 或 .NET 的应用程序?
java·前端·rust·.net