倒计时的需求在 Web 项目里太常见了。电商秒杀、活动开奖、考试剩余时间、直播间下播倒计时,随便一数就是一堆场景。而这个组件刚上手时最容易踩坑的,就是那个“秒显示完了之后,分钟怎么跟着减少”的换算逻辑。很多人第一版写出来,要么秒钟到了 00 就卡住不动,要么分钟数显示成负数,要么数字跳动的时候整个布局跟着抖。
这篇文章就从最朴素的“秒到分钟转换”说起,一直讲到翻牌动画、进度环这类进阶玩法,把我这些年做倒计时组件时踩过的坑、验证过的方案、还有最终的代码结构一起整理出来。不管是刚接触前端的同学,还是被倒计时 bug 折磨过的老手,应该都能找到点有用的东西。
1. 倒计时的核心逻辑:秒到分钟的换算先想清楚
1.1 小学数学题,但容易想岔
你要实现一个倒计时,手上大概率只有一个数据:总秒数。比如后台返回 3650 秒,你要显示成 01:00:50 这样的格式,或者只需要 分钟:秒 的 60:50。本质就是一次整除和一次取余:
javascript复制const totalSeconds = 3650;
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
这个逻辑的直觉来源很简单:60 秒进一位,所以除以 60 取整就是已经过去的分钟数,对 60 取余就是剩下的秒数。小时同理,只是先把 3600 秒剥出去。
这里最关键的细节是:顺序不能乱。你不能先 totalSeconds % 60 拿到秒,然后拿减掉秒的剩余值再去算分钟,那样代码绕一圈反而容易出错。直接用两次运算拆开,逻辑最清晰。如果要实现“根据本地计算得到下次整点时间”这种需求,也是先把目标时间和当前时间做差拿到毫秒,再除以 1000 得到总秒数,后续逻辑完全复用这套除法。
1.2 为什么用 Math.floor 而不是 Math.round 或 parseInt
这三种方法表面上看都像“去掉小数部分”,但语义完全不同。Math.round 是四舍五入,倒计时最忌讳四舍五入——你 Math.round(59.6) 会得到 60,秒钟直接跳两次,显示直接崩。parseInt(59.6) 和 Math.floor(59.6) 结果一样是 59,但 parseInt 的设计初衷是解析字符串,用它处理数字会在某些边界场景下产生意外行为(比如 parseInt(0.0000005) 会解析成 0,而 Math.floor(0.0000005) 也是 0,但再极端一点的数值就会暴露问题),干脆统一用 Math.floor,语义明确,不给自己留坑。
javascript复制// 千万不要这样写
const seconds = Math.round(totalSeconds % 60); // 到 59.5 秒时就提前跳到 00
// 正确姿势
const seconds = Math.floor(totalSeconds % 60);
1.3 补零是最容易被忽略的细节
9 和 09 在倒计时里的视觉体验完全不同。两位数对齐的倒计时读起来不费劲,一位数会让整个组件看起来像没做完。补零的常规办法是先转字符串再判断:
javascript复制function pad(value) {
return String(value).padStart(2, '0');
}
padStart(2, '0') 的意思是:如果这个字符串长度不足 2,就在开头补 0 直到长度为 2。9 变成 09,59 保持 59,逻辑非常干净,比起老式的拼接写法 (value < 10 ? '0' + value : value) 更直观,也省得多写一个判断。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 基础工程实现:结构、脚本、样式三层拆解
2.1 先搭一个能跑的 HTML 结构
倒计时的 HTML 结构没什么玄学,关键在于“哪里放数字、哪里放单位分隔符”,这决定了你后续做动画的复杂度。常规做法是每个时间单位独立元素:
html复制<div class="countdown" id="countdown">
<span class="countdown__unit">
<span class="countdown__number" id="minutes">00</span>
<span class="countdown__label">分</span>
</span>
<span class="countdown__colon">:</span>
<span class="countdown__unit">
<span class="countdown__number" id="seconds">00</span>
<span class="countdown__label">秒</span>
</span>
</div>
分钟和秒分别用独立的元素包裹,而不是把 05:09 塞进一个 span 里。这样有几个好处:第一,JS 更新数字时只改对应元素,不需要重新解析整个字符串;第二,可以做针对单个数字的动画,比如秒数变化时只让秒的部分闪烁;第三,CSS 结构清晰,display: flex 对齐时分隔符和数字可以分开控制。
如果你只需要“分:秒”格式,甚至可以更进一步,把个位和十位拆成两个独立元素。这样做翻牌动画时特别方便,但要权衡一下维护成本——拆得太细,代码可读性会下降。我自己一般只在做轮播滚动效果时才拆到个位十位,普通场景保持单位级拆分就够了。
2.2 JS 逻辑:用 setInterval 还是 requestAnimationFrame
这是倒计时组件里争论最多的问题之一。setInterval 的优点是简单直观,缺点是浏览器在后台标签页会降低定时器频率,而且它本身不保证回调在精确的间隔触发。requestAnimationFrame 的优点是跟浏览器绘制频率同步,视觉上最平滑,缺点是需要自己计算增量,代码略复杂。
对于纯粹的“秒级倒计时”,我的建议是:常规页面用 setInterval,倒计时精度要求高(比如需要精确到 0.1 秒的赛事计时)再用 requestAnimationFrame。因为秒级倒计时只要在整秒时更新一次 DOM 就够了,setInterval 完全够用,没有必要把简单问题复杂化。
但 setInterval 有一个非常隐蔽的坑:如果你在回调里做耗时操作,或者浏览器线程卡顿,回调会堆积,导致显示时间不准确。更稳的做法是每次回调时重新用当前时间计算剩余秒数,而不是简单地对一个变量做 totalSeconds--:
javascript复制let endTime;
let timerId;
function startCountdown(durationSeconds) {
// 用时间戳计算剩余时间,避免累减误差
endTime = Date.now() + durationSeconds * 1000;
update();
timerId = setInterval(update, 500); // 500ms 检查一次,保证到秒的及时性
}
function update() {
const remainingMs = endTime - Date.now();
if (remainingMs <= 0) {
clearInterval(timerId);
render(0, 0);
// 触发倒计时结束回调
onComplete && onComplete();
return;
}
const totalSeconds = Math.ceil(remainingMs / 1000);
render(Math.floor(totalSeconds / 60), totalSeconds % 60);
}
function render(minutes, seconds) {
document.getElementById('minutes').textContent = pad(minutes);
document.getElementById('seconds').textContent = pad(seconds);
}
这个方案的核心思路是:不用一个变量自己减,而是始终拿“目标结束时间戳”和“当前时间戳”做差。这就是解决定时器漂移的标准做法,你不需要依赖定时器每次触发的间隔完全均匀,只要它最终能触发一次,就能根据真实时间计算出正确的剩余秒数。Math.ceil(remainingMs / 1000) 的细节也要注意:倒计时还有 1.2 秒时,应该显示 2 秒而不是 1 秒,因为用户体验上“还有 2 秒”比“还有 1 秒”更符合直觉,最后那一下跳 0 才自然。
2.3 CSS 基础样式:防抖是第一个要解决的问题
倒计时组件最常见的视觉 bug 是什么?数字跳动时整个布局左右晃。原因很简单:数字宽度变化了。1 和 8 的宽度不同,字体在普通正体下是无法对齐的。
解决办法有两个方向。第一个是给数字用等宽字体:
css复制.countdown__number {
font-family: 'Courier New', Consolas, monospace;
font-variant-numeric: tabular-nums;
}
font-variant-numeric: tabular-nums 是让数字以等宽形式渲染的现代 CSS 属性,对系统自带的大部分现代字体都有效,但不保证所有字体都支持。保险起见,给数字专门用等宽字体族,或者直接配合 min-width 固定占位。
第二个方向是固定容器宽度,让每个数字位拥有统一大小:
css复制.countdown__unit {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2ch; /* ch 单位:一个数字字符的宽度 */
}
ch 单位是这个场景下的法宝。2ch 就表示“两个 0 的宽度”,只要数字不超过两位数,min-width: 2ch 就能保证布局稳定。整个倒计时组件里,分钟最多显示 99 分钟,秒数固定两位数,用 2ch 正好。
排版上还有一个我觉得很提升质感的细节:冒号分隔符 : 不要在 HTML 里打成普通文本,而是用 CSS 伪元素生成:
css复制.countdown__unit + .countdown__unit::before {
content: ':';
margin: 0 0.2em;
opacity: 0.6;
}
这样做的好处是冒号不是内容的一部分,后续如果要做“分钟和秒之间用图标分隔”的改动,只需要改 CSS,不需要动 JS。这个组件做到后面你会发现,把“纯装饰元素”从 HTML 里剥离出去,能让结构更干净。
3. 进阶样式:让倒计时在视觉上真正“会动”
3.1 数字变动的过渡效果:颜色和透明度比位移更讨巧
很多教程喜欢给数字做“向上滚动”的动画,但说实话,纯 CSS transition 做滚动效果很难做到自然,它只能做透明度和位置的渐变。我给普通业务组件推荐的过渡方式是:秒数变化时,短暂地改变数字的颜色和透明度,让用户感知“这一秒刷新了”。
css复制.countdown__number {
transition: color 0.3s ease, opacity 0.3s ease;
}
.countdown__number.is-flash {
color: #e74c3c;
opacity: 0.5;
}
配合 JS,在每次更新数字时给秒的元素加上 is-flash 类,然后在下一次定时器回调时移除。这种效果在暗色背景的 HUD 风格页面上特别好看,像电子显示屏的刷新感。而且实现成本极低,不用引入任何动画库。
3.2 真正能打的翻牌倒计时:两个元素配合 3D 翻转
如果你做的是大屏活动页面、或者客户点名要“翻牌”效果,那就要上一点真功夫了。翻牌倒计时的核心视觉原理是:一张卡片显示当前数字,到了切换时刻,上半部分先翻转显出新数字的上半部分,然后下半部分再翻转。纯 CSS 实现的关键是用 transform-style: preserve-3d 和 backface-visibility: hidden。
这里贴一个简化但完整的翻转数字实现:
html复制<div class="flip-card" data-value="05">
<div class="flip-card__top">05</div>
<div class="flip-card__bottom">05</div>
<div class="flip-card__flip-top">04</div>
<div class="flip-card__flip-bottom">04</div>
</div>
css复制.flip-card {
position: relative;
width: 80px;
height: 100px;
perspective: 300px;
font-family: 'Courier New', monospace;
}
.flip-card__top,
.flip-card__flip-top {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 50%;
overflow: hidden;
line-height: 100px;
background: #222;
color: #fff;
border-radius: 8px 8px 0 0;
}
.flip-card__bottom,
.flip-card__flip-bottom {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 50%;
overflow: hidden;
line-height: 100px;
background: #1a1a1a;
color: #fff;
border-radius: 0 0 8px 8px;
}
/* 下半部分文字要往下偏移,让上下拼接成一个完整数字 */
.flip-card__bottom span,
.flip-card__flip-bottom span {
display: block;
transform: translateY(-50%);
}
.flip-card__flip-top {
transform-origin: bottom center;
transform: rotateX(0deg);
z-index: 2;
}
.flip-card.flipping .flip-card__flip-top {
animation: flipTop 0.4s ease-in forwards;
}
@keyframes flipTop {
from { transform: rotateX(0deg); }
to { transform: rotateX(-90deg); }
}
这个实现里最容易翻车的是下半部分的文字定位。因为 overflow: hidden 会只显示元素范围内的一半内容,下半部分卡片里的数字需要把整个数字往上挪半个卡片高度,让数字的下半部分落在这个容器里。所以底部数字通常要在内部再套一层 span,用 translateY(-50%) 调整位置。不同的字体高度和行高会导致细微偏差,这块没有一劳永逸的公式,必须打开 DevTools 实测调整。
翻牌逻辑的 JS 部分说白了就是:在切换瞬间,先把四个面都设置成新值,然后给卡片加 flipping 类触发上半部分翻转;上半部分翻到 90 度(看不见了)的瞬间,也就是动画进行到一半时,把 flip-top 变成已经翻过去的状态,再把底部的静态卡片换成新数字。这里有个小技巧:上半部分动画的 forwards 填充模式要保留,这样翻转结束后会保持 rotateX(-90deg) 的隐藏状态;如果你用 both 或 backwards,动画开始前的初始状态可能出现白闪。
3.3 进度环:CSS 用 conic-gradient 就能画
除了数字,“还剩多少比例”这个信息对用户也很有价值。分钟级倒计时用进度环展示特别直观。用 SVG 画是常规方案,但现代 CSS 的 conic-gradient 可以直接做:
css复制.progress-ring {
width: 160px;
height: 160px;
border-radius: 50%;
background: conic-gradient(#4facfe 0%, #4facfe var(--progress), #e5e5e5 var(--progress), #e5e5e5 100%);
display: flex;
align-items: center;
justify-content: center;
}
.progress-ring::before {
content: '';
width: 130px;
height: 130px;
border-radius: 50%;
background: #fff;
}
这个方案的核心是 CSS 变量 --progress,你只需要在 JS 里每秒钟更新它就行:
javascript复制const percent = (remainingSeconds / totalDurationSeconds) * 100;
ring.style.setProperty('--progress', percent + '%');
::before 伪元素盖在中间制造空心效果,比用 border-radius: 50%; border: 8px solid 的方式更容易做内部纹理和文字叠加。如果你要的是“从圆环上方开始,顺时针绘制”,conic-gradient 默认就是从 12 点方向开始的,不用额外旋转。要调整起始角度,可以用 from 参数:conic-gradient(from 180deg, ...)。
3.4 动画的边界:别忘了尊重用户的系统设置
倒计时动画做多了之后,我开始特别在意 prefers-reduced-motion 这个媒体查询。对于有前庭功能障碍的用户,翻牌动画和快速闪烁可能引发不适。这已经不是可选项了,而是专业性的体现。
css复制@media (prefers-reduced-motion: reduce) {
.flip-card__flip-top {
animation: none;
transform: rotateX(-90deg);
}
.countdown__number {
transition: none;
}
}
同时,为了性能,翻牌动画的卡片建议加上 will-change: transform,但要克制使用,只在确实需要动画的元素上加。倒计时每秒更新一次,如果你把 will-change 加在十几个元素上,反而会造成不必要的层提升和内存占用。
4. 常见翻车现场与排查实录
4.1 页面切回来之后,倒计时显示错乱
这个坑我早期做电商活动页时踩过。手机锁屏半小时再解开,倒计时还停留在锁屏前的时间,过了几分钟才“追”回来。原因是浏览器对后台标签页的 setInterval 做了节流,最小间隔可能被拉长到 1 秒甚至更久,导致回调长时间不执行。
解决方案就是我前面说的:不要依赖累计减,而是每次回调都用 endTime - Date.now() 重新计算。这样才能保证页面从后台切回前台时,第一次回调就能计算出正确的剩余时间。如果你还在用 totalSeconds-- 这种写法,建议立刻改掉。
一个辅助手段是监听 visibilitychange 事件,在页面回到前台时主动刷新一次:
javascript复制document.addEventListener('visibilitychange', () => {
if (!document.hidden) update();
});
这样用户切回页面时能立刻看到正确时间,不用等下一个定时器周期。
4.2 最后一秒显示 01 还是 00,结束时机总差一秒
这其实是 ceil 和 floor 的选择问题。倒计时逻辑里,时间差是毫秒级的,比如剩下 1.6 秒,显示 00:02(用 ceil)还是 00:01(用 floor)?
我的实测经验是:倒计时用 ceil 更符合直觉。因为用户理解“剩余时间”是“我还剩下多少秒可以用”,如果剩下 1.6 秒,告诉他还有 1 秒,他可能觉得时间被偷了。用 ceil,显示 2 秒,最后 0.6 秒时跳到 01,0 秒时正式归零,节奏更自然。反过来,如果是“已经消耗时间”的计时器,才用 floor。
4.3 刷新页面后倒计时重置了
这是最多初学者问的问题。原因是倒计时的目标时间 endTime 存在了 JS 变量里,刷新就丢了。解决办法很朴素:把结束时间存到 localStorage,页面加载时先检查有没有未过期的目标时间:
javascript复制const STORAGE_KEY = 'countdown_end_time';
function getOrCreateEndTime(durationSeconds) {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved && Number(saved) > Date.now()) {
return Number(saved);
}
const end = Date.now() + durationSeconds * 1000;
localStorage.setItem(STORAGE_KEY, String(end));
return end;
}
这里还有一个隐藏的注意事项:如果用户手动改了本地系统时间,Date.now() 会受影响,导致倒计时提前结束或拉长。对时间准确性要求很高的场景(比如秒杀),不能依赖用户本地时间,应该用服务器返回的时间戳,并且定期校准。一般做法是:进入页面时请求一次服务器时间,计算本地时间与服务器时间的偏差,之后在代码里用 Date.now() + offset 代替本地时间。这块逻辑不复杂,但一定要做,否则测试环境一直正常、上线就出问题,基本上都是卡在这。
4.4 数字更新时整个组件闪烁发白
如果数字更新的时候出现白色闪烁,多半是 transition 没有忽略初始渲染。比如你给数字加了 opacity 过渡,但初次加载时 JS 还没来得及赋值,数字是 opacity: 0 的原始状态,然后瞬间变成 opacity: 1,触发了过渡动画。解决办法是给组件加一个 is-ready 类,在初始化完成后再添加过渡相关的样式:
css复制.countdown__number {
opacity: 0;
}
.countdown.is-ready .countdown__number {
opacity: 1;
transition: opacity 0.3s ease;
}
JS 初始化结束后给容器加上 is-ready,这样用户不会看到从透明到显示的突兀过程。这个技巧对很多“进场动画”都通用,本质是:过渡动画只应该在状态改变时触发,不应该在初始加载时触发。
4.5 快速切到后台再切回来,倒计时直接“卡死”不再走
有些实现里,setInterval 被浏览器节流后,如果标签页长时间在后台,定时器回调可能被合并执行,但你的 update 已经判断 remainingMs <= 0 并且 clearInterval 了,理论上不会有问题。但如果你在 update 里依赖 DOM 读取当前值再减(比如先 parseInt(document.getElementById('seconds').textContent)),就会出现回调堆积时数值错乱。所有状态的唯一来源应该是时间戳差值,而不是 DOM 里的内容。这条规则写进 Code Review 清单,能帮你避免 80% 的倒计时诡异 bug。
5. 实战复盘:一个竞拍页倒计时组件的完整落地
5.1 需求整理与方案选型
我之前做过一个竞拍页,需求是这样的:拍品页展示距离本场拍卖结束的倒计时,格式用“分钟:秒”,结束前最后 1 分钟需要视觉上特别强调。技术栈就是原生 HTML/CSS/JS,没有 React 或 Vue。
方案选型时我做了这么几个决策:
| 决策点 | 选择 | 理由 |
|---|---|---|
| 时间来源 | 服务器返回的结束时间戳 + 本地时间偏移校准 | 避免用户改本机时间,保证拍卖同步 |
| 更新机制 | setInterval 500ms + Date.now() 动态计算 |
平衡性能和准确性 |
| 格式范围 | 分钟最大 99,两位数补零 | 竞拍时长不超过 99 分钟,无需小时位 |
| 视觉方案 | 数字 + 进度环 | 数字显示精确剩余,进度环感知整体进度 |
| 动画级别 | 普通数字变色 + prefers-reduced-motion 降级 |
避免过度动效影响性能 |
样式走的是硅谷风暗色主题,数字用青蓝色发光效果,进度环用渐变蓝,这些在 CSS 里都是常规操作,真正麻烦的是和业务状态的联动。
5.2 结束态的处理:不能只停在一个 00:00
倒计时归零之后,页面要进入“拍卖结束”状态。当时产品提了一个合理要求:倒计时结束后,页面上的按钮要变成灰色、不可点,同时显示“竞拍已结束”。这个逻辑不能放在 setInterval 的回调里只做一次,因为如果页面处于后台,回调被节流,用户切回来时虽然能触发 update 并走到结束分支,但如果有其他页面模块也在监听结束事件,就可能有时间差。
我的做法是:把“结束态”的判断抽成一个独立的函数,在倒计时组件内部用一个自定义事件通知外部:
javascript复制const countdown = new CountdownComponent({
endTime,
onChange: (minutes, seconds, percent) => { ... },
onComplete: () => {
document.dispatchEvent(new CustomEvent('auction:ended'));
}
});
外部模块监听 auction:ended,统一处理按钮置灰、文案切换、发送埋点。这样倒计时组件本身不依赖任何业务代码,后续其他页面复用成本也低。等拍卖结果接口返回真实状态后,再根据服务端数据修正 UI。网页端倒计时本质上只负责“读秒”,最终的业务状态永远以服务器为准,这个原则可以帮助你避免很多生产事故。
5.3 性能与体验的平衡艺术
竞拍页的倒计时组件每秒更新一次 DOM,如果整个页面还有其他高频更新(比如出价记录滚动),主线程压力会变大。这里有个简单的优化方向:update 函数里只更新需要变的文本节点,不要整个容器 innerHTML 重绘。textContent 的赋值不会触发子节点的重新渲染,成本极低。
如果页面里同时有好几个倒计时组件(列表页可能同时显示 20 个拍品的倒计时),不要给每个组件都开一个 setInterval。我当时是把所有倒计时实例注册到一个管理器里,一个全局 setInterval 统一驱动,每个实例只需要做一次时间差计算和 DOM 更新:
javascript复制class CountdownManager {
constructor() {
this.instances = [];
setInterval(() => this.tick(), 500);
}
register(instance) {
this.instances.push(instance);
}
tick() {
this.instances.forEach((i) => i.update());
}
}
组件多起来以后,这个管理器模式能显著降低定时器的资源占用。而且 tick 里做的是批量更新,浏览器只需要处理一次定时器回调,性能明显优于 20 个独立定时器。
5.4 上线前必做的几项检查清单
最后,我把这个组件上线前过一遍的清单整理出来。这些检查点基本都是真实踩过坑之后沉淀下来的:
- 检查所有时间单位是否都补零,分钟数超过 10 后布局是否抖动
- 刷新页面后倒计时是否还能继续,不能从头开始
- 手机锁屏 10 分钟再打开,倒计时是否正确
- 系统时间手动改掉后,页面是否还能和服务端保持一致
- 最后一秒的显示是否符合预期,结束回调是否只触发一次
- 所有动画在
prefers-reduced-motion: reduce下是否正常展示静态内容 - 快速点击浏览器前进后退,倒计时页面是否残留旧的定时器
定时器泄漏是个高频问题。如果你用 SPA 框架,组件销毁时一定记得 clearInterval;如果你用原生页面跳转,页面对应函数执行完毕后定时器要能自然销毁。我见过不少页面切走之后控制台还在报倒计时相关的错误,十有八九是定时器没有清理。
6. 从秒到分钟,再到一个产品级组件
写到这里,整个倒计时组件的技术路线已经比较完整了。从最简单的 Math.floor(totalSeconds / 60) 换算,到用时间戳对抗定时器漂移,再到翻牌动画、进度环和全局管理器,每个环节都有值得注意的细节。我个人的体会是:倒计时看起来是个“小功能”,但它能把前端的基础功、性能意识、用户体验细节全部串起来。你要是能把一个倒计时组件做得既准确又流畅,背后的编程素养一定不会差。
最后分享一个我常用的收尾小技巧:倒计时结束时,除了更新文字,顺便把页面标题里的数字也替换成“已结束”。“【已结束】拍品名称”的标签在浏览器标签栏里非常醒目,用户从其他标签页切回来时不用进页面就知道竞拍结束了。这个改动只有一行代码,但对关键业务场景来说,体验提升非常明显。
