学习前提
理解事件处理、时间戳、setInterval与页面可见性;本课重点是计时状态。
本课完成后你能做什么
实现开始、暂停、继续与重置,用截止时间计算剩余量而不是依赖回调次数。
先理解机制
第一阶段明确状态:ready、running、paused、completed。remaining保存剩余毫秒,deadline仅在运行时表示结束时间。按钮是否可用从running派生,处理器也检查运行状态,避免重复点击创建多个interval。界面约束与逻辑约束必须一致。
第二阶段处理计时。setInterval不是精确时钟,页面忙碌或切到后台会推迟回调;每次减一秒会积累误差。示例每次用deadline减当前时间重新计算,回到前台时立即刷新。显示秒数使用ceil,结束时归零,不出现负数。
第三阶段处理暂停和重置。暂停先计算当下remaining再清除定时器;继续创建新的deadline。重置使用输入时长,清除旧interval,回到ready。输入限制为1至120整数分钟,不允许NaN、空值或小数。
第四阶段设计反馈。不要让每秒变化的数字持续通过aria-live朗读;状态区域只在开始、暂停或完成时更新。示例不请求通知权限,关闭或刷新页面会重置,不保证浏览器被冻结时能准点发出提醒。
可运行示例
运行方式:将HTML示例保存为独立的.html文件并在浏览器打开;含脚本的示例使用现代浏览器。资源文件与多文件模块另按正文要求准备。
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Focus timer</title><style>
*{box-sizing:border-box}body{max-width:850px;margin:auto;padding:24px;font:17px/1.6 system-ui;color:#20362c;background:#f7faf4}button,input,select{font:inherit;padding:8px;max-width:100%}button{cursor:pointer}button:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid #176343;outline-offset:3px}form{display:flex;flex-wrap:wrap;gap:12px;align-items:end}label{display:block}input,select{display:block}li{margin:12px 0;padding:12px;background:white;border:1px solid #d4dfd2;border-radius:8px;overflow-wrap:anywhere}li button{margin:4px}progress{width:100%}.row{display:flex;gap:12px;flex-wrap:wrap}output{font-size:2rem;font-variant-numeric:tabular-nums}small{display:block}table{border-collapse:collapse;width:100%}td,th{padding:10px;text-align:left;border-bottom:1px solid #ccc}.table-wrap{overflow:auto}h1{font-size:clamp(26px,5vw,38px)}
</style></head><body><main><h1>Focus timer</h1><p>A local practice timer. No notification permission or background service is required.</p>
<label for="minutes">Duration in minutes</label><input id="minutes" type="number" min="1" max="120" value="25">
<p><output id="clock" aria-label="Time remaining">25:00</output></p><div class="row"><button id="start">Start</button><button id="pause" disabled>Pause</button><button id="reset">Reset</button></div><p id="status" role="status">Ready</p></main><script>
let remaining=25*60*1000,deadline=0,timer=null,running=false;
const duration=document.querySelector('#minutes'),clock=document.querySelector('#clock'),status=document.querySelector('#status'),start=document.querySelector('#start'),pause=document.querySelector('#pause');
function paint(){const seconds=Math.ceil(remaining/1000);clock.textContent=String(Math.floor(seconds/60)).padStart(2,'0')+':'+String(seconds%60).padStart(2,'0');start.disabled=running;pause.disabled=!running;duration.disabled=running;}
function tick(){if(running){remaining=Math.max(0,deadline-Date.now());if(remaining===0){running=false;clearInterval(timer);timer=null;status.textContent='Session complete. Take a break.';}}paint();}
function readDuration(){const value=Number(duration.value);if(!Number.isInteger(value)||value<1||value>120){status.textContent='Enter a whole number from 1 to 120.';return null;}return value*60000;}
function reset(){const value=readDuration();if(value===null)return;clearInterval(timer);timer=null;running=false;remaining=value;status.textContent='Ready';paint();}
start.addEventListener('click',()=>{if(running)return;const value=readDuration();if(value===null)return;if(remaining===0){remaining=value;}deadline=Date.now()+remaining;running=true;status.textContent='Focusing';timer=setInterval(tick,200);paint();});
pause.addEventListener('click',()=>{if(!running)return;remaining=Math.max(0,deadline-Date.now());running=false;clearInterval(timer);timer=null;status.textContent=remaining===0?'Session complete.':'Paused';paint();});
document.querySelector('#reset').addEventListener('click',reset);duration.addEventListener('change',reset);document.addEventListener('visibilitychange',tick);paint();
</script></body></html>
推演与验证
设1分钟,开始后暂停,等待几秒仍保持暂停值;继续后按剩余时长前进。快速重复开始不能加速。切后台再返回,剩余时间应与截止时间一致。测试0、121、小数和空输入;完成显示00:00且可重新开始。
常见错误与边界
Date.now受系统时钟调整影响;本项目用墙上时间追踪后台经过时间,不适合精密测量。若做秒表可研究performance.now,但持久化恢复还需要独立设计。不要把示例计时误差解释成用户不专注。
动手练习
增加“专注25分钟/休息5分钟”模式选择。运行期间切换模式应先明确重置或禁止,不能悄悄改变正在进行的截止时间。
参考解法与理由
把模式映射成默认时长,切换调用同一reset流程。不要再复制一套独立计时器。完成时只提示下一模式,是否自动开始应由明确设置决定。
项目深化:用时间戳推导剩余时间
setInterval不是准确时钟。浏览器后台节流或主线程忙碌时,回调可能晚到;每次减1会累积误差。记录截止时刻,回调只负责重新显示差值。下面使用注入的now数值,不必真的等一分钟,也不修改系统时钟。
在现代浏览器Console中独立运行这个实验;它不替换上方主示例。
function secondsLeft(deadline, now) {
return Math.max(0, Math.ceil((deadline - now) / 1000));
}
const start = 100000;
let deadline = start + 60000;
console.log(secondsLeft(deadline, start));
console.log(secondsLeft(deadline, start + 20400));
const pausedMilliseconds = Math.max(0, deadline - (start + 20400));
const resumedAt = start + 120000;
deadline = resumedAt + pausedMilliseconds;
console.log(secondsLeft(deadline, resumedAt));
console.log(secondsLeft(deadline, resumedAt + 40000));
预期观察
输出60、40、40、0。暂停保留39600毫秒,不是显示出来的40秒,因此不会在每次暂停时凭空增加时间。
原因、取舍与适用边界
在同一页面会话中,performance.now适合测量单调经过时间;跨刷新持久化时通常需要可恢复的墙上时间戳,但用户调整系统时间可能影响结果。两者不能混用相减。完成通知也应由running到finished的一次状态转换触发,而不是每次剩余0都播放声音。需要重置时先清除旧回调,再重建状态,避免多个计时循环竞争。
进阶练习
模拟标签页20秒没有收到回调,恢复时应该补跑20次减1吗?
解法与推理
不需要。恢复可见时读取当前时刻并重新计算一次差值。测试应覆盖延迟回调、暂停后恢复、超过截止时刻及连续点击开始;验证状态和显示,而不是只数定时器执行了几次。
进一步阅读
完整源码与验收
下载后在独立练习目录另存为.html文件,用浏览器打开。文件包含上方完整主项目,后面的深入实验独立练习;数据保存在对应浏览器来源中,不是服务端账号。