1. 整点秒杀到底难在哪,先把问题拆干净
整点秒杀这个需求,前端层面的实现(JS/DOM/HTML/CSS)看着像个小活:一个倒计时、一个按钮、一个点击发请求,似乎半小时就能收工。我最早也是这么想的,结果第一个版本上线当天就被打回来了——用户手机时间比标准时间快了三分钟,页面显示"已开始",点下去提示"活动未开始";另一个用户切到微信聊天再切回来,倒计时还停在原处没动。从那以后我明白,整点秒杀的难点从来不在"写个倒计时",而在时间一致性和状态一致性这两件事上。这篇内容就是把这套东西从 HTML 骨架、CSS 表现、JS 计时与状态机,到线上排查的完整思路过一遍,适合刚接手活动页的前端、需要一个能直接抄的骨架的同学,也适合已经写过但被时间漂移坑过的人对照检查。
先把边界划清楚:前端做不了"保证抢到",前端能做的是把用户的等待体验做顺、把不该发出的请求拦掉、把按钮状态和真实活动状态对齐。超出这个范围的期待(比如前端算库存、前端判定资格)都是给自己挖坑。真正决定成败的是服务端,前端的职责是别添乱、别误导、别让用户在"我明明看到可以点了"和"系统说不行"之间反复横跳。这个定位想明白了,后面所有的技术选择都会顺很多。
1.1 三条时间线必须先对齐
页面里同时存在三个时间:用户设备的系统时间(用户自己可以手动改,误差可能几小时)、浏览器拿到的Date.now()(跟随系统,同样不可信)、服务端时间(唯一真相)。倒计时如果用本地时间算,等于把活动开始时间交给用户自己决定,属于原则性错误。正确姿势是:页面初始化时向服务端取一次当前时间,算出本地时钟相对服务端的偏移量存起来,之后所有"现在几点"的判断都走这个偏移量修正后的时间。这样做的好处是页面运行期间只需要一次时间请求,不会因为每次倒计时都打接口而把后端压垮。
这里有个细节很多人漏掉:网络往返是有耗时的。你发出请求记一个t0,收到响应记一个t1,服务端返回的时间戳其实对应的是"服务端处理请求的那一刻",大概落在t0和t1的中间。所以偏移量应该按serverTime - (t0 + rtt / 2)来算,而不是简单减t0。局域网内这点误差可能就几十毫秒,但在弱网环境下能到几百毫秒,秒杀场景下几百毫秒足够决定用户看到的是"还有 1 秒"还是"已开始"。如果服务端能在响应体里带上自己的处理时间戳,配合响应头里的时间字段做二次校验,准确度会更高。
注意:不要拿
performance.now()单独当时间源。它确实是单调递增的、不受系统时间调整影响,但它的原点在不同页面、不同 iframe 里不统一,跨页面传递会出问题。稳妥做法是:用Date.now() + offset作为业务时间,用performance.now()只做"两次采样之间的间隔测量"。
1.2 按钮状态机要唯一,不能有多处判断
我见过不少实现,按钮能不能点是靠if (now >= startTime && now <= endTime && !clicked)在点击回调里临时判断的,页面上显示的文案又是另一套if。两套逻辑一多,迟早对不上,出现"按钮写着立即抢购但点了没反应"或者"显示已结束但按钮还能点"的诡异现象。解决办法很简单:定义一个明确的状态枚举,用一个函数根据当前时间算出状态,状态驱动文案、样式和disabled属性,点击回调只读状态,不再自己算。状态只有一处定义,DOM 只是它的投影,这样后面加"已抢光""未登录"这些分支时也不会乱。
状态的取值我一般定五个:idle(还没到预热时间,按钮不可点)、ready(在预热窗口内,可点但请求可能返回未开始)、running(可抢)、sold(抢光,由接口返回驱动)、end(已结束)。注意ready这个中间态是必要的——很多活动页会在开始前几分钟放开按钮预热,这时候用户点击应该给一个明确的"还没开始"提示,而不是把按钮做成死的让用户以为页面卡了。
1.3 别指望一个 setInterval 走天下
setInterval(fn, 1000)是最直觉的写法,也是坑最多的写法。第一,它有累积漂移,每次回调实际间隔是 1000ms 加上执行耗时,跑一小时能偏出好几秒。第二,标签页切到后台后,浏览器会把定时器节流到最低 1 秒甚至更长,用户切回来看到的数字是过期的。第三,如果回调里做的是"剩余时间减 1"这种累减逻辑,那么任何一次节流、任何一次主线程卡顿,都会让倒计时永久性地慢下来,永远追不回去。
正确的思路是把"计时"和"渲染"分开:每次渲染都重新用真实时间算一遍剩余毫秒数,不做任何累减;渲染的触发用requestAnimationFrame保证流畅,同时用一个低频定时器做兜底,防止 rAF 在后台被完全暂停导致回到前台时状态陈旧。回到前台时再补一次visibilitychange监听,强制重算。这套组合下来,无论用户怎么切页面、怎么锁屏,倒计时都不会偏。
2. 页面骨架与样式,DOM 结构先行
动手写 JS 之前,先把 HTML 结构定下来,这一步能省掉后面大量querySelector和innerHTML拼接。我的习惯是给每个需要动态更新的文本节点单独一个元素,并且加><section class="sk">.sk__clock { display: inline-flex; align-items: baseline; gap: 4px; font-variant-numeric: tabular-nums; font-feature-settings: "tnum"; } .sk__num { display: inline-block; min-width: 2ch; text-align: center; font-size: 28px; font-weight: 700; letter-spacing: 0.02em; } .sk[data-state="running"] .sk__btn { background: #e8452c; color: #fff; cursor: pointer; } .sk__btn[disabled] { background: #d8d8d8; color: #8a8a8a; cursor: not-allowed; pointer-events: none; }
pointer-events: none这一行是重点。移动端点击有 300ms 左右的历史遗留延迟(现在主流浏览器基本没了,但部分 WebView 里还在),如果只靠disabled属性,某些环境下快速连点仍可能触发事件。加上pointer-events: none相当于从渲染层屏蔽了命中测试,双保险。不过要注意,它在部分老版本安卓 WebView 上会让按钮的:active反馈失效,所以我是把pointer-events只加在disabled状态上,可点状态下正常响应。
2.3 动画要克制,别用 left/top 和 box-shadow
预热阶段很多人喜欢加个"呼吸灯"或者"光环扩散"效果。如果要加,优先用transform和opacity,这两个属性走合成层,不会触发重排重绘。见过一些实现用box-shadow做脉冲,那个开销相当大,在低端机上会直接拖累主线程,进而影响到倒计时回调的准点率——说白了,装饰动画把业务逻辑拖慢了,属于本末倒置。如果非要用阴影,至少开一个独立图层并限制动画范围。
另外,倒计时进入最后一分钟时,很多人会做数字变红加缩放动画。这里要注意别在requestAnimationFrame里同步修改 class——频繁的 class 切换也会引起样式重算。我的做法是只在跨过阈值的那一次改一次 class,之后让 CSS 自己去跑动画。
3. 倒计时核心,时间校准与渲染节拍
这一节是整块内容的重头戏。我把倒计时拆成三个独立的部分:时间源、调度器、渲染器。时间源负责回答"现在几点",调度器负责回答"什么时候该重画",渲染器负责回答"画成什么样"。三者之间通过一个状态对象通信,任何一部分都可以单独替换调试,不会牵连其他部分。这种拆法看起来比"一个 setInterval 全搞定"麻烦,但在排查线上问题时能救命——用户反馈"倒计时不准",你能立刻定位是时间源偏了还是渲染没触发。
3.1 时间同步的完整实现
同步函数要处理三种情况:首次加载同步成功、同步失败(降级用本地时间,但要标记不可信)、后续重新同步(有些活动时间长,页面开了几小时,需要定时校准)。同时要处理页面从后台恢复时的补同步,因为设备休眠期间系统时间可能被调整。
const clock = { offset: 0, trusted: false, syncing: null, }; function nowMs() { return Date.now() + clock.offset; } async function syncTime() { if (clock.syncing) return clock.syncing; clock.syncing = (async () => { const t0 = Date.now(); try { const res = await fetch('/api/now', { cache: 'no-store', credentials: 'same-origin', }); const t1 = Date.now(); const data = await res.json(); if (typeof data.serverTime !== 'number') throw new Error('bad payload'); const rtt = t1 - t0; clock.offset = data.serverTime - (t0 + rtt / 2); clock.trusted = true; } catch (e) { clock.offset = 0; clock.trusted = false; } finally { clock.syncing = null; } })(); return clock.syncing; }cache: 'no-store'必须加,否则被 CDN 或者浏览器缓存住,返回的是一个小时前的时间戳,偏移量直接算成负一小时,倒计时会凭空多出一小时。credentials视活动是否依赖登录态决定,如果需要登录才返回时间,那页面初始化顺序就得调整成先登录后同步。同步失败时trusted置 false,界面上可以给一个非常轻的降级提示,或者干脆不提示——大多数情况下本地时间和标准时间差不了太多,硬提示反而增加焦虑。
提示:同步接口尽量做得轻,返回体只要一个时间戳,不要带用户信息、不要走鉴权链路的长流程。这个接口会在活动开始前被大量用户同时请求,越简单越稳。可以在页面上线后观察它的失败率,如果失败率偏高,说明前置的网络或网关有问题,需要在服务端侧排查而不是前端加重试。
3.2 剩余时间格式化,边界要抠干净
格式化看着简单,边界问题不少。第一是四舍五入还是向上取整:剩余 1000ms 时应该显示00:00:01还是00:00:00?我的选择是向上取整(Math.ceil),因为显示00而活动还没开始会让用户困惑,显示01则不会提前暴露"可以点了"的错误信号。第二是负数处理,剩余时间小于等于 0 时立刻切状态并停止渲染,绝不能出现-1秒这种输出。
function fmt(ms) { const total = Math.max(0, Math.ceil(ms / 1000)); const h = Math.floor(total / 3600); const m = Math.floor((total % 3600) / 60); const s = total % 60; const pad = (n) => (n < 10 ? '0' + n : String(n)); return { h: pad(h), m: pad(m), s: pad(s) }; }小时位超过 99 的情况也要考虑。跨天的大活动预热可能超过 24 小时,如果固定两位,会显示成25、48甚至100溢出容器。我的处理是:超过 99 小时就换成"X 天 X 时"的文案,把倒计时从纯数字切换成文字描述,既准确又不会挤爆布局。这种取舍没什么技术含量,但真到用的时候能省一轮返工。
3.3 调度器:rAF 加低频兜底
调度器要同时满足两个矛盾的需求:前台要顺滑,后台要省电但回到前台要立刻准。单纯用 rAF,后台会被暂停;单纯用 setInterval,后台会被节流到 1 秒以上且恢复时不可控。所以我的做法是双通道:前台用 rAF 驱动,同时在切到后台时启动一个 1000ms 的 interval 做兜底,切回前台时立刻取消 interval、执行一次强制渲染、再恢复 rAF。
let rafId = 0; let fallbackId = 0; let lastText = { h: '', m: '', s: '' }; function render() { const left = TARGET - nowMs(); if (left <= 0) { setState(STATE.RUNNING); stopTick(); paint({ h: '00', m: '00', s: '00' }); return; } paint(fmt(left)); } function paint(t) { if (t.h === lastText.h && t.m === lastText.m && t.s === lastText.s) return; lastText = t; const nodes = clockEl.children; for (let i = 0; i < nodes.length; i++) { const unit = nodes[i].dataset && nodes[i].dataset.unit; if (unit && nodes[i].textContent !== t[unit]) { nodes[i].textContent = t[unit]; } } }paint里的"值没变就跳过"这一层判断很关键。rAF 一秒内可能触发 60 次,如果不做比对,就是每秒 60 次 DOM 写入,纯属浪费。加上比对之后,实际写入次数降到每秒 1 次左右,主线程占用可以忽略不计。lastText用对象而不是字符串做比较,是为了避免拼接字符串产生的临时对象开销,虽然这点开销很小,但在一秒 60 次的循环里,能省则省。
那个TARGET变量的来历也得说清楚。它必须来自服务端下发的活动配置,格式用 ISO 8601 带时区偏移的字符串,比如2025-03-01T20:00:00+08:00。绝对不要写成2025-03-01 20:00:00这种不带T的格式,Safari 和部分 iOS WebView 会把它解析成Invalid Date,导致TARGET是NaN,整个倒计时直接不显示。这个坑我在 iOS 上踩过一次,本地开发机全是 Chrome,测不出来,上线才发现。
4. 状态机与交互,把请求管住
倒计时跑起来只是第一步,真正容易出事的是"点击"这条链路。用户在最后一秒疯狂点击、请求还在路上又点一次、接口超时了不知道要不要重试、抢到了但页面没刷新——这些问题叠在一起,就是一个体验灾难。我的处理原则是:前端只发一次请求,请求发出后立刻锁死按钮,结果回来之后按结果改状态,任何时候都不允许出现第二个并发请求。
4.1 按钮的五个状态和对应文案
状态机的价值在这一刻体现得最明显。每个状态对应一组固定的文案、样式和可点击性,写成一个表,代码里用查表的方式实现,加新状态就是加一行,不用去翻散落各处的 if 分支。
| 状态 | 触发条件 | 按钮文案 | 可点击 | 说明 |
|---|---|---|---|---|
| idle | 当前时间早于预热开始 | 即将开始 | 否 | 只显示倒计时 |
| ready | 在预热窗口内,未到开抢时刻 | 即将开始 | 是 | 点击提示"还没开始" |
| running | 到达开抢时刻,未抢光 | 立即抢购 | 是 | 允许发请求 |
| pending | 请求已发出 | 提交中 | 否 | 锁定,防重复 |
| sold | 接口返回无库存 | 已抢光 | 否 | 不可再点 |
| end | 超过活动结束时间 | 已结束 | 否 | 不可再点 |
pending这个状态必须有。它和running的区别在于按钮被禁用但文案不是"已抢光",用户能看出"我的请求在飞",心理上不会觉得被吞了。如果请求超时或者失败,要能从pending回到running并给出提示,而不是直接掉到end——直接掉到结束态会让用户以为活动没了,体验极差。
4.2 请求链路:预热、超时、幂等
点击之后的第一件事不是发请求,而是把状态切成pending并禁用按钮,然后再发。这个顺序不能反,反了就会出现用户手快点了两次、两个请求同时出去的情况。很多活动页的"超卖"事故,前端多发的请求占了一部分原因。
请求本身有三点要处理。超时时间给短,秒杀场景下等 10 秒意义不大,我一般设 3000ms 到 5000ms,超时就直接回退状态并提示"网络拥挤,请重试"。失败重试不做自动的,让用户手动点,因为秒杀请求通常有幂等要求(服务端靠 token 或请求 ID 去重),前端乱重试会打乱服务端的判定。请求参数里带一个客户端生成的唯一 ID(可以用crypto.randomUUID(),不支持的话降级到时间戳加随机数),方便服务端做幂等。
let inFlight = false; async function submit() { if (inFlight) return; if (state !== STATE.READY && state !== STATE.RUNNING) return; inFlight = true; setState(STATE.PENDING); const reqId = window.crypto && crypto.randomUUID ? crypto.randomUUID() : String(Date.now()) + Math.random().toString(16).slice(2); const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 4000); try { const res = await fetch('/api/seckill', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ actId: ACT_ID, reqId }), signal: ctrl.signal, }); const data = await res.json(); if (data.code === 0) { setState(STATE.SOLD); announce('抢购成功'); } else if (data.code === 4001) { setState(STATE.SOLD); announce('已经抢光了'); } else { setState(STATE.RUNNING); announce(data.msg || '没抢到,再试试'); } } catch (e) { setState(STATE.RUNNING); announce('网络有点慢,请重新点击'); } finally { clearTimeout(timer); inFlight = false; } }inFlight这个锁和状态机的pending是双保险。状态有可能因为别的逻辑被改回去(比如倒计时重算触发了setState),但inFlight只在请求的生命周期内变化,更可靠。有人问能不能只用button.disabled判断,答案是不要——disabled属性会被任何一次setState覆盖,而锁变量不会被意外重置。
注意:
announce函数写的是sr-only那个节点的内容,别写成alert。秒杀场景下弹窗会让用户多点一次关闭,这零点几秒的损失没有必要。
4.3 页面可见性变化的处理
用户在等待的时候经常会切出去看消息、刷别的页面,回来的时候倒计时必须是对的。这件事靠 rAF 是不够的——后台标签页的 rAF 会被暂停,visibilitychange事件是最可靠的信号。
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { render(); startRaf(); syncTime().then(render); } else { stopRaf(); startFallback(); } });注意这里切回前台时又调了一次syncTime。如果用户锁屏半小时,系统时间可能被自动校时调整过,之前的偏移量就失效了。重新同步一次成本极低,但能避免"回来发现倒计时差了几分钟"这种投诉。同时syncTime内部有clock.syncing做并发保护,不会因为用户反复切换而打出多个请求。
4.4 一个容易被忽略的细节:系统时间被手动修改
这个场景听起来极端,但真实存在——有人为了提前看到活动内容去改手机时间,结果页面倒计时直接跳成负数。因为我们是基于Date.now() + offset计算的,用户改系统时间会把Date.now()一起改掉,偏移量就失去了意义。进一步的处理是用performance.now()做单调计时:记录同步时刻的performance.now()值,之后用两次采样之间的间隔去推算经过的时长,再叠加到服务端时间上。这样即使用户中途改了系统时间,经过时长仍然是对的。
// 同步成功后记录基准 clock.baseServer = data.serverTime; clock.basePerf = performance.now(); function nowMsStable() { if (!clock.trusted) return Date.now(); return clock.baseServer + (performance.now() - clock.basePerf); }这套做法的代价是页面长时间运行后可能有累积误差(performance.now()本身很准,主要误差来自标签页节流时的精度下降),所以定期重新同步一次更稳妥。两种方案可以结合:平时用performance.now()推算,每次切回前台用Date.now()重新同步刷新基准。这样两头的好处都占上了。
5. 实测踩坑与排查清单
写了这么多,最后这部分才是真正值钱的。下面这些问题都是我自己或者同事在真实活动里遇到的,按"现象—原因—处理"整理成表,遇到类似症状可以直接对号入座。
5.1 常见现象速查表
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| 倒计时完全不显示 | Date解析失败得到NaN | 活动时间统一用 ISO 8601 带时区格式 |
| 数字每秒抖动位移 | 字体非等宽,容器宽度随内容变化 | 开启tabular-nums,数字容器固定宽度 |
| 切回页面后倒计时停滞 | rAF 在后台暂停且未监听可见性变化 | 加visibilitychange,恢复时强制重算 |
| 倒计时越跑越慢 | 用了累减逻辑而非绝对时间差 | 每次渲染都用目标时间减去当前时间 |
| 点击后没有任何反应 | 状态判定与实际渲染脱节 | 状态机单点定义,DOM 只做投影 |
| 同一用户重复下单 | 点击未加锁,请求并发 | 状态切 pending 加inFlight变量 |
| 时间差了几分钟 | 时间同步接口被缓存 | 请求加cache: 'no-store' |
| 按钮点了能触发两次 | 移动端事件延迟或未禁用 | 禁用属性加pointer-events: none |
表格里每一行背后都是一个真实的返工。特别是"越跑越慢"那条,原因往往藏在很不起眼的地方——有人在渲染函数里写remain -= 1000,看起来一秒减一秒,但只要有一次主线程卡顿超过 1 秒,这一秒就永远补不回来了,跑十分钟能慢出好几秒。
5.2 三个印象最深的坑
第一个是同步接口的缓存问题。测试环境和线上环境的网关策略不一样,测试时fetch没加no-store也没事,因为测试环境没配缓存。上线后网关给 GET 请求默认加了 5 分钟缓存,用户拿到的时间戳是旧的,偏移量算出来直接偏了五分钟,倒计时从 5 分钟开始跳。排查的时候看接口返回体是正确的,但用的时间是旧的,最后靠对比响应头里的Date和响应体里的时间戳才发现。现在我的习惯是:时间类接口一律 POST,或者 GET 加随机参数加no-store,三重保险。
第二个是iOS 的日期解析。new Date('2025-03-01 20:00:00')在 Chrome 里能过,在 iOS Safari 里返回Invalid Date,getTime()得到NaN,后续所有计算都是NaN,页面表现为倒计时空白、按钮一直禁用。这个问题的隐蔽性在于它只在 iOS 上出现,而且如果开发时用了某个 polyfill 或者第三方时间库,可能又被掩盖掉,直到换了构建配置才暴露。统一用2025-03-01T20:00:00+08:00这种格式,或者直接用服务端下发的时间戳数字,就不会有这个问题。能传时间戳就别传字符串,这是最省心的做法。
第三个是渲染性能拖垮计时。有个版本在倒计时里加了一个粒子飘落效果,用setInterval每 16ms 更新几十个元素的style.left。在高端机上没事,在低端安卓机上主线程被占满,倒计时回调延迟到两三秒一次,用户看到的数字是跳着变的。后来把粒子改成transform加will-change,数量从几十个降到十几个,问题才消失。这个教训是:任何和倒计时同处一个主线程的高频动画,都是在跟业务逻辑抢资源,能砍就砍,不能砍就挪到合成层。
5.3 上线前的自检清单
每次活动页上线前,我会固定跑一遍这套检查,大概十分钟,能挡掉八成问题。
- 把设备时间手动往前调 10 分钟、往后调 10 分钟,看倒计时是否稳定,状态是否正确切换。
- 在倒计时进行中切到其他应用停留 30 秒再回来,看数字是否立刻正确,不出现跳变或停滞。
- 在最后一秒连续快速点击按钮 10 次,看网络面板里是否只有一个请求。
- 用开发者工具把网络限速到 3G,看超时处理和失败提示是否合理,按钮能否恢复可点。
- 断网后点击,看是否有明确提示,恢复网络后能否重试成功。
- 在 iOS 真机上完整走一遍,重点看时间显示和按钮点击反馈。
- 把页面缩放和系统字号调大,看倒计时容器会不会溢出、按钮文案会不会被截断。
- 读屏软件过一遍,确认状态变化有播报,倒计时数字不会疯狂打断。
第 7 条经常被忽略。老年机或者系统字号调大的用户,倒计时容器如果写死了宽度,数字会被裁掉一半,显示成"00:0"。用rem或者允许换行、给容器设min-width而不是width,能缓解很多。第 8 条则是无障碍的基本要求,虽然大多数项目不做验收,但真做了就是加分项。
我对这套东西的整体体会是:整点秒杀的前端实现,本质是在做减法。减掉不必要的请求、减掉重复的 DOM 写入、减掉多余的动画、减掉散落各处的状态判断。每减掉一样,出问题的概率就低一截。我见过最稳的一个活动页,代码加起来不到三百行,没有花哨的动画,没有复杂的组件封装,就是老老实实同步一次时间、老老实实每秒改一次文本、老老实实把按钮锁住,跑了几场大促没出过一次线上问题。反倒是那些堆了一堆"优雅架构"的页面,每次活动都要修点东西。这个行业里,把简单的事做扎实,比把复杂的事做漂亮要难得多。