做多阶段倒计时的需求很常见:健身应用里一组动作30秒、休息15秒循环往复;在线考试里第一部分20分钟、第二部分40分钟;答题小游戏里每题限时10秒。这类场景的共同点是计时器由多个阶段组成,每个阶段时长不同,阶段结束后自动进入下一个。真正麻烦的地方在于,用户经常会把标签页切走——去看微信、打开别的网页,等切回来时发现页面上的秒数纹丝不动,或者干脆乱跳。要彻底解决这个问题,不能只依赖定时器本身,得把「定时触发」和「真实时间计算」分开处理。

为什么标签切走后计时器会失灵
先搞清楚根源。现代浏览器为了省电,会对不可见标签页里的定时器做节流处理。以Chrome为例,后台标签页中的setTimeout和setInterval触发间隔会被强制拉长到至少1秒,有些情况下甚至限制得更严格。如果你的页面里用setInterval(fn, 1000)每秒给计数器减一,切到后台十分钟后回来,实际可能只执行了几百次,页面显示的时间就和真实流逝的时间差了一大截。
另一个坑是计时器的「累积误差」。即使页面在前台,setInterval(fn, 1000)也不是精准的每秒触发,浏览器只保证不早于指定时间触发,实际间隔往往是1002毫秒、1005毫秒这样一点点往后漂。计时阶段一多、总时长一长,误差就越积越大。所以正确的思路从一开始就该确定:定时器只负责驱动界面刷新,真实剩余时间永远用时间戳算出来。
具体做法是记录每个阶段的起始时间戳,渲染时用Date.now()减去起始时间戳得到已流逝的毫秒数,再用阶段总时长减去它得到剩余时间。这样即使定时器被节流、被暂停、被跳过若干次,只要时间戳还在,算出来的结果就是准的。
用visibilitychange监听标签切换并触发重算
知道了原理,接下来要解决「什么时候重算」。浏览器提供了document.visibilitychange事件,当标签页从可见变为隐藏、或从隐藏变为可见时都会触发,配合document.hidden属性就能判断当前状态。相比被动等待被节流的定时器,主动监听这个事件能让我们在用户切回页面的第一时间刷新显示。
下面是一段基础的事件绑定代码,同时考虑了部分旧浏览器使用webkitHidden的情况:
// 兼容处理:旧版浏览器可能带前缀
var visibilityProperty = 'hidden' in document ? 'hidden' : 'webkitHidden';
var visibilityEvent = 'hidden' in document ? 'visibilitychange' : 'webkitvisibilitychange';
document.addEventListener(visibilityEvent, function () {
if (document[visibilityProperty]) {
// 标签页进入后台:可以选择暂停计时,或让时间继续流逝
console.log('页面已隐藏');
pauseTick();
} else {
// 标签页回到前台:立即重算剩余时间并恢复刷新
console.log('页面重新可见');
recalculate();
resumeTick();
}
});这里有一个设计决策需要根据业务定:切后台时到底是暂停计时还是继续计时。做在线考试限时,通常要求时间照常流逝,切走也没用,那就只需要在recalculate里用当前时间戳重算,甚至可能直接判定几个阶段已经结束;而做健身计时或秒表,用户切走大概率是临时接个电话,暂停计时会更贴心。两种策略的核心区别就是切回来时要不要把隐藏期间流逝的时间计入。
如果选择「继续计时」策略,还必须处理一种边界情况:用户在后台待的时间足够长,可能已经跨过了好几个阶段。重算时不能只看当前阶段,要写一个循环,把已结束的阶段逐个结算掉,直到找到当前应该处于的阶段为止。这就是下面完整方案里settlePhases函数要做的事。
完整实现:支持多阶段自动切换与误差校准
把前面的思路整合起来,实现一个多阶段计时器类。它接收一个阶段数组,每个阶段包含名称和时长,内部始终用时间戳计算进度,setInterval只以500毫秒的频率调用渲染函数刷新界面。
class MultiPhaseTimer {
constructor(phases, onComplete) {
this.phases = phases; // [{name: '运动', duration: 30000}, ...]
this.currentIndex = 0; // 当前阶段下标
this.phaseStartTime = 0; // 当前阶段的起始时间戳
this.running = false;
this.onComplete = onComplete || function () {};
}
start() {
this.running = true;
this.phaseStartTime = Date.now();
this._tick();
this._timer = setInterval(this._tick.bind(this), 500);
}
// 核心结算逻辑:处理跨阶段的情况
_settle() {
var now = Date.now();
while (this.running && this.currentIndex < this.phases.length) {
var elapsed = now - this.phaseStartTime;
var total = this.phases[this.currentIndex].duration;
if (elapsed >= total) {
// 当前阶段已结束,结算并推进到下一阶段
this.currentIndex++;
// 关键:把超出部分带入下一阶段,避免误差丢失
this.phaseStartTime += total;
} else {
return; // 当前阶段未结束,停止结算
}
}
this._finish();
}
_tick() {
this._settle();
if (!this.running) return;
var now = Date.now();
var phase = this.phases[this.currentIndex];
var remaining = phase.duration - (now - this.phaseStartTime);
// 渲染到页面
this.render(phase.name, Math.ceil(remaining / 1000));
}
_finish() {
this.running = false;
clearInterval(this._timer);
this.onComplete();
}
pause() {
if (!this.running) return;
this._pausedElapsed = Date.now() - this.phaseStartTime;
this.running = false;
clearInterval(this._timer);
}
resume() {
if (this.running) return;
this.running = true;
this.phaseStartTime = Date.now() - (this._pausedElapsed || 0);
this._timer = setInterval(this._tick.bind(this), 500);
this._tick();
}
// 标签页重新可见时立即刷新一次,消除后台节流造成的显示滞后
onVisible() {
if (this.running) this._tick();
}
render(name, seconds) {
var el = document.getElementById('timer-display');
if (el) el.innerHTML = name + ' 剩余 ' + seconds + ' 秒';
}
}
// 使用示例:三阶段训练计划
var timer = new MultiPhaseTimer([
{ name: '热身', duration: 10000 },
{ name: '训练', duration: 30000 },
{ name: '拉伸', duration: 15000 }
], function () {
alert('全部阶段完成');
});
timer.start();
document.addEventListener('visibilitychange', function () {
if (!document.hidden) timer.onVisible();
});这段代码里有几个细节值得注意。第一,_settle中的结算用的是while循环而不是if判断,就是为了覆盖用户后台停留时间超过多个阶段总时长的情况。第二,结算时phaseStartTime += total而不是重新取Date.now(),这样上一阶段多出来的零头时间会带入下一阶段,整体时间轴不会漂移。第三,渲染用的剩余秒数是Math.ceil向上取整,用户看到的第一个数正好是满秒数,体验更自然。
几个容易踩的坑和补充建议
第一,不要在visibilitychange回调里做重操作。这个事件在切换瞬间触发,如果回调里同步执行大量计算或DOM操作,会让页面回显变卡。正确的做法是回调里只标记一个状态,重活交给下一帧或requestAnimationFrame处理。
第二,如果项目允许引入依赖,更精细的场景可以考虑用Web Worker跑定时器。Worker中的定时器不受标签页可见性的严格节流限制,触发频率相对稳定,适合那种必须在后台也保持节奏的任务,比如后台播放提示音的前置调度。不过要注意,即便用Worker,剩余时间依然建议用时间戳计算,两者是配合关系而非替代关系。
第三,做好阶段切换时的用户提示。多阶段计时器最大的体验问题是用户不知道阶段已经切换了,尤其是从后台切回来发现直接跳到了拉伸阶段。可以在_settle推进阶段时播放一段短音效、或者给页面标题(document.title)加上剩余秒数,这样即使用户在别的标签页也能看到进度,这个技巧在倒计时类应用里非常实用。
总结一下核心原则:定时器负责「什么时候刷新」,时间戳负责「显示什么数字」,visibilitychange负责「什么时候抢救」。三者各司其职,多阶段计时器在任何切换场景下都能保持准确。
JavaScript计时器visibilitychange计数器重置修改时间:2026-09-15 13:48:45