实现一个与服务器时间校准、不会越走越慢的倒计时。
先理解原理和示例,再练习完整口述与追问。
回答要点
不要用 setInterval 每次减一秒,定时器会延迟、页面后台会被节流,误差不断累积。正确做法:记录服务器时间与本地单调时钟的偏移,每次根据“结束时间 − 当前校准时间”重新计算剩余毫秒;下一次定时只等到下一个整秒边界;页面回到前台或重新联网时重新校准;剩余时间按向上取整秒展示。
原理拆解
用户可以修改本机时间,Date.now() 也会因为系统校时跳变,因此结束时间以服务器为准。获取服务器时间后,offset = serverNow − performance.now(),之后的校准当前时间为 performance.now() + offset;performance.now 单调递增,不受系统时间修改影响。网络延迟会让 serverNow 偏旧,可用往返时间的一半修正。
每次触发时重新计算剩余时间,即使某次回调延迟了 300ms,显示也不会出错,下一次等待 remaining % 1000 毫秒,对齐到显示的秒数变化点。后台标签页的定时器可能被限制为每分钟执行一次,回到前台的 visibilitychange 中调用 resync 立即刷新。展示时用向上取整,剩余 0.4 秒仍显示 1 秒,归零时刚好结束。
示例与推演
function createCountdown({ serverNow, endAt, onTick, onEnd, now = () => performance.now() }) {
let offset = serverNow - now()
let timer = null
let stopped = false
function tick() {
if (stopped) return
const remaining = Math.max(0, endAt - (now() + offset))
onTick(remaining)
if (remaining === 0) {
stopped = true
if (onEnd) onEnd()
return
}
timer = setTimeout(tick, remaining % 1000 || 1000)
}
tick()
return {
stop() {
stopped = true
clearTimeout(timer)
},
resync(nextServerNow) {
if (stopped) return
offset = nextServerNow - now()
clearTimeout(timer)
tick()
}
}
}
function splitDuration(ms) {
const totalSeconds = Math.ceil(Math.max(0, ms) / 1000)
return {
days: Math.floor(totalSeconds / 86400),
hours: Math.floor((totalSeconds % 86400) / 3600),
minutes: Math.floor((totalSeconds % 3600) / 60),
seconds: totalSeconds % 60
}
}
推演:服务器时间为 10:00:00.000,活动结束时间为 10:00:02.500,首次回调剩余 2500ms,显示 3 秒,等待 500ms;之后剩余 2000ms,显示 2 秒,等待 1000ms;以此类推,到 0 时调用 onEnd。若某次回调晚了 200ms,剩余时间按真实时间计算为 1800ms,显示 2 秒,下一次只等 800ms,误差不会累积。
易错点
用 setInterval 计数在后台标签页和主线程繁忙时会越走越慢;只在客户端判断活动结束不安全,下单等操作仍需服务端校验时间。
参考回答可直接练习口述
我不会用 setInterval 每次减一秒,因为定时器延迟和后台节流会累积误差。做法是拿到服务器时间后,记录它和 performance.now 的偏移,每次回调都用结束时间减去校准后的当前时间重新算剩余值,下一次只等到下个整秒边界,显示按向上取整秒。页面回到前台或重新联网时重新获取服务器时间校准。活动是否结束最终仍由服务端判断。