前端如何实现准确的倒计时效果

1.场景

试想,现在有这样一个场景,用户有一个观看视频的任务,任务的总时间为3min,观看达到这个时间用户即可完成此任务,我们需要做一个完成倒计时,来提示用户还剩多长时间即可完成该任务。

最容易想到的实现方式是使用定时器setInterval,每隔1s倒计时减1,用户暂停播放时清除定时器,开始播放时重启定时器

let countdown = 3 * 60
let timer = null

// 停止倒计时
const stop = () => {
clearInterval(timer)
timer = null
}

// 开始倒计时
const start = () => {
if (!timer) {
timer = setInterval(() => {
if (countdown > 0) {
countdown--
console.log('countdown: ', countdown)
} else {
console.log('done')
stop()
}
}, 1000)
}
}

这样的实现方式在一般情况下不会出现大的问题,但是如果用户暂停次数比较多的话,会发现用户实际播放时长大于倒计时所记的时长。

想个比较及端的例子,用户每次播放时长不超过1s,那么我们的倒计时一直保持不变,显然我们实现的倒计时是不准确的,不可靠的。

可能你会想着缩短setInterval的间隔来减少这种误差,但是间隔过小,又有阻塞的可能,定时器本身也不是那么精确,总有几毫秒的误差,而且显然这种做法略显笨拙,

这时我们就要思考是不是需要换一种实现方式,更优雅一些。

要想每次暂停播放不损失那不到1s的时间,我们只有精确到ms才行,既然不能用setInterval间隔1ms,那我们是可以取当前时间的毫秒数的,这个是非常精确的,本身就是以ms为单位的

let countdown = 3 * 60
let time = 0
let startTime = 0
let timer = null

// 停止倒计时
const stop = () => {
time += (Date.now() - startTime)
clearInterval(timer)
timer = null
}

// 开始倒计时
const start = () => {
if (!timer) {
startTime = Date.now()
timer = setInterval(() => {
let currentCountdown = countdown - Math.floor((Date.now() - startTime + time) / 1000)
if (currentCountdown > 0) {
console.log('countdown: ', currentCountdown)
} else {
console.log('done')
stop()
}
}, 1000)
}
}

这里我们依然使用了setInterval,但是只是用来每隔一秒读取倒计时,用来实现用户视图层的更新,实际与倒计时实现并无太大关联,我们可以封装一个倒计时

class Countdown {
// 传入倒计时初始值(ms)
constructor(countdown) {
this.lastTime = 0
this.startTime = null
this.countdown = countdown
this.stoped = true
}

// 开始计时
start() {
this.startTime = Date.now()
this.stoped = false
}

// 停止计时
stop() {
this.lastTime += Date.now() - this.startTime
this.stoped = true
}

// 获取倒计时
getCountdown() {
let result
if (this.stoped) {
result = this.countdown - this.lastTime
} else {
result = this.countdown - (Date.now() - this.startTime) - this.lastTime
}
return result >= 0 ? result : 0
}
}

一但我们创建了一个Countdown实例,就可以随时通过调用实例的getCountdown方法获取当前的倒计时。在未开始start时,会返回倒计时总秒数;

开始倒计时后,我们可以指定一定的时间间隔读取当前的倒计时,如果你想每秒更新view的倒计时,可以每秒取样,或是更小的时间间隔;

停止倒计时后,我们只能读取到一个固定值,除非再次开启倒计时;

倒计时为0时,将一直返回0,表示倒计时完成,可以将此视为完成标志;

以上我们已经可以拿到准确的倒计时,不论是倒计时还未完成,还是已经完成返回0,都能准确表示当前倒计时状态,但还是存在一个问题,就是我们无法知道倒计时是何时完成的,即便我们读取倒计时的值为0时,只能说明倒计时已经完成,却无法准确知道合适完成

所以这里还需要补充一个完成回调,来告诉我们倒计时何时完成,以便执行后续的业务逻辑

class Countdown {
// 传入倒计时初始值(ms)及完成回调
constructor(countdown, callback) {
this.lastTime = 0
this.startTime = null
this.countdown = countdown
this.callback = callback
this.timer = null
}

// 开始计时
start() {
this.startTime = Date.now()
this.timer = setTimeout(() => {
this.callback && this.callback()
}, this.countdown - this.lastTime)
}

// 停止计时
stop() {
this.lastTime += Date.now() - this.startTime
if (this.timer) {
clearTimeout(this.timer)
this.timer = null
}
}

// 获取倒计时
getCountdown() {
let result
if (this.timer) {
const currentTime = Date.now() - this.startTime + this.lastTime
result = this.countdown - currentTime
} else {
result = this.countdown - this.lastTime
}
return result >= 0 ? result : 0
}
}