倒计时该用 deadline 还是每秒减一?我把两种写法都跑了一遍

补 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,拿到的次数偏少,后来才补上。

我会改的两处

  1. tick 频率可以按 format 自适应 :只有 format 含 ss 时 250ms 才有意义;只显示 mm 的场景 1 秒一次就够,能省掉 3/4 的空转。
  2. formatTime 用 String.replace 单次替换 :若将来支持重复占位符(如 HH:mm:ss HH),只会替换第一个。现在没这需求,但值得在注释里写明限制。

结论

倒计时这个小东西,真正的决策只有一条:状态由时间点推导,还是由时间流逝累加。

实测告诉我,前台两者精度几乎相同(17ms vs 15ms),所以别拿「更准」当理由。真正的理由是三条:长跑不累积、后台节流能自愈、暂停继续没有边界特例。

能用「现在几点」算出来的,就不要用「过了多久」累加出来。


在线示例:ui.ruanno.top/components/... 源码:gitee.com/ruanno-top/...

相关推荐
徐小夕5 小时前
下载量超 30 万的开源智能体 Prime Agent:它把自己从 TypeScript 重写成了 Rust
前端·vue.js·github
toooooop85 小时前
CRMEB 故障排查笔记:安全网关只放行 GET/POST 怎么办
前端·javascript·vue.js·thinkphp6·crmeb
软件技术NINI5 小时前
基于SpringBoot和Vue的项目评审系统
javascript·vue.js·spring boot·后端·spring·毕业设计·毕设
FRing0318 小时前
vue2干货总结3(computed计算属性、watch监听器)
开发语言·前端·vue.js·vue2
Java小卷18 小时前
JuanForm vs FormCreate!Vue3 表单引擎联动配置 PK!
vue.js·低代码
小郎君6581 天前
Vue 多标签页的加载态设计:为什么我没有用 Suspense 实现 PageLoading
vue.js·算法
yilaisai1 天前
前端开发遇到的一些细节问题整理
vue.js
华玥作者1 天前
Vue 3 + TypeScript + uni-app 组件库推荐:hy-app(华玥组件库)
vue.js·typescript·uni-app
阿琳a_1 天前
b-map-viewer:面向 Vue 3 的地图可视化 SDK,从接入到展示再到交互分析
前端·javascript·vue.js·gis·数据可视化·地图