Open toFrankie opened 3 months ago
倒计时、计时器是一个很常见的业务场景。要求很简单,但做起来也不太简单:
如果你将要实现的计时要体现在 DOM 上,它永远不可能百分百准确。
JavaScript 是单线程的(指主线程),注定了无法一边执行 JS 代码、一边更新 DOM。即便是 HTML5 提出的 Web Worker,它是可以在主动创建一些后台执行的线程,可它不能直接操作 DOM,它传递信息给主线程,也会受到 Event Loop 的影响,该排队还是得排队。
但就人眼来说,几毫秒、几十毫米的误差基本是无感的,这就可以算是一个准确、合格的倒计时。
通常要考虑的问题有:
我认为还是要聊一聊 setTimeout 和 setInterval。
看个例子:
setTimeout(() => { console.log('Hi~') }, 1000)
众所周知的原因,它至少 1s 之后才能打印 Hi~。
Hi~
setTimeout(fn, delay) 的 delay 是最小开始执行时间,而且只会多不会少。
setTimeout(fn, delay)
delay
再看:
setInterval(() => { console.log('Hi~') }, 1000)
它跟 setTimeout 一样受 Event Loop 影响,自然不可能完美地每秒打印一次 Hi~。
用 setTimeout 模拟:
setTimeout(function tick() { console.log('Hi~') setTimeout(tick, 1000) }, 1000)
🙋 提问:它跟 setInterval 版本功能上等效的吗?
答案是不一样的,setInterval 会产生一种“漂移”(drift)现象。
在 Google 上搜索「setInterval drift」关键词,可以看到很多相关的讨论帖子,比如:
怎么理解漂移呢?
<!DOCTYPE html> <html lang="en"> <body> <div id="time"></div> <script> window.onload = function () { const element = document.getElementById('time') const startTime = performance.now() let count = 0 setInterval(() => { count++ const currentTime = performance.now() const time = (currentTime - startTime) / 1000 const rate = count / time element.innerHTML = `${count} call in ${time.toFixed(3)}s, or ${rate.toFixed(6)} calls per second.` }, 1000) } </script> </body> </html>
CodePen Demo
它在 Chrome 126 表现很好,几乎是每一秒更新一次。
34 call in 34.001s, or 0.999965 calls per second.
在 Firefox 127 上,当执行了大概 300 次之后,约漂移了 1s 左右。Safari 漂移也较为明显。
317 call in 318.242s, or 0.996097 calls per second.
据查,Chrome 有做“自动修正”的处理(源码),即便是执行了 300 多次,甚至更多时,其漂移也很低,几乎可以忽略。尽管这种修正并不是规范所要求,但应该是开发者想要的结果。
除此之外,当页面挂起后台,为了省电和减少 CPU 占用,不同浏览器会采用一些策略,暂停或延长定时器的 Delay Time。
挂起后台的情况包含但不限于:有其他处于活跃状态的标签、窗口最小化、网页内容完全不可见、屏幕锁定、移动设备回到桌面等。
小结:
setInterval(fn, delay)
关于 Event Loop 推荐两个不错的视频:
尽管 setTimeout 和 setInterval 很多问题,但还是要用到它,我们要做的是尽可能减少误差。
假设有示例如下:
<div id="countdown">0 days, 0 hours, 0 minutes, 0 seconds</div>
window.onload = function () { // 倒计时时长(秒) const seconds = 90 countdown(seconds) } function countdown(seconds) { // TODO: 待实现... } // 倒计时展示形式 function renderCounter(timeLeft) { const secondInMillisecond = 1000 const minuteInMillisecond = secondInMillisecond * 60 const hourInMillisecond = minuteInMillisecond * 60 const dayInMillisecond = hourInMillisecond * 24 const dayLeft = Math.floor(timeLeft / dayInMillisecond) const hourLeft = Math.floor((timeLeft % dayInMillisecond) / hourInMillisecond) const minuteLeft = Math.floor((timeLeft % hourInMillisecond) / minuteInMillisecond) const secondLeft = Math.floor((timeLeft % minuteInMillisecond) / secondInMillisecond) const html = `${dayLeft} days, ${hourLeft} hours, ${minuteLeft} minutes, ${secondLeft} seconds` document.getElementById('countdown').innerHTML = html }
其中 countdown() 方法接收一个剩余的秒数 seconds。
countdown()
seconds
我不关心是用本地时间,还是服务器时间算出来的,你只需告诉我剩余多少秒就行。
简陋版本:
function countdown(seconds) { const startTime = Date.now() const endTime = startTime + seconds * 1000 let timeLeft = endTime - startTime const timer = setInterval(() => { timeLeft -= 1000 if (timeLeft <= 0) { clearInterval(timer) renderCounter(0) return } renderCounter(timeLeft) }, 1000) renderCounter(timeLeft) // 🙋 }
假设在 🙋 处有一个耗时的同步任务,比如:
function longRunningTask() { for (let i = 0; i < 1000000000; i++) { // do something... } }
实际中,耗时任务不应放在主线程中执行,这里只用于表达上述例子的缺点。
那么 setInterval 第一次回调的执行就可能发生在 N 秒之后,这样页面上的倒计时就更不准了。会出现过了 5s 之后,倒计时可能只减去 1s 的情况,显然这不是我们想要的。
即便没有耗时任务,如果被挂起后台,执行频率会变低,甚至暂停,重新回到前台剩余时间就不准了。
因此,timeLeft(剩余时间)要在 setInterval 回调函数内重新计算,修改如下:
function countdown(seconds) { const startTime = Date.now() const endTime = startTime + seconds * 1000 const timer = setInterval(() => { const now = Date.now() const timeLeft = endTime - now if (timeLeft <= 0) { clearInterval(timer) renderCounter(0) return } renderCounter(timeLeft) }, 1000) renderCounter(endTime - startTime) }
这样,至少可以确保下一次更新的时候,剩余的时间是“准确”的。
假设在相对理想的环境中,页面上只剩下这个倒计时了,也没有阻塞主线程的(同步)任务,它几乎可以每秒执行一次 renderCounter,最起码人眼感知不到其中的误差。
但现实是,在不同浏览下,随着 setInterval 不停地执行,其 Delay Time 会产生偏差。比如 Safari 和 Firefox 可能会增加几毫秒,而 Chrome 甚至会“自动修复”这种时间偏差(这应该是开发者所期待的),也就是说 Delay Time 甚至会减少。
所以,页面看到的效果有可能是:
0 days, 0 hours, 1 minutes, 30 seconds ↓ 0 days, 0 hours, 1 minutes, 28 seconds ↓ ...
原因是:假设刚好在剩余 1m 30s 的时候 renderCounter(),由于 Delay Time 的偏差(假设多了 10ms),导致下一次执行时得到 1m 28s < timeLeft < 1m 29s 的结果,导致页面跳过 29s 显示了 28s(前面使用了 Math.floor() 来换算)的问题。
renderCounter()
Math.floor()
如果页面有其他耗时任务或者挂起后台时,这种偏差只会更明显。
综上,这个方案缺点如下:
setInterval()
-1
-2
Date.now()
当页面挂起时,如果不想让定时器一直在后台执行,可以借助 visibilitychange 事件来处理。
function countdown(seconds) { const startTime = Date.now() const endTime = startTime + seconds * 1000 const paint = () => { const now = Date.now() const timeLeft = endTime - now if (timeLeft <= 0) { clearInterval(timer) renderCounter(0) return } renderCounter(timeLeft) } let timer = setInterval(paint, 1000) handleVisibilityChange({ hiddenFn: () => { clearInterval(timer) }, visibleFn: () => { if (timer) clearInterval(timer) timer = setInterval(paint, 1000) }, }) renderCounter(endTime - startTime) } function handleVisibilityChange({ hiddenFn = () => {}, visibleFn = () => {} }) { document.addEventListener('visibilitychange', event => { if (document.visibilityState === 'hidden') { hiddenFn(event) return } visibleFn(event) }) }
该方案的缺点:
可以考虑 requestAnimationFrame,它会在页面重绘之前执行指定的回调函数。
出于省电和性能考虑,当页面挂起时,该 API 会暂停执行。
它执行频率跟屏幕刷新率有关。比如屏幕刷新率为 60Hz,表示每秒刷新 60 次,即每 16.67ms 刷新一次以确保画面不卡顿。其他常见的 90Hz、120Hz、144Hz 的刷新率同理。
比如:
function countdown(seconds) { const startTime = Date.now() const endTime = startTime + seconds * 1000 renderCounter(endTime - startTime) let rafId = requestAnimationFrame(function paint() { const now = Date.now() const timeLeft = endTime - now if (timeLeft <= 0) { renderCounter(0) cancelAnimationFrame(rafId) return } renderCounter(timeLeft) rafId = requestAnimationFrame(paint) }) }
在刷新率为 60Hz 的显示器下,每秒执行 60 次,倒计时是足够准确了。但执行太频繁了,也不是我们想要的,还不如 setInterval(() => {}, 333) 呢。
setInterval(() => {}, 333)
可以结合 setTimeout 解决频繁执行的问题,然后要解决的是:如何获取下一次更新的时间?
引入一个 Document Timeline,此时间轴对于每个文档(document)来说都是唯一的,并在文档的生命周期中持续存在。其时间原点(Time Origin)可通过 performance.timeOrigin 获取。
performance.timeOrigin
要获取当前文档自创建以来(即相对于时间原点)所经过的时间,有两种方式:
document.timeline.currentTime
performance.now()
它们都返回一个相对高精度的毫秒数,但又有点区别。
举个例子:以 60Hz 的屏幕为例,页面每 16.67ms 更新一次。假设第三次更新完(当前时间记为 50ms),接着马上执行下一次 Tick,若时间过了 5ms,此时 document.timeline.currentTime、performance.now() 分别为 50ms、55ms。等这次 Tick 执行完那一刻它俩的值又将同步,以此类推。
简单来说,document.timeline.currentTime 是当前帧起始那一刻相对于时间原点经过的毫秒数。而 performance.now() 是“真正”当前时间相当于时间原点经过的毫秒数。所以,实际表现后者总是比前者大一点。
接着,我们尝试修改下:
function countdown(seconds) { const startTime = document.timeline ? document.timeline.currentTime : performance.now() const endTime = startTime + seconds * 1000 const paint = () => { const now = document.timeline ? document.timeline.currentTime : performance.now() const timeLeft = endTime - now if (timeLeft <= 0) { renderCounter(0) return } const roundedTimeLeft = Math.round(timeLeft / 1000) * 1000 renderCounter(roundedTimeLeft) const nextTime = startTime + (seconds * 1000 - roundedTimeLeft) + 1000 const nextDelay = nextTime - performance.now() setTimeout(() => requestAnimationFrame(paint), nextDelay) } paint() }
考虑 Document API、[High Resolution Time API](https://caniuse.com/?search=performance.now()) 兼容性。
以下这行处理,目的是避免跳秒现象。举个例子,假设当前 timeLeft 为 2988ms,由于 renderCounter() 里秒数转换是使用了 Math.floor(),它会被转为 2s,但实际上它更接近 3s,因此应该用 Math.round() 作取整操作。
timeLeft
Math.round()
注意,这里是秒数取整,而不是毫秒数取整。
const roundedTimeLeft = Math.round(timeLeft / 1000) * 1000
在 renderCounter() 之前处理,也便于准确计算出下一秒的时间轴时间。
const nextTime = startTime + (seconds * 1000 - roundedTimeLeft) + 1000
最后,通过下一秒的时间点减去当前时间点,得出延迟时间。
const nextDelay = nextTime - performance.now()
这种方案的优点:
由于这种方案还用到了 setTimeout(),跳秒问题还存在。假设主线程存在耗时任务,没办法及时执行其回调函数,因此可能会出现类似 4s 直接跳到 6s、7s 的情况。
setTimeout()
有些文章使用 Web Worker 来实现倒计时,因为它是独立于主线程,可以一直在后台线程进行计时,这样计时倒是准确。如果计时要体现在页面上,得每隔 1s 通知主线程更新 UI(Worker 无法直接操作 DOM)。但是,如果主线程被耗时任务占着,即便主线程接到通知了,但你还是要排队等主线程空闲下来。
因此,根本的解决办法应该是将耗时任务放在 Worker 执行,或者使用时间分片(Time Slicing)方案将耗时任务分成若干小任务,以让出空隙给主线程更新 UI,避免造成页面假死现象。
小程序框架的逻辑层并非运行在浏览器中,因此 JavaScript 在 web 中一些能力都无法使用,如 window,document 等。
在小程序里,它们都不能用:
window.performance.now()
window.requestAnimationFrame()
小程序有个 wx.getPerformance().now() 方法(文档未提到),它返回的是自 1970 年 1 月 1 日 0 点开始以来的毫秒数,调试发现其内部返回的就是 Date.now(),所以这玩意在这里压根没用。🙄
wx.getPerformance().now()
既然小程序里面获取不到不受系统时钟影响的当前时间,唯有使用 Date.now() 了,并在 onShow() 时重新校验。
onShow()
示例如下(小程序代码片段):
import { getServerTime } from '../../utils/index' // 截止时间:2024/07/28 23:59:59 const DEADLINE_TIME = new Date(2024, 6, 28, 23, 59, 59).getTime() Page({ data: { formattedCountdown: '', }, async onShow() { // TIPS: 小程序需配置请求域名,获取服务器时间根据实际调整,比如发起 HEAD 请求获取 header.date 等方式。 const now = await getServerTime() const secondsLeft = Math.floor((DEADLINE_TIME - now) / 1000) this.countdown(secondsLeft) }, onUnload() { clearTimeout(this._countdown_timer) }, countdown(seconds) { const startTime = Date.now() const endTime = startTime + seconds * 1000 // 避免 onShow 后有多个定时器在跑 clearTimeout(this._countdown_timer) const paint = () => { const now = Date.now() const timeLeft = endTime - now if (timeLeft <= 0) { this.renderCounter(0) return } const roundedTimeLeft = Math.round(timeLeft / 1000) * 1000 this.renderCounter(roundedTimeLeft) const nextTime = startTime + (seconds * 1000 - roundedTimeLeft) + 1000 const nextDelay = nextTime - Date.now() this._countdown_timer = setTimeout(() => paint(), nextDelay) } paint() }, renderCounter(timeLeft) { const secondInMillisecond = 1000 const minuteInMillisecond = secondInMillisecond * 60 const hourInMillisecond = minuteInMillisecond * 60 const dayInMillisecond = hourInMillisecond * 24 const dayLeft = Math.floor(timeLeft / dayInMillisecond) const hourLeft = Math.floor((timeLeft % dayInMillisecond) / hourInMillisecond) const minuteLeft = Math.floor((timeLeft % hourInMillisecond) / minuteInMillisecond) const secondLeft = Math.floor((timeLeft % minuteInMillisecond) / secondInMillisecond) const formattedStr = `${dayLeft} days, ${hourLeft} hours, ${minuteLeft} minutes, ${secondLeft} seconds` this.setData({ formattedCountdown: formattedStr }) }, })
前言
倒计时、计时器是一个很常见的业务场景。要求很简单,但做起来也不太简单:
如果你将要实现的计时要体现在 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