补 Countdown 示例时我盯着它的实现看了一会儿:这个组件没有把「剩余时间」当成每秒减一的数字,而是拿一个终点时间戳去反算。
我原本笃定「递减一定更差」,于是把两种写法都跑了一遍------结果前台的精度几乎一样。真正拉开差距的是别的地方,这篇把过程和数据都写下来。
先看它怎么算
核心 20 行,在 Countdown.vue:
ts
const remaining = ref(props.duration);
let timer: ReturnType<typeof setInterval> | null = null;
let deadline = 0;
function tick() {
const left = Math.max(0, deadline - Date.now());
remaining.value = left;
if (left <= 0) {
clear();
emit("finish");
}
}
function start() {
if (timer !== null || remaining.value <= 0) return; // 幂等:连点开始不会叠定时器
deadline = Date.now() + remaining.value;
timer = setInterval(tick, 250);
}
关键点:remaining 不是被减出来的,而是每次由 deadline - Date.now() 算出来的。定时器只决定「什么时候算一次」,不决定「减多少」。

实测一:定时器 250ms 一次,界面却是每秒才变
我一开始的判断是:remaining 每 250ms 赋一次新值,界面必然 250ms 重绘一次,纯属白烧性能。
用 MutationObserver 盯住渲染节点,跑 6 秒:
| 指标 | 实测值 |
|---|---|
| 观察时长 | 6009 ms |
| DOM 变更批次 | 18 次 |
| 新增 / 移除的字符节点 | 9 / 9 个 |
| 相邻批次最大间隔 | 1012 ms |
| 平均间隔 | 339 ms |
最大间隔 1012ms------视觉上真正变化的是每秒一次。原因在渲染层:
html
<span v-for="(ch, i) in chars" :key="`${i}-${ch}`" class="rn-countdown__char">{{ ch }}</span>
chars 是显示文本按字符拆出来的数组,:key 里带了字符本身。remaining 每 250ms 变一次,chars 会重算,但某个位置的字符没变时 key 就没变,那个 <span> 不会重挂载。只有真正变化的数字换节点,并触发滚动动画。



说明:这三张是连拍,间隔受截图耗时影响,不代表真实 tick 间隔;tick 间隔以上表的 MutationObserver 数据为准。
250ms 到底买到了什么
既然渲染每秒才一次,为什么还要 250ms 算一次?
用「显示滞后」量化就清楚了。秒数切换是一个离散事件,如果每秒算一次,从真实时间跨过整秒到界面更新,最坏要等 1000ms;250ms 算一次,最坏只等 250ms。
| tick 间隔 | 秒切换最大滞后 | 6 秒内计算次数(实测) |
|---|---|---|
| 1000ms | ~1000 ms | 6 次 |
| 250ms(实际) | ~250 ms | 24 次 |
代价是 4 倍的计算次数。但计算只是 Date.now() 减法和给一个 ref 赋值,真正的 DOM 变更被 :key 挡在每秒一次------所以这 4 倍换来的是「更跟手的秒数」,成本主要在 JS 侧,几乎可以忽略。
反过来,如果 format 只到分钟(HH:mm),250ms 就纯属浪费:分钟级变化用 1 秒 tick,滞后最大 1 秒,肉眼完全不可见。这也是我上面说「tick 频率应该按 format 自适应」的理由。
实测二:6 秒只换了 9 个节点
按「整串重绘」的写法,00:58 这 5 个字符每秒全部重建,6 秒就是 30 个节点替换。
实测 9 个 。差出来的部分就是被 :key 保住的字符。
| 方案 | 6 秒内节点替换数 |
|---|---|
| 整串重绘 | ~30 |
| 按字符 key 复用(实际) | 9 |
| 理论最小值(只有秒的个位变) | 6 |
9 介于两者之间,因为秒的十位每 10 秒也会变,这次观测跨了两个十位区间。
实测三:显示「少一秒」是截断,不是漂移
跑 6009ms 后显示 00:53,按 60 秒起始算真实剩余是 53.99 秒。
ts
const total = Math.floor(Math.max(0, ms) / 1000);
53.99 被截断成 53。这是显示策略,不是计时误差。
实测四:递减 vs 时间戳反算,精度居然一样
这是这篇最反直觉的地方。我在页面里同时起两个实现,跑 6 秒:
js
// A:递减
setInterval(() => { dec -= 1000 }, 1000)
// B:时间戳反算
setInterval(() => { rem = deadline - Date.now() }, 250)
| 指标 | 递减方案 | 时间戳反算 |
|---|---|---|
| 实际流逝 | 6017 ms | 6017 ms |
| 计算出的剩余(真值 53983) | 54000 ms | 53998 ms |
| 误差 | -17 ms | -15 ms |
| 定时器触发次数 | 6 次 | 24 次 |
前台跑 6 秒,两者误差都在 20ms 内,差 2ms 完全在噪声范围。所以「递减一定不准」这个说法,在前台是站不住的。
那为什么还要用时间戳?三个真实理由:
1. 长跑会累积。 实测 6 次 tick 偏 17ms,约 2.8ms/tick 。30 分钟倒计时要 tick 1800 次,按这个速率会偏出 约 5 秒。时间戳方案不会累积------它每次都用绝对时间重算。
2. 后台标签页会被节流。 切到后台,浏览器把定时器降到最低 1 次/秒甚至更稀疏。递减方案的 dec 会「停在原地」,回来时已经慢了一大截;时间戳方案回来第一算就自我纠正。这一条比精度重要得多。
3. 暂停/继续的边界更干净。 递减方案里「暂停」必须处理没走完的那一秒,否则一暂停一继续就少一秒。当前实现里 pause() 只是 clear(),remaining 保持原值,start() 再用它重算 deadline,没有边界特例。
format 不只是格式,它决定单位怎么累计
Props 表里这句话容易读漏:「缺省大单位时小单位自动累计」。

