为什么前端倒计时活动不宜直接用setTimeoutsetInterval?

摘要:在商城项目中,「倒计时活动」几乎是绕不开的需求: 秒杀、限时优惠、拼团、支付剩余时间…… 我相信很多都跟我一样一开始写出类似这样的代码: setInterval(() => { remainTime-- }, 1000
在商城项目中,「倒计时活动」几乎是绕不开的需求: 秒杀、限时优惠、拼团、支付剩余时间…… 我相信很多都跟我一样一开始写出类似这样的代码: setInterval(() => { remainTime-- }, 1000) 功能能跑,但线上问题也会跟着跑出来。 一、为什么 setTimeout / setInterval 不适合活动倒计时? 它们天生就不准 很多人对定时器有一个误解: setInterval(fn, 1000) ≠ 每 1000ms 准时执行 原因只有一个: JavaScript 是单线程的。 到这你先回想一下事件循环机制,有哪些?然后再往下看;如果实在想不起来就先上网搜下,或者看看我的单线程原理。 事件循环机制 主线程被渲染卡住 执行大量 JS GC、布局、重绘 都会导致定时器回调被延后执行。 倒计时的表现就是: 跳秒 变慢 和真实时间对不上 浏览器会「故意」降级定时器 这是活动倒计时最容易翻车的一点。 当页面进入以下状态: 后台 Tab 页面最小化 手机锁屏 浏览器会主动做这些事: 延长定时器触发间隔 甚至直接暂停执行 结果就是: 用户切个 Tab 回来, 倒计时还显示 10 秒, 实际活动已经结束。 对活动类业务,这是不能接受的。 setTimeout 递归,本质问题没变 可能有的会写成这样: function tick() { setTimeout(() => { remainTime-- tick() }, 1000) } 看起来比 setInterval 稳一点,但实际上: 依然受线程影响 依然受浏览器限流 依然不可靠 只是“写法高级了”,问题没解决。 二、倒计时的核心思路必须反过来 错误思路(很多第一版代码) “我现在有 60 秒,每秒减 1” 正确思路(真实业务) “活动有一个确定的结束时间点,我只计算当前时间与结束时间的差值” 正确的倒计时模型 后端返回活动结束时间戳(endTime) 前端永远不存“剩余秒数” 每次渲染时: const remain = endTime - Date.now() 前提是: Date.now() 是可信的 这样做的好处是: 页面卡顿不影响 切 Tab 不影响 页面刷新不影响 时间一定是真实世界的时间 三、requestAnimationFrame 在倒计时里的正确用法 那么有的就得来犟一下: 那是不是可以用 requestAnimationFrame? nonono,是这样的 requestAnimationFrame 适合“展示型倒计时”,不适合直接当计时器。 为什么 rAF 比 setInterval 好一点? 跟随浏览器刷新节奏(通常 60fps) 页面不可见时自动暂停(省性能) 不会出现多个定时器竞争 但它的问题也很明显: 后台直接停 不保证时间间隔 本质还是“帧驱动”,不是“时间驱动” 正确用法:rAF + 时间戳差值 function startCountdown(endTime, update) { function loop() { const remain = endTime - Date.now() if (remain <= 0) { update(0) return } update(remain) requestAnimationFrame(loop) } loop() } rAF 只负责触发更新 时间完全由 Date.now() 决定 不用 rAF 去「数秒」 这种方式非常适合: 大屏倒计时 动画数字变化 强 UI 表现的倒计时 四、真实业务里的性能坑 多个倒计时 = 多个定时器 列表页如果有 20 个活动: 20 个 setInterval 页面性能直线下降 组件卸载忘记清理 内存泄漏 幽灵定时器 难以排查的线上问题 前后端时间不同步 前端显示没结束 后端接口已判定结束 用户点击直接报错 五、重点来了:第三方方案怎么选? dayjs / date-fns(强烈推荐) 它们不是倒计时库,但非常适合做倒计时。 示例(dayjs): import dayjs from 'dayjs' const endTime = dayjs('2026-01-30 20:00:00') setInterval(() => { const diff = endTime.diff(dayjs(), 'second') console.log(diff > 0 ? diff : 0) }, 1000) 时间计算可靠 不依赖定时器精度 和后端时间模型一致 这是我线上最常用的方案之一。 自己封装一个「全局时间驱动器」 这是很多成熟项目最终都会走到的一步。 核心思想: 全局只存在一个 timer / rAF 所有倒计时组件订阅它 统一调度、统一销毁 简单示意: const listeners = new Set() setInterval(() => { const now = Date.now() listeners.forEach(fn => fn(now)) }, 1000) export function subscribe(fn) { listeners.add(fn) return () => listeners.delete(fn) } 组件只关心: subscribe(now => { remain.value = endTime - now }) 性能稳定 行为一致 易维护 UI 倒计时组件(慎用) 很多组件库提供: <Countdown /> <Timer /> 适合: 展示 Demo 非关键业务 不适合: 活动判定 支付 风控相关逻辑 展示可以用,业务别依赖。 六、SO 倒计时不是在“数秒”,而是点对点的时间差。 setTimeout / setInterval 只能当“触发器” requestAnimationFrame 只负责“渲染节奏” 真正的时间 永远来自时间戳差值 如果一个倒计时: 切 Tab 就不准 刷新就重置 和后端状态对不上 那它大概率不是 UI 问题,而是时间模型错了。 再记住三个原则: 前端时间永远不能当权威 用「服务端时间差」而不是本地时间(服务端多分布只允许一个地方定义) 关键状态以接口返回为准 为什么说这三个,大家可以好好思考下,评论区欢迎大家讨论!