简介番茄时钟.zip 是一套面向学生、自由职业者与程序员等需要高效管理时间人群的小程序源码围绕番茄工作法实现25分钟专注与5分钟休息的循环节奏帮助使用者拆解任务、减少拖延并培养可持续的工作习惯。压缩包共42个文件以10个js逻辑脚本、8个wxss样式表、6个wxml页面结构与8个png、4个gif图像资源为主另含2个json配置、2个md说明及2个url快捷方式整体约1.29MB结构完整便于直接运行与二次开发。资源涵盖计时器、休息提醒、任务管理、统计分析、多模式切换、专注模式、云同步、个性化设置与社区互动等模块读者可据此理解小程序页面组织、状态管理与数据统计的实现思路并在此基础上调整时长、界面与提示音快速搭建属于自己的时间管理工具。目前已有51人学习下载。1. 番茄时钟.zip一个压缩包背后藏着多少种能跑起来的实现路径拿到「番茄时钟.zip」这个标题多数人的第一反应是去找下载链接但真正值得琢磨的是一个番茄时钟到底要写多少行代码、用哪种技术栈、做成什么形态才算「能用」。番茄工作法本身不复杂——25 分钟专注、5 分钟休息、四个循环后长休息——但把它变成一个自己顺手、能长期跑下去的工具中间要做的取舍比想象中多。这个压缩包里可能是一个纯前端 HTML 单文件也可能是一个 Python 桌面小工具甚至是一个带统计面板的全栈应用。不管哪种核心逻辑都是计时器加状态机加提醒机制。这篇文章面向的是想自己动手做一个番茄时钟、或者拿到一个番茄时钟项目后想改造成自己形状的开发者。我会把从零实现到进阶改造的完整路径拆开讲包括技术选型、关键参数、代码实现和那些只有真正跑过才会遇到的坑。2. 番茄时钟的技术选型桌面端、Web 端还是终端先想清楚你坐在哪2.1 三种主流形态的适用场景与取舍番茄时钟看起来简单但「在哪里运行」直接决定了你的技术栈和后续维护成本。我见过太多人一上来就用 Electron 套一个 React 页面结果打包出来 150MB启动要三秒最后因为太重而弃用。选型的第一步不是选语言而是选你每天工作时的主环境。桌面原生小组件适合 Windows/macOS 重度用户。Windows 上可以用 Python 的 tkinter 或者 C# 的 WPFmacOS 上可以用 SwiftUI 的菜单栏应用。优点是常驻系统托盘、提醒及时、不占浏览器标签页。缺点是跨平台要写两套UI 美观度需要额外投入。Web 页面适合浏览器就是主战场的人。一个 HTML 文件加一段 JavaScript 就能跑部署到任何静态托管上都能用手机浏览器也能打开。缺点是浏览器标签页一多就容易被淹没提醒容易被忽略。常见做法是配合 Notification API 做系统级通知但需要用户授权。终端 TUI适合长期在命令行里工作的人。Python 的textual或者 Go 的bubbletea都能做出漂亮的终端界面。优点是极轻量、启动快、不打断心流。缺点是对普通用户不友好配置成本高。我的建议是如果你每天有超过 4 小时在浏览器里工作优先做 Web 端如果你主要用 IDE 和终端做一个终端版或者菜单栏版如果你需要跨设备同步统计那就直接上 Web 端加一个轻量后端。2.2 核心状态机设计别把计时器写成意大利面条不管选哪种形态番茄时钟的核心都是一个有限状态机。很多人写番茄时钟翻车不是因为计时不准而是因为状态管理混乱——暂停的时候休息计时器还在跑、切换任务时旧计时器没清掉、长休息和短休息的切换条件写错。一个可靠的状态机应该包含以下状态和转移条件状态触发条件下一个状态空闲用户点击开始专注中专注中25 分钟倒计时结束短休息专注中用户手动暂停暂停中暂停中用户点击继续专注中恢复剩余时间短休息5 分钟倒计时结束专注中循环计数1短休息循环计数达到 4长休息长休息15 分钟倒计时结束空闲循环计数归零这个状态机看起来简单但实际编码时有几个关键点计时器必须用绝对时间戳而不是累加setInterval的次数否则页面切到后台再切回来时间会漂移暂停时要记录剩余毫秒数而不是暂停时刻循环计数要在进入休息时判断而不是在专注结束时判断否则第四个番茄的休息逻辑会出错。下面是一个用 JavaScript 实现的核心状态机骨架可以直接嵌入任何 Web 版番茄时钟// 番茄时钟核心状态机 const POMODORO_CONFIG { focusDuration: 25 * 60 * 1000, // 专注时长毫秒 shortBreakDuration: 5 * 60 * 1000, // 短休息时长 longBreakDuration: 15 * 60 * 1000, // 长休息时长 cyclesBeforeLongBreak: 4 // 几个番茄后进入长休息 }; class PomodoroTimer { constructor(config) { this.config config; this.state idle; // idle | focusing | paused | shortBreak | longBreak this.cycleCount 0; // 已完成的番茄数 this.endTimestamp null; // 当前阶段结束的绝对时间戳 this.remainingOnPause null; // 暂停时剩余的毫秒数 this.onTick null; // 每秒回调用于更新 UI this.onStateChange null; // 状态切换回调 this._intervalId null; } start() { if (this.state idle) { this._enterFocus(); } else if (this.state paused) { // 从暂停恢复用剩余时间重新计算结束时间戳 this.endTimestamp Date.now() this.remainingOnPause; this.remainingOnPause null; this.state focusing; this._startTicking(); } } pause() { if (this.state focusing || this.state shortBreak || this.state longBreak) { this.remainingOnPause this.endTimestamp - Date.now(); this._stopTicking(); this.state paused; this._emitStateChange(); } } reset() { this._stopTicking(); this.state idle; this.cycleCount 0; this.endTimestamp null; this.remainingOnPause null; this._emitStateChange(); } _enterFocus() { this.state focusing; this.endTimestamp Date.now() this.config.focusDuration; this._startTicking(); this._emitStateChange(); } _enterBreak() { // 先增加循环计数再判断是否需要长休息 this.cycleCount 1; if (this.cycleCount % this.config.cyclesBeforeLongBreak 0) { this.state longBreak; this.endTimestamp Date.now() this.config.longBreakDuration; } else { this.state shortBreak; this.endTimestamp Date.now() this.config.shortBreakDuration; } this._startTicking(); this._emitStateChange(); } _startTicking() { this._stopTicking(); this._intervalId setInterval(() { const remaining this.endTimestamp - Date.now(); if (remaining 0) { this._stopTicking(); if (this.state focusing) { this._enterBreak(); } else { // 休息结束回到专注 this._enterFocus(); } } else { if (this.onTick) this.onTick(remaining); } }, 200); // 200ms 刷新一次UI 更平滑 } _stopTicking() { if (this._intervalId) { clearInterval(this._intervalId); this._intervalId null; } } _emitStateChange() { if (this.onStateChange) { this.onStateChange(this.state, this.cycleCount); } } }这段代码的关键设计决策有三个。第一用endTimestamp绝对时间戳而不是每秒减一这样即使setInterval被浏览器节流后台标签页常见恢复时时间仍然准确。第二暂停时保存remainingOnPause而不是保存暂停时刻恢复时重新计算结束时间戳逻辑更清晰。第三循环计数在进入休息时才增加这样第四个番茄结束后cycleCount变成 4取模为 0正确进入长休息。参数方面focusDuration默认 25 分钟是番茄工作法的经典值但如果你在做深度编程可以调到 45 分钟甚至 52 分钟cyclesBeforeLongBreak设为 4 是标准做法但如果你觉得四个番茄太长可以改成 3。setInterval的刷新间隔设为 200ms 而不是 1000ms是为了让进度条动画更流畅代价是 CPU 占用略高但在现代设备上可以忽略。2.3 提醒机制别让通知变成骚扰番茄时钟的提醒方式直接影响你愿不愿意继续用它。我踩过的坑是一开始用alert()弹窗结果每次切换都打断心流后来改成声音提醒但在办公室环境下又太吵。常见做法是分层提醒专注结束前 30 秒给一个轻微的视觉提示比如进度条变色结束时播放一段短促的提示音同时发一条系统通知。Web 端可以用 Notification API桌面端可以用系统原生通知。注意 Notification API 需要用户手势触发授权不能在页面加载时自动请求否则浏览器会直接拒绝。// 请求通知权限必须在用户点击事件中调用 function requestNotificationPermission() { if (Notification in window Notification.permission default) { Notification.requestPermission(); } } // 发送通知 function sendNotification(title, body) { if (Notification in window Notification.permission granted) { new Notification(title, { body, silent: true }); // silent 避免和提示音重叠 } }提示如果你做的是桌面端应用优先用系统原生通知而不是自己画弹窗用户可以在系统设置里统一管理免打扰。3. 从零跑通一个最小可用版本HTML 单文件方案3.1 完整代码与文件结构如果你不想折腾构建工具和依赖一个 HTML 单文件就是最快能跑起来的方案。把下面的代码保存为pomodoro.html双击就能在浏览器里用。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title番茄时钟/title style * { margin: 0; padding: 0; box-sizing: border-box; } body { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; background: #1a1a2e; color: #e0e0e0; font-family: -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif; user-select: none; } .timer-display { font-size: 6rem; font-weight: 200; letter-spacing: 0.05em; font-variant-numeric: tabular-nums; transition: color 0.3s; } .state-focusing { color: #e94560; } .state-shortBreak { color: #4ecca3; } .state-longBreak { color: #4ecca3; } .state-idle { color: #888; } .state-paused { color: #f0a500; } .controls { margin-top: 2rem; display: flex; gap: 1rem; } button { padding: 0.6rem 1.8rem; border: 1px solid #444; border-radius: 6px; background: transparent; color: #e0e0e0; font-size: 1rem; cursor: pointer; transition: background 0.2s; } button:hover { background: #2a2a4a; } .cycle-info { margin-top: 1.5rem; font-size: 0.9rem; color: #666; } .progress-bar { width: 300px; height: 4px; background: #2a2a4a; border-radius: 2px; margin-top: 1.5rem; overflow: hidden; } .progress-fill { height: 100%; width: 100%; background: #e94560; transition: width 0.2s linear; } /style /head body div classtimer-display state-idle iddisplay25:00/div div classprogress-bardiv classprogress-fill idprogress/div/div div classcontrols button idbtnStart开始/button button idbtnPause暂停/button button idbtnReset重置/button /div div classcycle-info idcycleInfo已完成 0 个番茄/div script // 配置参数按需修改 const CONFIG { focusDuration: 25 * 60 * 1000, shortBreakDuration: 5 * 60 * 1000, longBreakDuration: 15 * 60 * 1000, cyclesBeforeLongBreak: 4 }; const display document.getElementById(display); const progress document.getElementById(progress); const cycleInfo document.getElementById(cycleInfo); const btnStart document.getElementById(btnStart); const btnPause document.getElementById(btnPause); const btnReset document.getElementById(btnReset); // 状态机实例复用上一节的 PomodoroTimer 类 const timer new PomodoroTimer(CONFIG); function formatTime(ms) { const totalSeconds Math.ceil(ms / 1000); const m Math.floor(totalSeconds / 60).toString().padStart(2, 0); const s (totalSeconds % 60).toString().padStart(2, 0); return ${m}:${s}; } function getDurationForState(state) { if (state focusing) return CONFIG.focusDuration; if (state shortBreak) return CONFIG.shortBreakDuration; if (state longBreak) return CONFIG.longBreakDuration; return CONFIG.focusDuration; } timer.onTick (remaining) { display.textContent formatTime(remaining); const total getDurationForState(timer.state); progress.style.width ${(remaining / total) * 100}%; }; timer.onStateChange (state, cycleCount) { display.className timer-display state-${state}; cycleInfo.textContent 已完成 ${cycleCount} 个番茄; if (state focusing) { display.textContent formatTime(CONFIG.focusDuration); progress.style.width 100%; } else if (state shortBreak || state longBreak) { const dur state shortBreak ? CONFIG.shortBreakDuration : CONFIG.longBreakDuration; display.textContent formatTime(dur); progress.style.width 100%; sendNotification(休息一下, state shortBreak ? 短休息 5 分钟 : 长休息 15 分钟); } else if (state idle) { display.textContent formatTime(CONFIG.focusDuration); progress.style.width 100%; } }; btnStart.addEventListener(click, () { requestNotificationPermission(); timer.start(); }); btnPause.addEventListener(click, () timer.pause()); btnReset.addEventListener(click, () timer.reset()); // 页面标题同步显示剩余时间方便在标签页上看到 setInterval(() { if (timer.state focusing || timer.state shortBreak || timer.state longBreak) { const remaining timer.endTimestamp - Date.now(); if (remaining 0) { document.title ${formatTime(remaining)} - 番茄时钟; } } else { document.title 番茄时钟; } }, 1000); /script /body /html这个单文件方案包含了完整的 UI、状态机和提醒逻辑。几个值得注意的细节font-variant-numeric: tabular-nums让数字等宽倒计时不会因为数字变化而抖动进度条用transition: width 0.2s linear配合 200ms 的 tick 间隔视觉上很平滑页面标题同步显示剩余时间这样即使切到其他标签页也能一眼看到。3.2 参数调整与个性化拿到这个文件后最常改的参数是CONFIG对象里的四个值。如果你觉得 25 分钟太短改成45 * 60 * 1000如果你想要更长的休息把shortBreakDuration改成10 * 60 * 1000。cyclesBeforeLongBreak改成 3 或 5 都可以取决于你一天能完成多少个番茄。颜色方案在 CSS 的.state-focusing等类里改。我一般会把专注状态设成暖色红或橙休息状态设成冷色绿或蓝这样余光扫一眼就知道当前处于什么阶段。如果你想让这个页面在手机上也能用把meta viewport保留然后把timer-display的font-size改成4rem按钮改成纵向排列。这些改动不需要任何构建工具直接编辑 HTML 文件就行。4. 避坑与排查那些只有真正跑过才会遇到的问题4.1 计时器在后台标签页变慢甚至暂停现象切到其他标签页几分钟后回来发现倒计时比实际时间慢了很多甚至完全停住了。原因浏览器为了省电会对后台标签页的setInterval和setTimeout进行节流最低可能降到每分钟只执行一次。如果你用累加计数的方式计时就会严重漂移。解决用绝对时间戳计算剩余时间而不是依赖setInterval的执行次数。上面代码里的endTimestamp - Date.now()就是正确做法。另外可以考虑用 Web Worker 来跑计时器Worker 线程不会被后台节流。如果对精度要求极高还可以用performance.now()替代Date.now()但注意performance.now()在页面刷新后会重置。4.2 通知权限被拒绝后无法再次请求现象第一次打开页面时没有在用户手势中请求通知权限后来想开启通知发现浏览器不再弹窗了。原因Notification API 的权限请求必须在用户手势如 click 事件中调用且一旦用户拒绝或浏览器自动忽略后续requestPermission()会直接返回denied不会再弹窗。解决把权限请求绑定在「开始」按钮的点击事件里确保是用户主动触发的。如果已经被拒绝只能引导用户去浏览器设置里手动开启。更好的做法是不依赖系统通知在页面内做一个视觉提醒比如闪烁标题或改变背景色系统通知作为锦上添花。4.3 暂停后恢复剩余时间对不上现象暂停时显示还剩 12:30恢复后变成 12:28 或者 12:32。原因暂停时如果保存的是「暂停时刻」而不是「剩余毫秒数」恢复时用endTimestamp - pauseTime计算中间的时间差会导致偏差。另外如果暂停和恢复之间页面被刷新所有内存中的状态都会丢失。解决暂停时保存remainingOnPause endTimestamp - Date.now()恢复时用endTimestamp Date.now() remainingOnPause重新计算。如果需要跨刷新保持状态把state、cycleCount、endTimestamp存到localStorage页面加载时恢复。注意localStorage存的是字符串取出来要parseInt或JSON.parse。4.4 第四个番茄结束后没有进入长休息现象完成了四个番茄但第四个结束后仍然只给了 5 分钟短休息。原因循环计数的增加时机不对。如果在专注结束时增加计数然后判断cycleCount % 4 0那么第一个番茄结束时cycleCount变成 1第四个结束时变成 4取模为 0逻辑上是对的。但如果计数在进入专注时增加或者在休息结束时增加就会错位。解决统一在「进入休息」这个动作里增加计数并且用cycleCount % cyclesBeforeLongBreak 0来判断。注意cycleCount从 0 开始第一个番茄结束后变成 1第四个结束后变成 44 % 4 0正确触发长休息。如果你希望第一个番茄结束后就判断那初始值要设为 1但这样重置逻辑会更复杂不推荐。4.5 移动端浏览器熄屏后计时器停止现象在手机上打开番茄时钟熄屏后计时器不再走亮屏后时间还停在熄屏前。原因移动端浏览器在熄屏或切到后台时会冻结 JavaScript 执行setInterval完全停止。解决这是移动端 Web 的固有限制纯前端方案无法完全绕过。常见做法是结合visibilitychange事件在页面重新可见时用endTimestamp - Date.now()重新计算剩余时间并立即更新 UI。如果需要在熄屏时也能提醒那就必须做成原生应用或者使用 Service Worker 加 Push API但后者需要后端支持复杂度高很多。对于个人使用最简单的方案是手机端设置屏幕常亮或者接受熄屏后回来手动校正。5. 进阶改造把番茄时钟变成你的时间统计面板5.1 用 localStorage 做轻量数据持久化一个只能计时的番茄时钟用一周就会腻真正让人留下来的是数据反馈。你不需要后端localStorage就能存下每天的番茄记录。下面是一个记录和读取的简单实现// 记录一个完成的番茄 function logPomodoro(taskName 未命名任务) { const key pomodoro_log; const log JSON.parse(localStorage.getItem(key) || []); log.push({ task: taskName, completedAt: new Date().toISOString(), duration: CONFIG.focusDuration / 60000 // 分钟 }); // 只保留最近 90 天的记录避免 localStorage 膨胀 const cutoff Date.now() - 90 * 24 * 60 * 60 * 1000; const filtered log.filter(item new Date(item.completedAt).getTime() cutoff); localStorage.setItem(key, JSON.stringify(filtered)); } // 统计今天的番茄数 function getTodayCount() { const log JSON.parse(localStorage.getItem(pomodoro_log) || []); const today new Date().toISOString().slice(0, 10); return log.filter(item item.completedAt.slice(0, 10) today).length; } // 统计最近 7 天每天的番茄数 function getWeeklyStats() { const log JSON.parse(localStorage.getItem(pomodoro_log) || []); const stats {}; for (let i 6; i 0; i--) { const d new Date(); d.setDate(d.getDate() - i); const dateStr d.toISOString().slice(0, 10); stats[dateStr] log.filter(item item.completedAt.slice(0, 10) dateStr).length; } return stats; }在timer.onStateChange里当状态从focusing切换到shortBreak或longBreak时调用logPomodoro()就能自动记录。注意要在状态切换回调里判断前一个状态是不是focusing否则暂停恢复也会触发记录。5.2 用 Canvas 画一个极简周视图有了数据之后用 Canvas 画一个简单的柱状图比引入 Chart.js 更轻量。下面这段代码在页面底部画一个 7 天的番茄数柱状图function drawWeeklyChart(canvasId) { const canvas document.getElementById(canvasId); const ctx canvas.getContext(2d); const stats getWeeklyStats(); const dates Object.keys(stats); const counts Object.values(stats); const maxCount Math.max(...counts, 1); const W canvas.width; const H canvas.height; const barWidth W / dates.length * 0.6; const gap W / dates.length * 0.4; ctx.clearRect(0, 0, W, H); dates.forEach((date, i) { const barHeight (counts[i] / maxCount) * (H - 30); const x i * (barWidth gap) gap / 2; const y H - 20 - barHeight; // 柱子 ctx.fillStyle counts[i] 0 ? #e94560 : #2a2a4a; ctx.fillRect(x, y, barWidth, barHeight); // 日期标签 ctx.fillStyle #666; ctx.font 10px sans-serif; ctx.textAlign center; ctx.fillText(date.slice(5), x barWidth / 2, H - 5); // 数量 if (counts[i] 0) { ctx.fillStyle #e0e0e0; ctx.fillText(counts[i], x barWidth / 2, y - 4); } }); }在 HTML 里加一个canvas idweeklyChart width300 height120/canvas然后在logPomodoro()之后调用drawWeeklyChart(weeklyChart)就能实时更新。这个图不需要任何外部依赖加载速度极快。5.3 任务标签让每个番茄有名字单纯的计时没有意义知道时间花在哪里才有。在开始一个番茄之前让用户输入一个任务名记录到logPomodoro(taskName)里。然后在统计面板里按任务名聚合你就能看到「今天 8 个番茄里5 个花在写代码2 个花在开会1 个花在刷网页」。这个反馈比单纯的计时强十倍。实现上在「开始」按钮旁边加一个input点击开始时读取输入框的值传给logPomodoro()。如果输入框为空默认用「未命名任务」。不需要做复杂的任务管理一个输入框就够了。我自己的习惯是每天早上列三件最重要的事每件事预估需要几个番茄。然后每完成一个番茄就记录一次晚上看统计的时候如果发现某个任务实际用了 6 个番茄但预估只有 3 个第二天就会调整预估。这个习惯坚持了半年对时间感知的准确度提升非常明显。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?