ts
const hours = hasDD ? Math.floor(total / 3600) % 24 : Math.floor(total / 3600);
const minutes = hasHH ? Math.floor(total / 60) % 60 : Math.floor(total / 60);
const seconds = hasHH || hasMM ? total % 60 : total;
同一个 90 秒的 duration:
| format | 显示 |
|---|---|
HH:mm:ss |
00:01:30 |
mm:ss |
01:30 |
ss |
90 |
所以 format 不是「把时间塞进模板」,而是决定哪些单位参与进位 。写 mm:ss 时小时会累计进分钟,超过 60 分钟不会变成 01:00:00,而是 60:00。

只有一个事件,状态留在组件里
Events 表只有一行:finish。

没有 tick、没有 update:remaining,这是刻意的。如果每 250ms emit 一次剩余时间,父组件会跟着重渲染;页面上五个倒计时就是每秒 20 次父级更新。要做进度条,自己在父级起一个 interval 读时间更划算。


需要主动控制时用实例方法(defineExpose 暴露 start / pause / reset),实测行为:
| 操作 | 显示 |
|---|---|
| 初始(autoStart=false) | 01:00 |
| 点「开始」后 1.5 秒 | 00:57 |
| 点「暂停」,等 2.2 秒 | 00:57(冻结) |
| 点「重置」 | 01:00 |
两个容易被忽略的实现细节
SSR 水合。 start() 在 onMounted 里调用,服务端渲染输出的永远是 duration 的初始值。所以首屏 HTML 和客户端首帧一致,不会出现 hydration mismatch;代价是首屏要多等一个 tick 才会动。
reduced-motion。 字符滚动动画包在 @media (prefers-reduced-motion: reduce) 里,用户开了「减少动态效果」时动画归零,但数字照常更新------动效是装饰,信息不能丢。
为什么用 setInterval,不用 requestAnimationFrame
一个自然的想法:既然要平滑,为什么不用 rAF?
因为 rAF 在后台标签页会完全暂停 。倒计时不是动画,它需要在不可见时也保持正确;setInterval 至少还会以节流后的频率运行,而且就算被节流到 1 次/秒,时间戳反算依然保证回来时是对的。
动画用 rAF、计时用定时器,这两者不该混。
一个真边界:duration=0 时 finish 永远不触发
写示例时我顺手试了 duration: 0,发现一个问题:
ts
function start() {
if (timer !== null || remaining.value <= 0) return; // ← 这里
...
}
start() 在剩余为 0 时直接 return,而 onMounted 只做 if (autoStart) start()。于是 duration 为 0 的倒计时会显示 00:00:00,但 finish 一次都不发------等这个事件的调用方(比如「倒计时结束自动关闭弹窗」)会一直挂着。
这是个典型的「边界没穷尽」:正常用不会传 0,但业务里 duration 常常是算出来的(活动截止时间减去当前时间),一旦活动已过期,算出来就是负数。
修复很小,把「初始已归零」当作已结束处理:
ts
onMounted(() => {
if (remaining.value <= 0) {
emit("finish");
return;
}
if (props.autoStart) start();
});
补了两个用例:duration: 0 且默认 autoStart、以及 duration: 0 + autoStart: false,都要求挂载即 emit 一次 finish。
它被 9 个用例固定住了
Countdown 的测试文件覆盖这些行为(修完后共 9 条):
| 用例 | 固定的是什么 |
|---|---|
| 初始渲染 | 未开始时显示完整时长 |
| autoStart 递减 | 挂载后自动走 |
| autoStart=false + 手动 start | 手动开始能走 |
| 归零触发 finish | 停在 00:00:00 且只发一次 |
| pause 后不变 | 暂停真的冻结 |
| reset 恢复并停止 | 重置回初始值且不再走 |
| format 单位累计 | DD 存在与否影响 HH 含义 |
| duration=0 立即 finish | 本次补的边界 |
| duration=0 + autoStart=false | 同上,另一条路径 |
最容易回归的是 pause → start:start() 会用当时的 remaining 重算 deadline,如果哪天有人把 remaining 改成「当前显示秒数」而不是「毫秒剩余」,这条链路就会少一秒。测试用的是假的定时器,能稳定复现。
这套数据你可以自己复现
核心测量不是靠肉眼,一段脚本就够:
js
const root = document.querySelector(".rn-countdown");
const marks = [];
let added = 0;
new MutationObserver((muts) => {
for (const m of muts) added += m.addedNodes.length;
marks.push(performance.now());
}).observe(root, { childList: true, subtree: true, characterData: true });
// 6 秒后统计 marks 的间隔分布与 added 数量
注意两个口径:childList + subtree 才能看到字符节点的替换;characterData 单独开是因为文本节点内容变化不一定伴随节点替换。我第一版只开了 childList,拿到的次数偏少,后来才补上。
我会改的两处
- tick 频率可以按 format 自适应 :只有
format含ss时 250ms 才有意义;只显示mm的场景 1 秒一次就够,能省掉 3/4 的空转。 formatTime用String.replace单次替换 :若将来支持重复占位符(如HH:mm:ss HH),只会替换第一个。现在没这需求,但值得在注释里写明限制。
结论
倒计时这个小东西,真正的决策只有一条:状态由时间点推导,还是由时间流逝累加。
实测告诉我,前台两者精度几乎相同(17ms vs 15ms),所以别拿「更准」当理由。真正的理由是三条:长跑不累积、后台节流能自愈、暂停继续没有边界特例。

能用「现在几点」算出来的,就不要用「过了多久」累加出来。
在线示例:ui.ruanno.top/components/... 源码:gitee.com/ruanno-top/...