真正能跑通的倒计时方案是服务端返回精确过期时间戳expire_at,前端每次渲染前动态计算剩余秒数,用settimeout递归更新并严格管理定时器生命周期和状态同步。

不能用 this.countdown-- 累减,也不能只靠 setInterval 控制节奏——跨端时 App 后台冻结、iOS 切页节流、H5 失焦暂停都会让倒计时错乱甚至卡死。真正能跑通的方案,是服务端给一个精确过期时间戳,前端每次渲染前动态算差值。
倒计时必须用服务端返回的 expire_at 时间戳锚定
接口返回必须带可信时间点,比如:{"expire_at": 1743165680}(秒级),前端不能自己算“60秒后”。否则不同设备时钟偏差、网络延迟、JS 执行滞后都会导致倒计时提前或延后结束。
-
expire_at是秒级时间戳,要转成毫秒:expire_at * 1000 - 每次更新 UI 前都重算:
Math.max(0, Math.floor((expire_at * 1000 - Date.now()) / 1000)) - 建议封装成计算属性,避免在多个地方重复写这行逻辑
- 别存
countdown: 60在 data 里初始化——点击前不显示倒计时,点击成功后再从服务端时间推导初始值
用 setTimeout 递归代替 setInterval
setInterval 容易失控:页面反复进出会叠加定时器,clearInterval 没清干净就残留,结束逻辑中断后 isCounting 永远为 true,按钮锁死。
- 声明一个响应式字段:
countdown: 0、isCounting: false、countdownTimer: null - 启动时赋值:
this.countdownTimer = setTimeout(() => { /* 重算 + 更新 + 再 setTimeout */ }, 300) - 每次回调第一件事是重算剩余秒数,不是“减一”
- 剩余 ≤ 0 时,立刻调用
this.resetCountdown()(清 timer、设countdown = 0、isCounting = false)
按钮禁用必须逻辑层 + UI 层双重控制
只写 :disabled="isCounting" 不够,用户可能绕过界面直接调用发送函数;也不该依赖 DOM 属性拦截行为。
- 点击事件开头加守卫:
if (this.isCounting) return - 请求发出去前立即设
this.isCounting = true,并更新按钮文字 - 接口失败或超时也要调用
this.resetCountdown(),否则按钮永远灰掉 - 别在
then外部直接改isCounting——页面可能已跳走,赋值失效
生命周期钩子中必须清理和恢复
App 切后台不触发 onUnload,只走 onHide;小程序被回收后重新激活,setInterval 已丢失但 data 还在旧值,看起来像“卡住”。
-
onHide中执行:if (this.countdownTimer) { clearTimeout(this.countdownTimer); this.countdownTimer = null; } -
onShow中立刻重算一次剩余时间,并根据结果决定是否重启定时器 - 不要在
mounted或onLoad里启动倒计时——那是没发请求的时候 - 如果需要退出应用后继续倒计时,得把
expire_at存到本地存储,onShow时读取校验
最易被忽略的是:倒计时结束时没清定时器 ID,或者没同步重置 isCounting,导致按钮再也点不了。状态和 UI 必须由同一份数据驱动,不能靠“视觉上归零”就认为逻辑也结束了。











