前言
setTimeout会坑到每一个不懂它原理的人,或早或晚——沃兹基硕德。
作为实现定时任务的函数,setTimeout是每位前端入行时认识的基础功能之一了。它的使用或轻于鸿毛,被用在轮播图切换等地方,岁月静好,或重于泰山,被用在hack竞态问题,岌岌可危;还有一个被经常使用,但要么不出问题,要么一出就是大问题的场景——倒计时。用它做短信获取倒计时的同学表示没有问题,用它做需要长时间显示倒计时场景的同学可就没有那么幸运了,一般会碰到计时不准的问题,要么越来越慢,要么切换到其他标签页,切回来时发现时间静止了。
关于原因就不从盘古开天辟地开始讲了,关键词事件循环机制。而文章的起因是刚解决了一个bug,姑且记录一下。
问题表现
具体功能是一个长时间的倒计时,倒计时的周期可以是几天,用户有可能长时间停留在页面。bug的体现就是倒计时时长不准,比如刷新页面以后会发现新显示的倒计时和当前的差距很大。简化后的代码如下:
function countDown(endTime, nowTime) {
if (nowTime >= endTime) {
// 停止显示倒计时
return
}
const duration = endTime - nowTime
// 根据duration计算天时分秒
setTimeout(() => {
countdown(endTime, nowTime + 1000)
}, 1000)
}
// endTime是未来某天的时间戳
countDown(endTime, Date.now())
问题分析
可以看出,这种写法除了存在由于事件循环机制导致的延迟执行问题外,至少还包括这两个问题:
-
- 使用了本地时间,有可能不是那么可靠
-
- 每次执行函数都需要把endTime传入,再重复计算duration,造成资源浪费
第一个问题很好解决,从服务端获取时间即可,第二个问题稍微改一下写法:
function countDown(endTime, nowTime) {
let duration = endTime - nowTime
if (duration <= 0) {
// 停止显示倒计时
return
}
// 根据duration计算天时分秒
setTimeout(doCountDown, 1000)
function doCountDown() {
duration -= 1000
if (duration <= 0) {
// 停止显示倒计时
return
}
// 根据duration计算天时分秒
setTimeout(doCountDown, 1000)
}
}
// endTime是未来某天的时间戳,serverTime是从服务器获取的当前时间
countDown(endTime, serverTime)
剩下就是解决最关键的时长显示不准问题了。
问题解决
思路是在倒计时开始时记录一个开始时间,每次执行倒计时函数时,计算当前时间和开始时间的实际间隔与理论上的时间间隔的差值,在下次执行倒计时函数时,提前执行一点。
function countDown(endTime, nowTime) {
let duration = endTime - nowTime
if (duration <= 0) {
// 停止显示倒计时
return
}
// 根据duration计算天时分秒
const interval = 1000
const startTime = Date.now()
let count = 0
setTimeout(doCountDown, interval)
function doCountDown() {
let offset, nextTime
count++
offset = Date.now() - (startTime + count * interval)
nextTime = interval - offset
if (nextTime < 0) {
nextTime = 0
}
duration -= interval
if (duration <= 0) {
// 停止显示倒计时
return
}
// 根据duration计算天时分秒
setTimeout(doCountDown, nextTime)
}
}
// endTime是未来某天的时间戳,serverTime是从服务器获取的当前时间
countDown(endTime, serverTime)
长时间盯着倒计时,似乎是没有问题了,误差会被控制在1秒内。
新的问题
但这种方式仍有一个缺点:页面不活跃一段时间回来,你将看到倒计时的时间疯狂跳动,一直跳到正常的时间点。这是由于浏览器的策略导致的,简单粗暴的解决方式也有,那就是判断误差大于一定值的时候,重新请求服务器时间,重新执行倒计时。另外别忘了在离开页面时销毁定时器。
function countDown(endTime, nowTime) {
let duration = endTime - nowTime
if (duration <= 0) {
// 停止显示倒计时
return
}
// 根据duration计算天时分秒
const interval = 1000
const startTime = Date.now()
let count = 0
// 你在外面定义的timer变量
timer = setTimeout(doCountDown, interval)
function doCountDown() {
let offset, nextTime
count++
offset = Date.now() - (startTime + count * interval)
if (offset > 600 * interval) {
// 重新倒计时
return
}
nextTime = interval - offset
if (nextTime < 0) {
nextTime = 0
}
duration -= interval
if (duration <= 0) {
// 停止显示倒计时
return
}
// 根据duration计算天时分秒
// 你在外面定义的timer变量
timer = setTimeout(doCountDown, nextTime)
}
}
// endTime是未来某天的时间戳,serverTime是从服务器获取的当前时间
countDown(endTime, serverTime)
有没有一种可能,需求就是要在切换到其他程序的时候保持倒计时,比如根据倒计时发送心跳数据,那有办法实现吗?答案是有的,上web worker。
问题拓展
无非就是把倒计时的逻辑放到worker里,页面端与worker通信获取倒计时数据:
const worker = new Worker('time-count-worker.js')
worker.onmessage = (event) => {
// 处理event.data数据
}
worker.postMessage('countDown')
同样别忘了在离开页面时执行worker.terminate()
。
到这一步,精准倒计时大功告成。