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

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

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

参考

相关推荐
神所夸赞的夏天3 分钟前
如何获取多层json数据,存成dictionary,并取最大最小值
java·前端·json
红色的小鳄鱼3 分钟前
前端面试js手写
开发语言·前端·javascript
焦糖玛奇朵婷7 分钟前
健身房预约小程序开发、设计
java·大数据·服务器·前端·小程序
上海云盾王帅16 分钟前
WEB业务如何接入安全防护:从零到一的实战指南
前端·安全
用户0595401744618 分钟前
AI Agent记忆丢失踩坑实录:这个问题让我排查了3天
前端·css
web行路人21 分钟前
前端对Commands(斜杠命令)一些常用
前端·javascript·vue.js·vue
当时只道寻常22 分钟前
从零到一打造企业级全栈后台管理系统 —— 技术选型、工程化实践与深度思考
前端·全栈·前端工程化
竹林81823 分钟前
用 ethers.js 连 MetaMask 做钱包登录,我踩了三个坑才搞定跨页面状态同步
前端·javascript
饺子不吃醋23 分钟前
深入理解 Vue 3 的 setup(含 Composition API)
前端·vue.js
阿星做前端25 分钟前
重度 AI 编程用户的一天:我怎么把 Claude Code / Codex 工作流搬进浏览器工作台
前端·javascript·后端