网页倒计时为什么越走越慢:用截止时间修正后台节流
前面的番茄待办系列介绍了提示音、SVG 圆环和本地存储。这一篇单独处理计时逻辑:如果标签页切到后台,或者主线程忙了一阵,如何避免“25 分钟倒计时”实际走得更久?下面给出一个可以接入纯前端小工具的示例方案,不依赖第三方库。
一、问题不在显示,而在计时依据
常见写法是每隔一秒执行一次 remaining -= 1。但 setInterval(fn, 1000) 并不承诺回调每隔精确的 1000 毫秒运行。主线程长任务、浏览器后台节流、页面冻结或设备休眠,都可能推迟回调。
假设一次回调在 5 秒后才得到执行,代码仍只减去 1 秒,就会少算 4 秒。多次延迟累加,倒计时便越来越慢。把刷新频率改成 100 毫秒,只能改善某些情况下的显示,不会从根本上修复这个问题。
二、把定时器当作刷新器,而不是时钟
开始时记录截止时间 deadline = Date.now() + remainingMs。每次显示前重新计算 Math.max(0, deadline - Date.now())。即使中间漏掉若干次刷新,下一次执行仍能按当前时间得到剩余时长。
这是一种“到点时间”语义:切到后台或设备睡眠期间,时间仍然流逝。它不代表浏览器能在冻结时执行 JavaScript,也不保证到点那一刻一定响铃。
三、最小界面
把下面的 HTML 放入页面,再将下一段 JavaScript 放在这些元素之后。示例只包含倒计时、开始、暂停和重置,暂不加入音频、跨标签页协调或存储。
<output id="timer" aria-label="剩余时间">25:00</output> <p id="status" role="status">未开始</p> <button id="start" type="button">开始或继续</button> <button id="pause" type="button">暂停</button> <button id="reset" type="button">重置</button>
四、实现开始、暂停、恢复和结束
// 将这段脚本放在上述 HTML 后面。
const durationMs = 25 * 60 * 1000;
const display = document.querySelector("#timer");
const statusText = document.querySelector("#status");
let remainingMs = durationMs;
let deadline = 0;
let phase = "ready";
let intervalId = null;
function stopTicker() {
if (intervalId !== null) clearInterval(intervalId);
intervalId = null;
}
function currentRemaining() {
return phase === "running"
? Math.max(0, deadline - Date.now())
: remainingMs;
}
function finish() {
if (phase !== "running") return;
phase = "finished";
remainingMs = 0;
deadline = 0;
stopTicker();
// 状态先变更,再执行计数、提示等副作用。
statusText.textContent = "本轮已结束,请休息。";
}
function render() {
const left = currentRemaining();
if (phase === "running" && left === 0) finish();
const seconds = Math.ceil(left / 1000);
display.textContent =
String(Math.floor(seconds / 60)).padStart(2, "0") + ":" +
String(seconds % 60).padStart(2, "0");
}
function start() {
if (phase === "running" || phase === "finished") return;
deadline = Date.now() + remainingMs;
phase = "running";
statusText.textContent = "专注中";
stopTicker();
intervalId = setInterval(render, 250);
render();
}
function pause() {
if (phase !== "running") return;
remainingMs = currentRemaining();
if (remainingMs === 0) {
finish();
render();
return;
}
phase = "paused";
deadline = 0;
stopTicker();
statusText.textContent = "已暂停";
render();
}
function reset() {
stopTicker();
phase = "ready";
remainingMs = durationMs;
deadline = 0;
statusText.textContent = "未开始";
render();
}
document.querySelector("#start").addEventListener("click", start);
document.querySelector("#pause").addEventListener("click", pause);
document.querySelector("#reset").addEventListener("click", reset);
document.addEventListener("visibilitychange", () => {
if (!document.hidden) render();
});
window.addEventListener("pageshow", render);
render();
开始或恢复时,根据剩余毫秒数重建 deadline;暂停时,先结算当前剩余时间,再切换状态并停止刷新。这样暂停期间不会扣时间,也不必维护多个“暂停总时长”变量。
phase 用来区分未开始、运行中、已暂停和已结束。重复点击开始不会创建多个 interval;完成后也不会因为再点开始而立即重复计数,需要重置才能开启下一轮。
五、三个容易忽略的边界
1. 结束回调必须防重复。定时刷新、visibilitychange 和 pageshow 都可能进入 render。finish 先检查运行状态,再将状态改为 finished,随后才处理计数或提示。这能防止同一页面实例把一轮番茄重复记录;它不解决多个标签页之间的并发,需要另外设计协调机制。
2. 显示秒数用向上取整。剩余 200 毫秒仍显示 00:01,到 0 才显示 00:00。结束判断基于毫秒值,而不是屏幕上格式化的字符串,避免四舍五入造成提前结束。
3. 恢复可见时立即重算。visibilitychange 让切回页面后的显示尽快校准;pageshow 覆盖部分页面恢复情形。不要假设后台一直有每秒回调,也不要为了“补齐漏掉的 tick”循环执行多次结束逻辑。
六、准确性与持久化的边界
Date.now 使用系统墙上时钟,便于表达截止时间和跨刷新恢复,但手动修改系统时间等时钟跳变会影响结果。仅关注同一次页面会话的单调时间间隔时,可以考虑 performance.now;它不宜直接作为跨页面持久化时间戳,设备休眠时的推进表现也需按目标环境验证。
本示例刷新页面会回到初始状态。如果需要恢复运行中的计时,保存 phase 与 deadline;暂停状态则保存 remainingMs。读取时校验字段类型和范围,不能把 localStorage 中的数据直接当作可信状态。已过期的计时应按明确规则结算一次,而不是每次刷新都增加番茄数。
前端页面被挂起时无法保证准时通知。Worker 也不是绕过系统休眠或浏览器生命周期限制的万能方案。重要提醒、服务端订单或考试截止时间应使用服务端权威时间与相应调度机制,不能依赖客户端倒计时裁决。
七、建议自测清单
- 把 durationMs 暂时改成 10 秒,检查正常结束、到点暂停和重复开始。
- 暂停几秒再继续,确认暂停时间没有被扣除。
- 切到后台再返回,确认显示按截止时间重算,而不是仅减去回调次数。
- 倒计时结束后切换标签页,确认结束计数不会再次执行。
- 重置后观察是否有旧定时器继续修改显示。
以上是建议验证项,并非跨浏览器测试报告。核心原则很简单:时钟决定剩余时间,定时器只负责安排刷新;状态决定能否结算,界面只负责展示。
- 点赞
- 收藏
- 关注作者
评论(0)