必须以服务端endTime为唯一可信源,前端仅通过offset = serverTime - Date.now()校准本地时间,用setTimeout递归重算剩余毫秒并每200ms更新DOM,禁用setInterval及累减逻辑,以防切后台、锁屏、节流导致倒计时跳变或卡死。

秒杀倒计时和抢购功能必须以服务端 endTime 为唯一可信时间源,前端只做偏移校准(offset = serverTime - Date.now()),用 setTimeout 递归更新,禁用所有累减逻辑——否则锁屏、切后台、低端机节流都会导致倒计时卡住或跳变,用户点“抢购中”时活动早已结束。
为什么不能用 setInterval 或 this.remaining--
系统后台休眠、iOS 节流、H5 切页、小程序 setData 延迟,都会让累减值脱离真实时间。比如:
- 用户切到微信聊天再切回来,
remaining可能卡在 27 不动,或直接跳到 -3 - Android 省电模式下
setTimeout延迟可达 2–5 秒,累减逻辑完全失效 -
setInterval不感知页面生命周期,onHide后仍在跑,回来时时间已错位
怎么正确初始化服务端时间校准
后端必须返回两个字段:serverTime(当前服务器毫秒时间戳)和 endTime(活动截止毫秒时间戳)。前端只做一次偏移计算:
- 在
onLoad请求接口后立刻执行:offset = serverTime - Date.now() - 把
offset存为全局响应式变量(如const offset = ref(0)),所有倒计时共享 - 后续所有时间计算统一用:
Math.max(0, props.endTime - (Date.now() + offset.value)) - 别存
endTime - serverTime这种相对值——服务端时间漂移时无法自愈
怎么用 setTimeout 递归更新且防残留
每次更新前重算剩余毫秒数,间隔设为 200ms(非 1000ms),既避免跳秒感,又减少响应式开销:
- 启动倒计时前先清旧定时器:
if (timerRef.value) clearTimeout(timerRef.value) - 更新函数内第一行就重算:
const remaining = Math.max(0, props.endTime - (Date.now() + offset.value)) - 只在
remaining > 0时继续递归:if (remaining > 0) timerRef.value = setTimeout(tick, 200) - 倒计时归零时显式重置状态:
isCounting.value = false,否则按钮永远禁用 - 不要在
onHide中clearTimeout——它本就没在跑;切回来时直接继续算即可
多商品列表里每个按钮怎么独立倒计时
一个页面多个抢购入口?不能共用定时器,也不能靠数组索引改值:
- 每个商品数据对象上挂载独立的
remaining和isCounting(用ref或reactive包裹) - 全局只维护一份
offset,所有按钮共享同一套服务端时间体系 - 新订单加载进来时,对每一项调用
initCountdown(item),内部仍走setTimeout+offset逻辑 - 倒计时归零后,用
orders.splice(index, 1)删除对应项,而不是靠 v-if 隐藏
最易被忽略的是:服务端时间必须毫秒精度,且需监控时钟误差——若某次心跳包带的 serverTime 比上次偏差超 500ms,应平滑过渡而非硬切,否则 UI 会突然跳变。











