实现一个与服务器时间校准、不会越走越慢的倒计时。

先理解原理和示例,再练习完整口述与追问。

不要用 setInterval 每次减一秒,定时器会延迟、页面后台会被节流,误差不断累积。正确做法:记录服务器时间与本地单调时钟的偏移,每次根据“结束时间 − 当前校准时间”重新计算剩余毫秒;下一次定时只等到下一个整秒边界;页面回到前台或重新联网时重新校准;剩余时间按向上取整秒展示。

用户可以修改本机时间,Date.now() 也会因为系统校时跳变,因此结束时间以服务器为准。获取服务器时间后,offset = serverNow − performance.now(),之后的校准当前时间为 performance.now() + offset;performance.now 单调递增,不受系统时间修改影响。网络延迟会让 serverNow 偏旧,可用往返时间的一半修正。

每次触发时重新计算剩余时间,即使某次回调延迟了 300ms,显示也不会出错,下一次等待 remaining % 1000 毫秒,对齐到显示的秒数变化点。后台标签页的定时器可能被限制为每分钟执行一次,回到前台的 visibilitychange 中调用 resync 立即刷新。展示时用向上取整,剩余 0.4 秒仍显示 1 秒,归零时刚好结束。

js
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 的偏移,每次回调都用结束时间减去校准后的当前时间重新算剩余值,下一次只等到下个整秒边界,显示按向上取整秒。页面回到前台或重新联网时重新获取服务器时间校准。活动是否结束最终仍由服务端判断。