如何在 JavaScript 中获得“准确的”倒计时

众所周知,setTimeout意味着在最小阈值(m单位)之后运行脚本,setInterval意味着以最小阈值周期连续执行指定的脚本。请注意,我在这里使用术语“最小阈值”,因为它并不总是准确的。
为什么setTimeout和setInterval不准确?
要回答这个问题,你首先需要了解 JavaScript 宿主环境(browser或Node.js)中有一种称为事件循环的机制。现在简单介绍一下:
首先,为什么要有一个事件循环?
因为对于浏览器来说,它需要接收用户的交互来实现一些UI的改变,而JS脚本是在浏览器中单线程运行的,所以不能直接知道用户的操作。所以这里增加了一个事件循环机制,浏览器内部进程通知JS让JS执行指定的脚本。
对于setTimeout和setIntervalAPI,它们是特殊的,因为它们都可以生成宏任务。那么什么是宏任务呢?
宏任务可以理解为需要JS执行的任务。例如,用户交互点击会生成一个宏任务,浏览器会将点击的宏任务添加到任务队列中。当JS执行引擎空闲时,会取出队列头部的任务执行,这样点击绑定的回调函数就可以执行了。
也就是说,setTimeout并且setInterval不会立即执行。当我们在代码中调用它们时,首先会将它们加入到任务队列中进行排队,然后JS执行引擎会在空闲时从队列头部取出任务执行。
【如何在 JavaScript 中获得“准确的”倒计时】如果是定时任务,会检查是否超时。如果已过期,将被取出并执行。如果没有过期,就会执行下面的宏任务。
执行完成后会开始新一轮的循环,继续检查任务队列的头部。
如何在 JavaScript 中获得“准确的”倒计时
文章图片

这也解释了为什么它们不准确,因为当它们加入任务队列时,可能已经有任务排在它们前面,而它们必须排在这些任务后面。
因此,当前面的任务完成,轮到他们执行时,实时间隔可能已经超过了我们传递的值,这就是最小阈值的由来。实时间隔只能大于或等于我们传递的值。

事件循环机制可以查看其他资料,此处忽略了微任务、宏任务的区别,只为了说明为什么会导致计时不准确。
let count = 0; const startTime = Date.now(); setInterval(() => { count += 1000; console.log('deviation', Date.now() - (startTime + count)); }, 1000); // deviation: 2 // deviation: 5 // deviation: 4 // deviation: 3

从上面的代码中,我们可以看出setInterval总是不准确的。如果在代码中添加耗时任务,差异会越来越大(setTimeout是一样的)。
如何得到一个相对准确的setInterval?
1. 使用 while 简单粗暴,我们可以直接用while语句阻塞主线程,不断计算当前时间和下一次的时间差。一旦大于等于0,立即执行。
代码如下:
function sleepInterval(cb, time) { let count = 0, startTime = Date.now()while (count <= time) { let nowTime = Date.now() - startTime let nextTime = count + 1000 if (nowTime >= nextTime) { count = nextTime cb && cb(); } } }function foo() { console.log('foo') }sleepInterval(loga, 3000)

此函数可以用作 js 里的 sleep 函数,多了定时执行的功能
这种方法可以控制差值稳定在0,但是这个方法阻塞了JS执行线程,使得JS执行线程无法停止并从队列中取出任务。这会导致页面冻结,无法响应任何操作。这是破坏性的,因此不可取。
2. 使用requestAnimationFrame 浏览器提供了 requestAnimationFrame API,它告诉浏览器你要执行一个动画,并要求浏览器在下次重绘前调用指定的回调函数更新动画。该回调函数将在浏览器下一次重绘之前执行。每秒的执行次数会根据屏幕的刷新率来决定。60Hz的刷新率意味着每秒会有60次,也就是16.7ms左右。
function intervalTimer(time) { let counter = 1; const startTime = Date.now(); function main() { const nowTime = Date.now(); const nextTime = startTime + counter * time; if (nowTime - nextTime >= 0) { console.log('deviation', nowTime - nextTime); counter += 1; } window.requestAnimationFrame(main); } main(); } intervalTimer(1000); // deviation 5 // deviation 7 // deviation 9 // deviation 12

我们可以发现,由于16.7ms的间隔执行,很容易造成时间不准确。
3.使用setTimeout + 系统时间偏移 该方案的原理是利用当前系统的准确时间在每次之后进行补偿和校正,setTimeout以保证后面的计时时间是补偿后的时间,从而减小时间差。
function intervalTimer(callback, interval = 500) { let counter = 1; let timeoutId; const startTime = Date.now(); function main() { const nowTime = Date.now(); const nextTime = startTime + counter * interval; timeoutId = setTimeout(main, interval - (nowTime - nextTime)); console.log('deviation', nowTime - nextTime); counter += 1; callback(); }timeoutId = setTimeout(main, interval); return () => { clearTimeout(timeoutId); }; }let value = https://www.it610.com/article/10; const cancelTimer = intervalTimer(() => { if (value > 0) { value -= 1; } else { cancelTimer(); } }, 1000); // deviation 3 // deviation 1 // deviation 0 // deviation 2

可以看到,这个方案的时间差比较小,而且可预见的差值不会随着时间的推移而逐渐增大,总是稳定在可以接受的范围内。
综合来看,我们在不阻塞主线程的情况下实现稳定且相对准确的 setInterval,第三种方式这可能是获得准确倒计时的最佳解决方案。
号外!!!每天一点小精进,推荐你关注一下前端之路小程序,里面有你想看的热门文章、定时推送的前端周刊、面试必刷的八股文集合,更主要的是我们有一帮志同道合的热爱前端开发的小可爱们~
如何在 JavaScript 中获得“准确的”倒计时
文章图片

    推荐阅读