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

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

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

参考

相关推荐
优雅永不过时·1 小时前
Three.js 原生 实现 react-three-fiber drei 的 磨砂反射的效果
前端·javascript·react.js·webgl·threejs·three
神夜大侠3 小时前
VUE 实现公告无缝循环滚动
前端·javascript·vue.js
明辉光焱3 小时前
【Electron】Electron Forge如何支持Element plus?
前端·javascript·vue.js·electron·node.js
柯南二号4 小时前
HarmonyOS ArkTS 下拉列表组件
前端·javascript·数据库·harmonyos·arkts
wyy72934 小时前
v-html 富文本中图片使用element-ui image-viewer组件实现预览,并且阻止滚动条
前端·ui·html
前端郭德纲4 小时前
ES6的Iterator 和 for...of 循环
前端·ecmascript·es6
王解4 小时前
【模块化大作战】Webpack如何搞定CommonJS与ES6混战(3)
前端·webpack·es6
欲游山河十万里4 小时前
(02)ES6教程——Map、Set、Reflect、Proxy、字符串、数值、对象、数组、函数
前端·ecmascript·es6
明辉光焱4 小时前
【ES6】ES6中,如何实现桥接模式?
前端·javascript·es6·桥接模式
PyAIGCMaster4 小时前
python环境中,敏感数据的存储与读取问题解决方案
服务器·前端·python