前端倒计时为什么会越走越慢?用时间戳重写计时器
【摘要】 番茄钟切到后台标签页后计时严重偏慢,根因是把 setInterval 当秒表、靠累加计算剩余时间。本文解释后台限流与主线程阻塞导致的误差累积,并给出以结束时间戳为准的重写方案,以及暂停恢复、visibilitychange 刷新等细节处理。
番茄钟上线后我自己用了几天,发现一个尴尬的问题:切到别的标签页写作业,二十多分钟后回来,计时器还剩七八分钟没走完。明明设的是 25 分钟,实际却拖了半个多小时。排查下来,问题出在计时方式上。
一、错误写法:把 setInterval 当秒表
let left = 25 * 60;
setInterval(() => {
left--; // 每次假设正好过了 1 秒
render(left);
}, 1000);
这段代码隐含了一个假设:每次回调之间正好相隔 1 秒。但这个假设在浏览器里并不成立,原因有两个。
- 后台标签页被限流:页面不可见时,浏览器会把定时器的最小间隔拉长到 1 秒甚至更久,省电策略更激进的时候间隔会更长。于是真实时间走了 30 分钟,回调可能只执行了 1200 次。
- 主线程阻塞会顺延:setInterval 只保证“不早于”指定时间触发。页面在渲染长列表或执行耗时脚本时,回调会被推迟,误差会一次次累积。
也就是说,用累加的方式计时,误差只会越积越多,不会自己修正。
二、正确写法:只信时间戳
思路很简单:不要去数“过了多少次 1 秒”,而是在开始时记下结束时刻,之后每次刷新都用当前时间重新算剩余秒数。
let endAt = 0; // 结束时刻的时间戳
let timer = null;
function start(seconds) {
endAt = Date.now() + seconds * 1000;
tick();
timer = setInterval(tick, 250); // 刷新比 1 秒更密,减少跳秒
}
function tick() {
const left = Math.max(0, Math.round((endAt - Date.now()) / 1000));
render(left);
if (left === 0) {
clearInterval(timer);
timer = null;
onFinish();
}
}
这样一来,定时器只负责“触发刷新”,不负责“计量时间”。即使回调被推迟,甚至几十次没有执行,下一次醒来算出的剩余时间依然是准确的。我把刷新间隔设成 250 毫秒,是为了让秒数跳变看起来更自然,代价可以忽略。
三、暂停和继续
既然记录的是结束时刻,暂停时就要把它换算回剩余秒数,恢复时再重新计算:
let pausedLeft = 0;
function pause() {
clearInterval(timer);
timer = null;
pausedLeft = Math.max(0, Math.round((endAt - Date.now()) / 1000));
}
function resume() {
start(pausedLeft); // 用剩余秒数重新计算结束时刻
}
四、几个容易忽略的细节
- 回到前台时立即刷新:监听 visibilitychange,页面重新可见时主动调用一次 tick,避免用户先看到一个过期的旧数字。
- 用户改系统时间:Date.now() 会跟着跳变。如果要防这种情况,可以改用 performance.now(),它是单调递增的,不受系统时钟影响。对番茄钟来说一般没必要。
- 取整用 round 而不是 floor:用 floor 会让计时器一启动就显示少 1 秒,观感上像是丢了一秒。
- 结束提醒要有兜底:如果标签页在后台,弹窗和提示音可能要等回到前台才生效。可以在 tick 里判断剩余时间已经为 0,补一次提醒。
五、小结
前端做任何计时功能,原则都是一样的:定时器用来驱动刷新,时间一律从时间戳算。凡是把 setInterval 当秒表、靠累加得出结果的写法,在后台标签页或繁忙页面里都会失准。改完之后,我再切出去写作业,回来时番茄钟的剩余时间终于对得上了。
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱:
cloudbbs@huaweicloud.com
- 点赞
- 收藏
- 关注作者
评论(0)