前阵子做一个秒杀活动页,需求很简单:把剩余时间显示成“分:秒”格式,比如“剩 89:32”。当时第一反应是写个 setInterval,每秒去改文本内容,顺手把秒数转成分钟。写到一半突然觉得不对,这个页面的倒计时样式要换好几轮,活动方一会儿要加进度条,一会儿要最后 10 秒变红,如果每次都在 JS 里拼字符串、改 class,后续维护会非常痛苦。
这篇文章,我就是想聊聊“剩余秒数”到“分:秒”显示这个看似简单的转换,到底有哪些值得抠的细节。核心话题是 CSS、倒计时、秒、分钟、转换技巧——但它不只是一篇教你怎么写代码的教程,更是一份我在实战中踩过坑、做过取舍的总结。适合正在做活动页、考试系统、会议倒计时,或者单纯想把倒计时做得更规范的开发者参考。
1. 倒计时的显示形态与“秒转分钟”的本质
1.1 两种常见的倒计时显示形态
这么多年看下来,倒计时界面无外乎两种形态。
第一种是直接显示总秒数,比如“剩余 6210 秒”,这种在大厦电梯里很常见,简单粗暴,但对人来说不友好——6210 秒到底是多久?大部分人需要心算一下才知道是 103 分钟。第二种是显示成“分:秒”格式,比如“103:30”,或者更完整的“01:43:30”(时:分:秒)。我在实际项目里遇到最多的是“分:秒”这个格式,尤其是时长控制在一个小时以内的场景:秒杀、考试倒计时、电话会议剩余时间、视频发布倒计时,基本都是用它。
这个形态选择的背后其实是一个体验问题。人脑对时间的感知更倾向于“还有几分钟”,而不是“还有几百秒”,所以把秒转换成分钟,不是技术上的自我感动,而是产品体验的基本要求。而“转换”这件事,看起来只是除以 60 取整、取余,但具体在哪个环节做,用什么方式做,直接决定了后续代码好不好维护、样式好不好改。
1.2 秒转分钟的本质:整除、取余和显示层职责
我们拿一个具体数字来拆解。假设某个倒计时当前剩余 5378 秒,要显示成“89:38”(89 分钟 38 秒),需要做两步:
- 分钟数:5378 ÷ 60 = 89 余 38,取整数部分,得到 89
- 秒数:5378 对 60 取余,得到 38
在 JavaScript 里就是 Math.floor(total / 60) 和 total % 60 的关系。这其实就是整个转换的核心数学逻辑,没有任何高深的地方。真正需要想清楚的是:这两个结果,应该在什么时候算好、由谁来算。
最直觉的写法是每秒钟更新 DOM 文本:
javascript复制const el = document.getElementById('countdown');
let total = 5378;
const timer = setInterval(() => {
total--;
const min = Math.floor(total / 60);
const sec = total % 60;
el.textContent = `${min}:${String(sec).padStart(2, '0')}`;
if (total <= 0) clearInterval(timer);
}, 1000);
这段代码对不对?功能上完全对。但问题在于,它把“数据计算”和“展示格式”混在一起了。如果产品经理说“分钟部分不要补零,秒数要两位”,你得改模板字符串;如果又说“最后 10 秒要变成红色”,你还得在这里加 class 操作。时间一长,这个函数会越写越长。
我个人的习惯是:JS 只负责把 total 这个数字算出来,并且转换成 min/sec 两个独立变量;CSS 负责格式化和视觉表现。 这样“秒转分钟”的数学逻辑固定在一个地方,UI 想怎么变都行。后面第 3 部分我会给出具体实现。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 纯 CSS 演示:@property + counter 让秒数自己“走起来”
在讲工程实践之前,我想先介绍一个很有意思的纯 CSS 玩法:不用一行 JS,让数字在页面上自动倒计时。你可能觉得这是炫技,但它背后用到的 @property 和 counter 机制,恰好能帮我们理解“秒到分钟”转换时,哪些事 CSS 能做、哪些事它做不了。
2.1 用 @property 注册一个整数型自定义属性
CSS 自定义属性(--var)大家都很熟,但默认情况下它只是字符串容器。到了 Chrome 85 之后,我们可以通过 @property 把它注册成某种类型,比如 <integer>、<number>、<length>。这里最关键的是 <integer> 类型,一旦注册成整数,动画帧就可以对这个值做离散的插值。
css复制@property --seconds {
syntax: '<integer>';
initial-value: 9;
inherits: false;
}
syntax 定义了变量类型,initial-value 是默认值,inherits 我建议设成 false,避免这个变量被意外继承到子元素去。注册完之后,--seconds 在计算时就是一个整数,可以被 animation 直接驱动。
2.2 关键帧 + steps() 控制离散跳动
如果我们写一个普通的关键帧,从 9 渐变到 0,比如:
css复制@keyframes countdown {
from { --seconds: 9; }
to { --seconds: 0; }
}
因为 --seconds 是整数类型,它不会像小数那样平滑过渡,而是会在逐帧之间跳变。但跳变的时机不是严格每秒钟一次的。为了让它在每一秒只变化一次,需要配合 steps(9) 这个时间函数。steps(n) 会把动画时长切成 n 段,每段保持一个值。
结合 animation: countdown 9s steps(9) forwards;,整个动画会有 9 个台阶,每个台阶持续 1 秒,值依次是 9、8、7、6、5、4、3、2、1、0——正好 10 个状态,对应 9 秒的倒计时。
2.3 counter-reset + content 把变量渲染成文本
CSS 变量本身不能直接显示在页面上,content: var(--seconds) 是不合法的。但我们可以借用 counter 机制:counter-reset 允许我们把一个整数值赋给计数器,然后再用 counter() 函数把它渲染出来。
css复制.countdown::before {
counter-reset: seconds var(--seconds);
content: counter(seconds);
}
这里我给伪元素取名为 .countdown,把 --seconds 的值塞进 seconds 计数器,然后显示出来。这是一种很经典的“CSS 变量 → 计数器 → 文本”的绕行方案。
2.4 完整 Demo:9、8、7……0 的纯 CSS 倒计时
直接看完整代码:
css复制@property --seconds {
syntax: '<integer>';
initial-value: 9;
inherits: false;
}
.countdown {
font-size: 48px;
font-weight: bold;
font-variant-numeric: tabular-nums;
}
.countdown::before {
counter-reset: seconds var(--seconds);
content: counter(seconds);
animation: countdown 9s steps(9) forwards;
}
@keyframes countdown {
from { --seconds: 9; }
to { --seconds: 0; }
}
HTML 只需要一行:
html复制<div class="countdown"></div>
这个 Demo 在 Modern Chrome、Edge、Safari 上都能跑。它的意义在于告诉我们:CSS 有能力让一个整型变量随时间做离散变化。但请注意,上面的例子只能展示“秒”这一层。如果我们想显示“89:38”,需要同时驱动分钟和秒两个变量,还要让分钟变量每 60 秒跳一次,这就需要两个不同周期的动画共用一个时钟,纯 CSS 写起来会非常绕。这也是我在第 3 部分转向“JS 算数 + CSS 渲染”混合方案的根本原因。
3. 秒转分钟的正确分工:JS 做换算,CSS 做渲染
3.1 为什么不在 CSS 里做除法和取余
虽然 CSS 有了 calc(),但它的能力边界很明确:可以计算长度、角度、百分比等数值,却不能把计算结果作为“文本内容”输出。上面我们看到的 counter() 输出,只能是计数器值而不是表达式结果。更关键的是,CSS 里没有“取余”运算:写 calc(var(--total) % 60) 会直接被浏览器判为非法。
说白了,CSS 适合做“映射和展示”,不适合做“进制转换”。把 5378 秒换算成 89 分 38 秒,这件事用 JavaScript 的 Math.floor 和 % 是最干净、最可靠的。我们要做的,不是让 CSS 去做数学题,而是让 CSS 拿到最终的两个整数后,舒服地把它显示出来,并且在样式上拥有完整的控制权。
3.2 推荐方案:--min / --sec 双变量渲染
我的基础方案是这样的:JS 每秒算出剩余总秒数,然后把分钟和秒分别写入两个 CSS 自定义属性 --min 和 --sec。CSS 端用 counter-reset 把两个属性分别映射成两个计数器,再通过 content 渲染出来。
css复制.countdown {
font-size: 56px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.countdown::before {
counter-reset: min var(--min) sec var(--sec);
content: counter(min) ":" counter(sec, decimal-leading-zero);
}
注意 content 里我写了 counter(sec, decimal-leading-zero),这个 decimal-leading-zero 是计数器样式,它可以让个位秒自动补零:5 秒显示成 05,0 秒显示成 00。这样我们就不用再在 JS 里写 String(sec).padStart(2, '0') 了,格式问题完全交给 CSS。
3.3 实现一个“90:00”倒计时组件
我们把方案落地成一个可以直接抄走的组件。HTML 结构很简单:
html复制<div class="countdown" id="demoCountdown"></div>
CSS 部分是上面那一段,加上一个用于收尾的样式:
css复制.countdown.is-finished {
color: #999;
}
JS 部分,我用一个目标结束时间戳来推剩余时间,而不是简单的 total--。原因放在第 5 部分讲,先把代码贴出来:
javascript复制const el = document.getElementById('demoCountdown');
const DURATION = 90 * 60; // 90 分钟,单位秒
// 结束时间 = 当前时间 + 时长
let endTime = Date.now() + DURATION * 1000;
function update() {
const remainMs = endTime - Date.now();
const remain = Math.max(0, Math.floor(remainMs / 1000));
el.style.setProperty('--min', Math.floor(remain / 60));
el.style.setProperty('--sec', remain % 60);
if (remain <= 0) {
clearInterval(timer);
el.classList.add('is-finished');
alert('时间到');
}
}
update();
const timer = setInterval(update, 250);
这里我做了两件“反直觉”但很实用的事:第一,定时器间隔设成 250ms 而不是 1000ms,是为了抵消定时器漂移;第二,剩余时间的计算用 endTime - Date.now(),只要系统时间正常,即使定时器被延迟几十毫秒,最终显示的剩余秒数依然是准确的。秒转分钟的数学逻辑就三行:remain / 60 取整、remain % 60 取余、Math.max(0, ...) 防负。
3.4 为什么用 CSS 变量而不是直接改 textContent
你可能会问:既然 JS 都已经算出 min 和 sec 了,为什么不直接 el.textContent = min + ':' + sec,非要绕一圈走 CSS 变量和 counter?
我的理由有三个。
第一,关注点分离。JS 只输出“还剩几分钟几秒”这个数据,不关心它长什么样。下次产品说要显示成“89分38秒”,我只需要改 content;如果要说“剩余时间:89:38”,也只需要改 content。JS 一行不动。
第二,样式平滑。CSS 变量的赋值不会触发 DOM 重新渲染整段文本,浏览器只需要重新计算依赖这个变量的样式,性能上更轻。倒计时的频率是每秒一次,性能差异不明显,但如果你要做毫秒级跳表,这个优势会变大。
第三,可扩展性。基于这两个变量,我可以很自然地在 CSS 里算进度条宽度、切换颜色,这些在第 4 部分会看到。如果直接用 textContent,进度条还得再来一个元素,由 JS 单独更新,等于一套逻辑拆两份。
4. 倒计时组件的视觉增强:进度条、紧急状态与收尾动画
4.1 把剩余比例映射成进度条宽度
“还剩多少时间”除了数字,最好的可视化方式就是进度条。我习惯把“剩余比例”单独存一个 0 到 1 的变量叫 --progress,然后让 CSS 用它来控制宽度。
JS 里加一行:
javascript复制el.style.setProperty('--progress', remain / DURATION);
CSS 里加一个进度条元素:
css复制.countdown-wrap {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.progress {
width: 200px;
height: 6px;
background: #eee;
border-radius: 3px;
overflow: hidden;
}
.progress i {
display: block;
height: 100%;
width: calc(var(--progress) * 100%);
background: #2ecc71;
transition: width 0.3s linear;
border-radius: inherit;
}
这里要注意,calc(var(--progress) * 100%) 的写法要求 --progress 是无单位的数字,单位放在 100% 上。为什么不直接让 JS 去改宽度?因为宽度是一个像素值,它在不同屏幕尺寸下表现不同。我们交给 CSS 变量传递比例,布局的事情让布局系统自己算,这样进度条宽度在响应式页面里也不会出错。
4.2 最后 10 秒的紧急状态变色
倒计时最后 10 秒通常要“变红”提醒用户抓紧。很多人会在 JS 里做判断,然后 classList.add('urgent')。这也行,但既然我们已经有了 --progress 这个 0~1 的数字,其实可以用一条纯 CSS 规则来判断。
不过我实测发现,目前 CSS 的 if()、min()/max() 组合虽然能处理一些条件逻辑,但要表达“剩余时间是否小于 10 秒”这种需求,还是 class 切换最直接。所以我的做法是:JS 算完 remain 之后,顺手切一个 class:
javascript复制el.classList.toggle('is-urgent', remain <= 10 && remain > 0);
CSS 侧:
css复制.countdown.is-urgent .time-text {
color: #e74c3c;
animation: pulse 1s ease-in-out infinite;
}
@keyframes pulse {
50% { opacity: 0.4; }
}
这样做的好处是,JS 只负责“是否小于等于 10 秒”这个布尔判断,至于红色、闪烁频率、是否放大,全部由 CSS 控制。活动运营说“最后 10 秒要变成橙色并且闪烁更快”,改 CSS 就行,不会误伤 JS。
4.3 结束瞬间的归零与禁用状态
倒计时结束后,最忌讳的是显示负数,比如 -1:-1。所以 JS 里我用了 Math.max(0, ...) 把最小值卡在 0。同时清掉定时器,加上 is-finished class。
CSS 里可以配合做一个收尾效果:
css复制.countdown.is-finished {
color: #95a5a6;
}
.countdown.is-finished::before {
content: "00:00";
}
第二种方式更实用:不管之前 --min 和 --sec 里是什么值,一旦进入结束状态,直接强制显示 00:00。这个思路也适合用在“活动已结束”的文案替换上,不需要在 JS 里再拼一次字符串。
4.4 多实例复用的组织方式
实际页面里,可能同时存在多个倒计时:抢购列表里的每个商品都有一个。我不建议给每个倒计时单独写一套定时器。
更好的组织方式是把所有倒计时元素收集起来,用一个统一的定时器统一更新:
javascript复制const items = document.querySelectorAll('.countdown');
function updateAll() {
items.forEach((el) => {
const end = Number(el.dataset.end);
const remain = Math.max(0, Math.floor((end - Date.now()) / 1000));
el.style.setProperty('--min', Math.floor(remain / 60));
el.style.setProperty('--sec', remain % 60);
if (remain <= 0) {
el.classList.add('is-finished');
}
});
}
updateAll();
const timer = setInterval(updateAll, 250);
每个实例只需要在 HTML 里携带 data-end 时间戳:
html复制<div class="countdown" data-end="1768983600000"></div>
这样无论是 5 个商品还是 50 个商品,都只有一个定时器在跑,性能开销非常小。秒到分钟的转换逻辑也集中在一处,不会因为复制粘贴导致逻辑分叉。
5. 实测中踩过的坑:兼容性、定时器与自定义属性
5.1 @property 的兼容性测试结果
我最早做的纯 CSS 倒计时 Demo 在 Chrome 上表现很好,但拿到 Firefox 上就“哑火”了——数字根本没显示。原因很简单:Firefox 直到 128 版本才支持 @property,之前版本会直接把带 @property 的规则忽略,导致变量没有注册,counter-reset 拿到的是一个无效值。
所以做纯 CSS 玩法之前,务必确认目标用户群用的浏览器。如果项目要兼容老旧浏览器,纯 CSS 方案只能当彩蛋,不能当主力。工程方案(JS 算好变量交给 CSS 渲染)反而是兼容性最好的,因为普通 CSS 自定义属性在 IE11 之后的所有现代浏览器里都能用。
5.2 秒数不跳变:syntax 类型写错了
用纯 CSS 方案时最常见的问题是:数字显示了,但它一直停在初始值,一秒都不动。我调了半天,最后发现 @property 里的 syntax 写成了 '<number>',而不是 '<integer>'。
原因是 <number> 允许小数,动画帧之间会做平滑插值,但 counter-reset 不接受小数,它会把数值截断,导致页面看起来数值“不动”。改成 <integer> 之后,动画每一帧强制取整,counter() 才能正常显示跳变。这是个非常隐蔽的坑,写出来希望大家少走弯路。
5.3 counter-reset 里变量失效的问题
还有一个很容易踩的坑:counter-reset 不是所有地方都能正确读变量。
请看这段错误写法:
css复制.countdown::before {
counter-reset: min var(--min) sec var(--sec);
content: counter(min) ":" counter(sec, decimal-leading-zero);
}
如果 --min 或者 --sec 从未被设置过,counter-reset 整条规则都会失效,content 里的 counter 输出会变成空值,页面直接不显示内容。解决方法是保证元素在挂载时 JS 先执行一次 update(),把默认值写进去,或者在 HTML 里用内联样式给一个初始值:
html复制<div class="countdown" style="--min: 90; --sec: 0;"></div>
5.4 页面切后台,倒计时为什么慢了
很多人用 total-- 来更新倒计时,结果发现把页面切到后台半小时后再切回来,倒计时还停留在三分之一的位置。原因是浏览器为了省电,会限制后台标签页的 setInterval 执行频率,有时候甚至会暂停执行。
解决办法就是我在 3.3 里写的:不要依赖“每秒减 1”,而是每次执行时都用 endTime - Date.now() 重新推算出真实剩余时间。只要结束时间戳不变,浏览器就算暂停了定时器,回来恢复执行的第一次计算也能瞬间纠正显示,让倒计时“追”上真实时间。这在秒杀等对时间敏感的场景里是必须的。
5.5 一个性能提醒
倒计时的频率不要设太高。我见过有人用 10ms 的 setInterval 去刷新一个每分钟才跳一次的界面,这纯属浪费。时间是秒级精度时,250ms 的刷新间隔已经足够:它既能及时纠正微小漂移,又不会高频触发布局计算。如果你需要毫秒级的“百分之一秒”跳动,那另说,但那种场景通常不应该用 DOM 文本刷新,而是用 Canvas 之类的方式。
另外一个和 CSS 相关的性能点:counter-reset 和 content 的变化会不会触发重排?我实测下来,它只触发伪元素的重绘,不会引起整页布局抖动。但如果你把倒计时的字号设得很大,并且页面里有大量文字,建议开启 contain: content 把倒计时元素的作用域隔离,让浏览器跳过它外部的内容重排计算。
5.6 关于“00:00”补零的样式细节
最后分享一个小细节。秒转分钟后,如果秒数是个位数,人类阅读习惯是补零显示成 08,这没问题。但分钟部分要不要补零呢?产品需求不一样:有的希望显示 89:08,有的希望显示 089:08(总时长原本就是三位数分钟)。我的建议是分钟不补零,只让秒数补零。因为分钟数通常会超过两位数,补零后会一直变宽,布局会抖;而秒数永远在 0~59 之间,补零后宽度恒定。如果要让整个倒计时数字不抖动,记得给容器加上 font-variant-numeric: tabular-nums,让数字采用等宽变体,不然每秒跳动时数字宽度变化会非常明显。
我实际用下来,“秒转分钟”这个转换本身并不难,难的是把转换的逻辑放在一个合理的位置,让数据、数学、展示各司其职。JS 负责把秒数算成分钟和秒两个整数,CSS 负责把它渲染成好看且稳定的界面,这套分工无论项目多大多小,都是值得长期坚持的。下一次你再遇到倒计时需求,可以先从这一行 counter-reset: min var(--min) sec var(--sec); 写起。
