使用异步和循环实现一个计时器
今天跟小盆友们讨论如何使用 setInterval
来实现计时器,发现小朋友对如何使用循环和异步实现计时器没有概念。 所以今天就打算用比较直观的实现来实现一个支持promise,以及相对准确的计时器的方案
这个计时器需要支持如下功能
- 支持设置计时器时长(废话,基础功能)
- 支持按照
interval: number
来为onTick: (remaining: number) => void
进行进度通知
常规实现
一般情况,我们使用一个类,来实现计时器,原理也是比较简单。 设置一个 setTimeout
和 setInterval
来实现计时器的功能。 在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();
这个实现比较普遍, 但是有三个问题,
- 我们的计时器并不是很准确,因为我们的计时器是基于时间间隔来计算的,而不是基于时间点来计算的。 众所周知,在浏览器中,
setTimeout
和setInterval
并不是很准确的,因为浏览器的事件循环机制,以及浏览器的性能问题,会导致这两个函数的执行时间并不是很准确。所以我们应该使用时间点来计算,而不是时间间隔来计算。 - 我们缺少计算器完成的通知,我们只能在计时器完成的时候,调用onTick,但是我们无法知道计时器是否完成。
- 代码不够直观。
setTimeout
和setInterval
的使用,会导致代码的可读性变差。实际上,我们的计时器,只需要一个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。