1. 活动页倒计时刷新归零,问题到底出在哪
页面倒计时在活动页、秒杀页里几乎是标配,但很多人第一次写都会踩同一个坑:用setInterval每秒把变量减一,页面一刷新,变量回到初始值,倒计时直接重置。用户抢购到一半刷新页面,发现剩余时间又变回 15 秒,体验非常割裂。
这个问题的本质是:倒计时的状态存在了内存里,而内存会随页面刷新被清空。setInterval只是驱动渲染的引擎,它不负责保存状态。真正需要持久化的是「截止时间戳」这个数据,而不是「剩余秒数」这个瞬时值。
我见过两种常见写法,都有问题。第一种是纯内存变量:
let seconds = 15; setInterval(() => { seconds--; document.getElementById('time').innerText = seconds; }, 1000);刷新即归零。第二种是照搬网上片段,把剩余秒数存进localStorage,每秒读一次写一次:
localStorage.setItem('gooseTime', '5'); function run() { var s = document.getElementById('goose_time'); s.innerHTML = localStorage.getItem('gooseTime'); if (!localStorage.getItem('gooseTime') || s.innerHTML <= 0) { clearInterval(timeId); localStorage.removeItem('gooseTime'); return false; } s.innerHTML = s.innerHTML * 1 - 1; localStorage.setItem('gooseTime', s.innerHTML); } var timeId = setInterval('run();', 1000);这段代码能跑,但隐患不少。它存的是「剩余秒数」,每秒读写一次localStorage,性能浪费;setInterval('run();', 1000)用字符串传参,会走eval路径,严格模式下直接报错;更关键的是,它没有记录「这个倒计时是什么时候开始的」,一旦用户手动改系统时间或者标签页被浏览器挂起,倒计时就会跳变。
正确的思路是:只存截止时间戳,渲染时用「截止时间 - 当前时间」实时计算剩余量。这样刷新恢复、跨标签页同步、时间校准三件事都能自然解决。下面我把这套方案拆成可复制的工具函数和配置,一步步带你落地。
本篇适合正在做活动页、秒杀页、限时优惠弹窗的前端同学,也适合想搞清楚localStorage缓存与setInterval配合方式的人。核心检索词就是「页面倒计时 localStorage 缓存 setInterval 刷新恢复」,你跟着做就能得到不跳变、不重置的倒计时。
2. 用 TaoToken 快速搭一个可调试的倒计时实验环境
写倒计时这种逻辑,最怕的是「改一行、刷一次、看结果」的循环太慢。我习惯先搭一个能实时对话调试的环境,把边界情况问清楚再动手。这里用 TaoToken 来做辅助,它提供统一的模型对话入口,适合边写边验证思路。
TaoToken 是什么?简单说,它是一个聚合式的 AI 模型调用平台,你可以在一个控制台里切换不同模型来对话、写代码、做代码审查。对前端同学来说,最实用的场景是:把倒计时的边界问题(比如标签页挂起后setInterval会不会累积、Date.now()和performance.now()该用哪个)直接丢给模型,让它给出对比和示例,比翻文档快。
适合谁用?三类人:一是刚接触localStorage和定时器、需要有人解释每一步为什么这么写的新手;二是做活动页需要快速验证倒计时逻辑的老手;三是想把倒计时封装成通用工具函数、需要模型帮忙做代码审查的团队。
接入前你需要准备三样东西,我把它叫「三件套」:
- Base URL:
https://taotoken.net/api - API Key:在控制台的 API Keys 页面创建,形如
sk-开头的一串字符 - Model ID:在模型对话页面能看到当前可用的模型标识,比如
claude-sonnet-4-5这类
拿到三件套后,你可以先在模型对话页面直接开聊,把下面这段提示词丢进去:
我在写一个活动页倒计时,用 localStorage 存截止时间戳,setInterval 每秒渲染。 请帮我分析:1) 标签页被浏览器挂起后 setInterval 的行为; 2) 用 Date.now() 还是 performance.now() 做时间基准更合适; 3) 跨标签页同步倒计时有哪些方案。给出可运行示例。模型会给你一份对比分析,你拿着结论再去写代码,方向就不会偏。如果你更习惯在编辑器里调试,可以走 API 接入,把 Base URL 和 Key 配到你的请求工具里,用 curl 先验证连通性:
curl https://taotoken.net/api/v1/chat/completions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer sk-你的Key" \ -d '{ "model": "claude-sonnet-4-5", "messages": [{"role": "user", "content": "解释 localStorage 存截止时间戳做倒计时的原理"}] }'返回里能看到choices[0].message.content,说明链路通了。这一步的意义不是让模型替你写代码,而是把「为什么存时间戳而不是存秒数」这个核心问题先想透。想清楚之后,下面的配置和函数你抄过去就能用。
如果你打算长期做这类活动页开发,反复调试倒计时、优惠券过期、限时弹窗这些逻辑,可以考虑 Coding Plan,把模型对话和代码生成整合进日常流程,省去每次重新配环境的麻烦。
3. 可复制的倒计时工具函数与缓存读写配置
这一节是核心,我给你一份可以直接落地的配置和函数。先明确设计原则:缓存里只存一个字段——截止时间戳(毫秒),渲染时实时算差值。这样无论刷新多少次、开多少个标签页,大家读到的都是同一个截止时间,天然一致。
先看缓存读写的封装。我把它单独抽出来,方便你替换成sessionStorage或加命名空间前缀:
// countdown-store.js const STORAGE_KEY = 'activity_countdown_deadline'; /** * 写入截止时间戳 * @param {number} deadline 毫秒时间戳 */ export function setDeadline(deadline) { localStorage.setItem(STORAGE_KEY, String(deadline)); } /** * 读取截止时间戳,不存在返回 null * @returns {number|null} */ export function getDeadline() { const raw = localStorage.getItem(STORAGE_KEY); if (raw === null) return null; const num = Number(raw); return Number.isFinite(num) ? num : null; } /** * 清除截止时间戳 */ export function clearDeadline() { localStorage.removeItem(STORAGE_KEY); }注意这里做了Number.isFinite校验,防止有人手动往localStorage里塞了非数字字符串导致NaN参与计算。这是很多人忽略的防御点。
接下来是倒计时工具函数。它接收一个 DOM 元素、一个持续时长(秒),内部负责「首次进入时初始化截止时间、刷新时恢复、到点后清理」:
// countdown.js import { setDeadline, getDeadline, clearDeadline } from './countdown-store.js'; /** * 启动倒计时 * @param {HTMLElement} el 显示剩余秒数的元素 * @param {number} durationSec 倒计时总时长(秒) * @param {Function} [onEnd] 结束回调 * @returns {Function} 停止函数 */ export function startCountdown(el, durationSec, onEnd) { let deadline = getDeadline(); // 缓存不存在或已过期,重新初始化 if (deadline === null || deadline <= Date.now()) { deadline = Date.now() + durationSec * 1000; setDeadline(deadline); } let timerId = null; function render() { const remainMs = deadline - Date.now(); if (remainMs <= 0) { el.textContent = '0'; clearInterval(timerId); clearDeadline(); if (typeof onEnd === 'function') onEnd(); return; } // 向上取整,避免显示 0 秒但实际还有几百毫秒 el.textContent = String(Math.ceil(remainMs / 1000)); } render(); // 立即渲染一次,避免首帧空白 timerId = setInterval(render, 1000); // 返回停止函数,组件卸载时调用 return function stop() { clearInterval(timerId); }; }这份代码有几个关键点值得展开。第一,render里用Math.ceil(remainMs / 1000)而不是Math.floor,因为剩余 0.3 秒时应该显示 1 而不是 0,否则用户会觉得倒计时提前结束了。第二,首次进入时先render()一次再启动setInterval,避免页面出现一秒的空白。第三,setInterval的间隔是 1000ms,但渲染值来自实时计算,所以即使定时器有轻微漂移,显示的数字也不会累积误差。
如果你用的是 Vue 或 React,把startCountdown包在生命周期里即可。React 示例:
import { useEffect, useRef } from 'react'; import { startCountdown } from './countdown.js'; export default function Countdown({ duration = 15, onEnd }) { const ref = useRef(null); useEffect(() => { const stop = startCountdown(ref.current, duration, onEnd); return stop; // 卸载时清除定时器 }, [duration]); return <span ref={ref}>--</span>; }Vue 3 组合式 API 类似,在onMounted里启动、onUnmounted里停止。这里要提醒一句:组件卸载一定要清定时器,否则路由切走后定时器还在跑,控制台会报「操作已卸载 DOM」的警告。
配置层面还有一个容易忽略的点:localStorage是按域名隔离的。如果你的活动页在activity.example.com,而主站在www.example.com,两者缓存不互通。跨子域共享需要额外方案,这个在第五节排障里会讲。
4. 验证请求与成功结果:刷新恢复、跨标签页同步、时间校准
代码写完不算完,得验证三类动作,确保倒计时真的不跳变、不重置。我按顺序给你演示。
第一类:刷新恢复。打开页面,倒计时从 15 开始走。走到 10 的时候按 F5 刷新。正确结果是:刷新后直接从 10 继续,而不是回到 15。你可以在控制台执行localStorage.getItem('activity_countdown_deadline'),会看到一个毫秒时间戳,比如1735689600000。刷新前后这个值不变,说明缓存生效。如果刷新后归零,检查是不是每次进入都无条件调用了setDeadline,把恢复逻辑覆盖了。
第二类:跨标签页同步。把同一个活动页在两个标签页里打开。标签页 A 走到 8 秒时,切到标签页 B,B 应该也显示 8 秒左右,而不是各自独立计时。因为两个标签页读的是同一个localStorage截止时间戳,渲染时都用deadline - Date.now(),所以天然同步。你可以再进一步,监听storage事件做实时联动:
window.addEventListener('storage', (e) => { if (e.key === 'activity_countdown_deadline') { console.log('截止时间被其他标签页修改:', e.newValue); // 这里可以触发重新渲染 } });注意storage事件只在「其他标签页修改」时触发,当前标签页自己改不会触发,这是规范行为,别误以为是 bug。
第三类:时间校准。这是最容易被忽略的。假设用户在倒计时进行中手动把系统时间往后调了 1 小时,会发生什么?因为我们的基准是Date.now(),系统时间一变,deadline - Date.now()立刻变成负数,倒计时直接结束。这在某些场景下是合理的(用户作弊),但在另一些场景下会造成误伤。
更稳的做法是用「服务端时间」做基准。页面加载时请求一次服务端时间,算出本地时间与服务端的偏移量,之后所有计算都用Date.now() + offset:
let serverOffset = 0; async function syncServerTime() { const start = Date.now(); const res = await fetch('/api/time'); const { timestamp } = await res.json(); const end = Date.now(); // 粗略补偿网络往返的一半 serverOffset = timestamp - (start + end) / 2; } function now() { return Date.now() + serverOffset; }然后把countdown.js里的Date.now()全部替换成now()。这样即使本地时间被改,倒计时依然以服务端为准。验证方法:调完系统时间后刷新页面,倒计时应该继续正常走,而不是瞬间归零。
三类验证做完,你可以用一个表格记录结果:
| 验证动作 | 预期结果 | 实际结果 | 是否通过 |
|---|---|---|---|
| 走到 10 秒刷新 | 从 10 继续 | 从 10 继续 | 是 |
| 双标签页对比 | 两边秒数一致 | 相差 0-1 秒 | 是 |
| 改系统时间后刷新 | 继续正常走 | 继续正常走 | 是 |
如果三类都通过,这套倒计时就可以上活动页了。实测下来,这套方案在秒杀场景里表现稳定,用户刷新、切标签、锁屏再回来,倒计时都不会乱。
5. 本篇常见错误排查:401、local proxy failed、reading choices、OAuth
这一节把你在接入和调试过程中可能撞上的报错集中列一下,对照着查。
报错一:401 Unauthorized。如果你在用 API 方式调模型辅助调试,返回 401 通常是 Key 没带对或过期了。检查请求头是不是Authorization: Bearer sk-xxx,注意Bearer和 Key 之间有一个空格。另外确认 Key 是从控制台的 API Keys 页面复制的完整串,别漏字符。如果 Key 没问题还是 401,去控制台看下这个 Key 是否被禁用或额度耗尽。
报错二:local proxy failed。这个报错一般出现在你本地配了请求转发工具,但工具没启动或端口对不上。先确认你的请求工具在运行,再检查 Base URL 是不是写成了https://taotoken.net/api,末尾不要多加斜杠。如果你在settings.json或环境变量里配了代理地址,把它临时清掉,直连试一次,能通就说明是转发配置的问题。
报错三:reading 'choices'。这个报错是典型的「响应结构不符合预期」。你代码里写了response.choices[0],但实际返回的response里没有choices字段。原因通常是:请求根本没成功,返回的是一个错误对象(比如{ error: {...} }),你却直接去读choices。修复方法是先判断:
const data = await res.json(); if (!res.ok) { console.error('请求失败:', data.error?.message || data); return; } const content = data.choices?.[0]?.message?.content;用可选链?.兜底,就不会再抛reading 'choices'了。
报错四:OAuth 相关。如果你在配 Claude Code 这类工具时看到 OAuth 报错,通常是认证流程没走完或者 token 过期。这类工具一般需要你在浏览器里完成一次授权,拿到回调后再写回本地配置。检查配置文件里的 token 字段是否为空,重新走一遍授权流程即可。注意不要把 token 提交到 Git 仓库,用.gitignore排除掉。
配置三件套再强调一次。无论你用 CC Switch、Cline MCP 还是 Codex 的auth.json,只要涉及模型接入,都要写全这三样:
- Base URL:
https://taotoken.net/api - API Key:控制台创建的那串
sk-开头的字符 - Model ID:模型对话页面显示的标识
以 Codex 的auth.json为例,路径通常在~/.codex/auth.json,内容形如:
{ "base_url": "https://taotoken.net/api", "api_key": "sk-你的Key", "model": "claude-sonnet-4-5" }Cline MCP 的配置则在settings.json里,字段名可能是baseUrl、apiKey、model,按工具文档填。CC Switch 类似,切换配置时确认这三项都指向同一套,别出现 Base URL 是 A、Key 是 B 的混搭,那样必然 401。
倒计时本身的排查清单。除了接入报错,倒计时逻辑也有几个高频坑:
- 刷新后归零:检查是不是每次进入都无条件
setDeadline,应该先getDeadline判断。 - 显示数字跳变:检查是不是用了
Math.floor,改成Math.ceil。 - 到点后缓存没清:检查
remainMs <= 0分支里有没有clearDeadline()。 - 多标签页不同步:检查是不是各标签页各存各的,应该共用同一个 key。
- 定时器泄漏:组件卸载时有没有调用返回的
stop函数。
把这些对照一遍,基本能覆盖 90% 的倒计时问题。
6. 把倒计时封装成可复用模块,接入你的活动页
最后说下怎么把这套东西沉淀下来。我建议把countdown-store.js和countdown.js放在项目的utils目录,对外只暴露startCountdown一个函数。活动页里这样用:
import { startCountdown } from '@/utils/countdown.js'; const el = document.getElementById('countdown'); const stop = startCountdown(el, 15, () => { console.log('倒计时结束,可以触发抢购按钮'); document.getElementById('buy-btn').disabled = false; }); // 页面离开时 window.addEventListener('beforeunload', stop);如果你需要多个倒计时共存(比如页面上有秒杀倒计时和优惠券倒计时),给每个倒计时传不同的STORAGE_KEY即可,把 key 作为参数暴露出去:
export function startCountdown(el, durationSec, options = {}) { const { key = 'activity_countdown_deadline', onEnd } = options; // ...内部用 key 替代常量 }这样一套代码能覆盖多个业务场景。调试阶段如果想让模型帮你审查这段封装,可以把完整代码贴到模型对话里,让它检查边界情况;如果打算长期迭代活动页,用 Coding Plan 把这类工具函数的生成和审查流程固定下来,每次新活动直接复用,不用重写。
倒计时看着简单,但「刷新不重置、多标签同步、时间可校准」这三点做到位,活动页的体验就稳了。核心就一句话:存截止时间戳,不存剩余秒数。记住这句,剩下的都是细节。