三亩地 三亩地SAN MU DI · CODE DIARY
CASE STUDY · 007

番茄钟计时器

25 分钟专注 + 5 分钟休息的循环。setInterval 驱动倒计时,localStorage 把今日完成个数和设置都留在本地,关掉浏览器再开也能接着数。

番茄钟计时器界面预览
全栈 · 中级

把专注和休息交给一个会自己记得的小钟

这是我自己写完之后用得最多的一个项目——每天开两个番茄钟写代码,比任何 to-do 应用都管用。技术上它把之前几个案例的能力揉在一起:定时器、状态机、本地存储、声音提示。难点不在某个 API,而在于让「切到后台」「关闭页面」「中途暂停」这些边界都不要把数据搞乱。

技术栈 JS / 定时器 / 本地存储
预计耗时 5 - 8 小时
难度等级 中级
核心难点 时间漂移与恢复
setInterval Date.now() localStorage Web Audio 状态机
STEPS

五步做一个能用的番茄钟

计时器易写难用,难就难在「时间准不准」和「关了再开能不能恢复」。

1

状态与常量

定三个常量:WORK = 25 * 60BREAK = 5 * 60LONG_BREAK = 15 * 60。状态变量:mode('work' / 'break')、remaining(剩余秒)、isRunningcompletedCount(今日完成数)。开始 / 暂停 / 重置三个按钮。

2

用时间戳避免计时漂移

别用 setInterval(() => remaining--, 1000)——定时器不精确,长时间运行会偏差几秒。正确做法:开始时记 endTime = Date.now() + remaining * 1000,setInterval 里只负责每秒渲染 remaining = Math.round((endTime - Date.now()) / 1000)。这样即使浏览器卡顿几秒,时间也始终准确。

3

阶段切换与提示音

remaining <= 0:如果当前是 work,completedCount++,切到 break;如果是 break,切回 work;每 4 个 work 后切到 long_break。用 new Audio('bell.mp3').play() 播一声提示。同样要存进 localStorage 防丢失。

4

localStorage 持久化

每秒把 { mode, remaining, endTime, isRunning, completedCount, date } 序列化存进 localStorage。页面加载时先读,如果 date 不是今天就把 completedCount 重置为 0。这样关浏览器再开、刷新页面都能恢复到离开前的状态。

5

暂停、恢复与重置

暂停:clearInterval + 把 remaining 写成当前剩余(基于 endTime 重算)。恢复:重新计算 endTime = Date.now() + remaining * 1000 再起 interval。重置:remaining 恢复到当前 mode 的初始时长,isRunning 置 false。这三步逻辑写顺,番茄钟就经得起日常使用。

CODE

关键代码片段

计时器主循环 + 持久化恢复,两段是这个项目的灵魂。

const WORK = 25 * 60, BREAK = 5 * 60;
let state = {
  mode: 'work', remaining: WORK,
  endTime: null, isRunning: false,
  completed: 0
};
let timer = null;

function tick() {
  state.remaining = Math.max(0, Math.round((state.endTime - Date.now()) / 1000));
  render();
  if (state.remaining === 0) switchMode();
  save();
}

function start() {
  if (state.isRunning) return;
  state.isRunning = true;
  state.endTime = Date.now() + state.remaining * 1000;
  timer = setInterval(tick, 1000);
  save();
}

function pause() {
  state.isRunning = false;
  clearInterval(timer);
  state.remaining = Math.round((state.endTime - Date.now()) / 1000);
  save();
}

function switchMode() {
  if (state.mode === 'work') {
    state.completed++;
    state.mode = (state.completed % 4 === 0) ? 'long_break' : 'break';
  } else {
    state.mode = 'work';
  }
  state.remaining = state.mode === 'work' ? WORK : BREAK;
  new Audio('bell.mp3').play();
}

// 页面加载时恢复
const saved = JSON.parse(localStorage.getItem('pomodoro') || 'null');
if (saved) {
  const isToday = saved.date === new Date().toDateString();
  state = { ...saved, completed: isToday ? saved.completed : 0 };
  if (state.isRunning) { state.isRunning = false; start(); }
}

定时器是前端最容易写错的地方:你以为每秒减一就准了,其实后台标签会被浏览器限流、定时器本身也有几毫秒漂移。这个项目让我第一次学会「不要相信时间间隔,要相信时间戳」——所有计时器都应该基于 Date.now() 推算,setInterval 只是渲染的节拍器,不是真相来源。

—— 三亩地 · 案例笔记
NEXT

继续下一个案例

Web 端的项目到此告一段落,最后一个案例我们去终端里,用 Python 写一个命令行待办工具。

← 上一篇:图片懒加载组件 下一篇:命令行待办工具 →
或者去小型实战项目继续练 →