You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
<!DOCTYPE html><htmllang="en"><body><divid="time"></div><script>window.onload=function(){constelement=document.getElementById('time')conststartTime=performance.now()letcount=0setInterval(()=>{count++constcurrentTime=performance.now()consttime=(currentTime-startTime)/1000constrate=count/timeelement.innerHTML=`${count} call in ${time.toFixed(3)}s, or ${rate.toFixed(6)} calls per second.`},1000)}</script></body></html>
前言
倒计时、计时器是一个很常见的业务场景。要求很简单,但做起来也不太简单:
如果你将要实现的计时要体现在 DOM 上,它永远不可能百分百准确。
JavaScript 是单线程的(指主线程),注定了无法一边执行 JS 代码、一边更新 DOM。即便是 HTML5 提出的 Web Worker,它是可以在主动创建一些后台执行的线程,可它不能直接操作 DOM,它传递信息给主线程,也会受到 Event Loop 的影响,该排队还是得排队。
但就人眼来说,几毫秒、几十毫米的误差基本是无感的,这就可以算是一个准确、合格的倒计时。
通常要考虑的问题有:
setTimeout 和 setInterval
我认为还是要聊一聊 setTimeout 和 setInterval。
看个例子:
众所周知的原因,它至少 1s 之后才能打印
Hi~
。再看:
它跟 setTimeout 一样受 Event Loop 影响,自然不可能完美地每秒打印一次
Hi~
。用 setTimeout 模拟:
🙋 提问:它跟 setInterval 版本功能上等效的吗?
在 Google 上搜索「setInterval drift」关键词,可以看到很多相关的讨论帖子,比如:
怎么理解漂移呢?
它在 Chrome 126 表现很好,几乎是每一秒更新一次。
在 Firefox 127 上,当执行了大概 300 次之后,约漂移了 1s 左右。Safari 漂移也较为明显。
据查,Chrome 有做“自动修正”的处理(源码),即便是执行了 300 多次,甚至更多时,其漂移也很低,几乎可以忽略。尽管这种修正并不是规范所要求,但应该是开发者想要的结果。
除此之外,当页面挂起后台,为了省电和减少 CPU 占用,不同浏览器会采用一些策略,暂停或延长定时器的 Delay Time。
小结:
setTimeout(fn, delay)
的 delay 是最小开始执行时间,而且只会多不会少。setInterval(fn, delay)
的 delay 会左右“漂移”,累计执行次数越多,漂移越明显。在 Chrome 浏览器下有“修正”处理,每次实际执行的 delay 与传入的值很接近,可以当作没有误差。关于 Event Loop 推荐两个不错的视频:
不靠谱版本
尽管 setTimeout 和 setInterval 很多问题,但还是要用到它,我们要做的是尽可能减少误差。
假设有示例如下:
其中
countdown()
方法接收一个剩余的秒数seconds
。简陋版本:
假设在 🙋 处有一个耗时的同步任务,比如:
那么 setInterval 第一次回调的执行就可能发生在 N 秒之后,这样页面上的倒计时就更不准了。会出现过了 5s 之后,倒计时可能只减去 1s 的情况,显然这不是我们想要的。
即便没有耗时任务,如果被挂起后台,执行频率会变低,甚至暂停,重新回到前台剩余时间就不准了。
因此,timeLeft(剩余时间)要在 setInterval 回调函数内重新计算,修改如下:
这样,至少可以确保下一次更新的时候,剩余的时间是“准确”的。
假设在相对理想的环境中,页面上只剩下这个倒计时了,也没有阻塞主线程的(同步)任务,它几乎可以每秒执行一次 renderCounter,最起码人眼感知不到其中的误差。
但现实是,在不同浏览下,随着 setInterval 不停地执行,其 Delay Time 会产生偏差。比如 Safari 和 Firefox 可能会增加几毫秒,而 Chrome 甚至会“自动修复”这种时间偏差(这应该是开发者所期待的),也就是说 Delay Time 甚至会减少。
所以,页面看到的效果有可能是:
原因是:假设刚好在剩余 1m 30s 的时候
renderCounter()
,由于 Delay Time 的偏差(假设多了 10ms),导致下一次执行时得到 1m 28s < timeLeft < 1m 29s 的结果,导致页面跳过 29s 显示了 28s(前面使用了Math.floor()
来换算)的问题。综上,这个方案缺点如下:
setInterval()
仍在执行,占用 CPU 资源。-1
,偶尔会-2
。Date.now()
受系统时钟影响。改进版本
当页面挂起时,如果不想让定时器一直在后台执行,可以借助 visibilitychange 事件来处理。
该方案的缺点:
Date.now()
受系统时钟影响的问题。进阶版本
可以考虑 requestAnimationFrame,它会在页面重绘之前执行指定的回调函数。
它执行频率跟屏幕刷新率有关。比如屏幕刷新率为 60Hz,表示每秒刷新 60 次,即每 16.67ms 刷新一次以确保画面不卡顿。其他常见的 90Hz、120Hz、144Hz 的刷新率同理。
比如:
在刷新率为 60Hz 的显示器下,每秒执行 60 次,倒计时是足够准确了。但执行太频繁了,也不是我们想要的,还不如
setInterval(() => {}, 333)
呢。可以结合 setTimeout 解决频繁执行的问题,然后要解决的是:如何获取下一次更新的时间?
引入一个 Document Timeline,此时间轴对于每个文档(document)来说都是唯一的,并在文档的生命周期中持续存在。其时间原点(Time Origin)可通过
performance.timeOrigin
获取。要获取当前文档自创建以来(即相对于时间原点)所经过的时间,有两种方式:
document.timeline.currentTime
performance.now()
它们都返回一个相对高精度的毫秒数,但又有点区别。
举个例子:以 60Hz 的屏幕为例,页面每 16.67ms 更新一次。假设第三次更新完(当前时间记为 50ms),接着马上执行下一次 Tick,若时间过了 5ms,此时
document.timeline.currentTime
、performance.now()
分别为 50ms、55ms。等这次 Tick 执行完那一刻它俩的值又将同步,以此类推。接着,我们尝试修改下:
以下这行处理,目的是避免跳秒现象。举个例子,假设当前
timeLeft
为 2988ms,由于renderCounter()
里秒数转换是使用了Math.floor()
,它会被转为 2s,但实际上它更接近 3s,因此应该用Math.round()
作取整操作。在
renderCounter()
之前处理,也便于准确计算出下一秒的时间轴时间。最后,通过下一秒的时间点减去当前时间点,得出延迟时间。
这种方案的优点:
由于这种方案还用到了
setTimeout()
,跳秒问题还存在。假设主线程存在耗时任务,没办法及时执行其回调函数,因此可能会出现类似 4s 直接跳到 6s、7s 的情况。有些文章使用 Web Worker 来实现倒计时,因为它是独立于主线程,可以一直在后台线程进行计时,这样计时倒是准确。如果计时要体现在页面上,得每隔 1s 通知主线程更新 UI(Worker 无法直接操作 DOM)。但是,如果主线程被耗时任务占着,即便主线程接到通知了,但你还是要排队等主线程空闲下来。
因此,根本的解决办法应该是将耗时任务放在 Worker 执行,或者使用时间分片(Time Slicing)方案将耗时任务分成若干小任务,以让出空隙给主线程更新 UI,避免造成页面假死现象。
微信小程序版本
在小程序里,它们都不能用:
document.timeline.currentTime
window.performance.now()
window.requestAnimationFrame()
小程序有个
wx.getPerformance().now()
方法(文档未提到),它返回的是自 1970 年 1 月 1 日 0 点开始以来的毫秒数,调试发现其内部返回的就是Date.now()
,所以这玩意在这里压根没用。🙄window.performance.now()
返回自performance.timeOrigin
开始以来的毫秒数,不受系统时钟影响。wx.getPerformance().now()
返回自 1970 年 1 月 1 日 0 点开始以来的毫秒数,受系统时钟影响。既然小程序里面获取不到不受系统时钟影响的当前时间,唯有使用
Date.now()
了,并在onShow()
时重新校验。示例如下(小程序代码片段):
References
The text was updated successfully, but these errors were encountered: