我手机里躺过十来个番茄钟App,从极简风到游戏化养成,换了一茬又一茬。最后真正留在日常里的,却是一个花了一个晚饭时间写的网页版番茄钟。这个决定听起来有点返祖,但用下来的感受是:工具一旦是自己亲手写的,你对它的理解和使用深度,跟下载一个现成品完全不在一个量级。这篇文章就聊聊我是怎么把这个念头落地的,以及手写一个番茄钟背后真正值得关注的东西。
这篇文章适合两类人。一类是跟我一样被“时间碎片化”折磨的上班族、自由职业者和学生,想用一个简单可靠的工具重新拿回注意力;另一类是刚学前端不久、想找个练手项目的新手,番茄钟是个非常完美的练习场景——它不算复杂,但状态切换、定时器、本地存储、浏览器通知这些基本功全都覆盖到了。你不需要很强的编程基础,跟着思路走,一个能天天用的东西很快就能跑起来。
1. 为什么市面上有那么多番茄钟,我还是要手写一个
1.1 先说说我和“时间碎片”的拉锯战
我以前的工作状态很典型:电脑上开着七八个标签页,微信上几个群同时在跳,手里的任务经常做到一半就被拖走。一顿忙到下班,回头一想,正经事没推进多少,全是零碎响应。后来读到番茄工作法的书,核心原则就两条:一次只做一件事,做25分钟休息5分钟。原理听着特别简单,但我很快发现,真正的问题不是不懂原理,而是没有一个工具能帮我守住边界。
下载过的番茄钟App其实都不差,有漂亮的动效,有数据曲线,还能跟别人PK专注时长。但用一段时间后,问题开始浮现:有些App的通知来得太温柔,我连看都不看一眼;有些把专注变成了社交打卡,反而增加了负担;更麻烦的是,很多工具把流程定死了,我想调整一下工作时间、休息时长,得去翻设置里藏得很深的选项,甚至有的一开始就不让改。
1.2 现有工具的核心痛点,以及手写方案能换来什么
我扒了扒自己的使用习惯,总结了现成工具让我放弃的四个痛点:
- 参数不可调或调整成本高:我的注意力周期其实不是标准的25分钟,不同时段、不同任务类型需要的长度差很多,但大多数工具把25+5当成了唯一的真理。
- 通知与打断处理太弱:番茄钟的核心价值是“保护一段完整时间”,但如果提醒方式不够强势,很容易被忽略。
- 数据只存云端,离线不可用:很多App必须先登录才能用,明明是个本地就能解决的问题,非要引入账号体系。
- 花里胡哨的功能反而制造了新负担:有的工具带积分、排行、宠物养成,专注本身反而成了次要的事。
自己动手写,最大的好处是“工具就是你注意力的镜子”。你想要什么样的提醒强度、什么样的节奏、什么样的数据记录,自己去代码里改。你今天觉得自己需要更长的深度工作时间,改一个数字就能实现。这种灵活度是现成工具给不了的。
2. 番茄钟不是“25分钟倒计时”那么简单
2.1 番茄工作法的真正重点:打断管理,而不是计时
很多人的误解在于,番茄钟就是一个每25分钟响一次的闹钟。其实不是,它的核心是一个打断管理系统。你的注意力之所以碎片化,不是因为你不会集中,而是因为打断太多——外部的消息、自己脑子里的杂念、突如其来的临时任务。
番茄钟做的事,是帮你做了一次“决策缓存”。当你在一个番茄周期内,突然想起要回复邮件或者取快递,正确的动作不是立刻去做,而是把它写进一张“待处理清单”,等休息时间再统一处理。这样一来,你没有被杂念牵着走,但也没有真的忘记要做的事。这个“短暂的延迟响应”机制,才是番茄钟真正值钱的地方。所以我写的工具里,专门留了一个随时可以记录的输入框,这个设计比计时本身重要得多。
2.2 为什么默认是25分钟?注意力周期的观察与调整逻辑
25分钟这个数字来自弗朗西斯科·西里洛在20世纪80年代的观察,当时他用的还是厨房定时器。这个长度的合理性在于:它足够长,让你能进入一个有意义的工作状态;又足够短,即使被打断,损失也在可接受范围内。从认知心理学角度看,人的持续注意力维持在一个相对高水平的时间是有极限的,保持专注20到40分钟后,大脑需要一点“离线时间”来整理信息。
但这不意味着25分钟对每个人都适用。我自己实测下来,下午三点左右状态好的时候,能轻松续满40分钟;但上午刚睡醒那会儿,15分钟都费劲。所以我的工具里,工作和休息时长都是可配置的,甚至可以按“上午/下午/晚上”设置不同的参数。这个想法也是手写工具后慢慢琢磨出来的——先用标准25分钟建立习惯,再根据自己的真实感受去调节,而不是强行让自己适配一个数字。
2.3 休息时间别刷手机,拉开“注意力距离”
番茄钟的另一个关键点是休息时间的使用方式。很多人35分钟定了,5分钟休息一到就刷短视频,结果注意力被新的刺激吸走,下一轮根本进入不了状态。我自己的体会是,休息时要做的事不是“获取信息”,而是“放松注意力”,比如站起来倒杯水、看看窗外、做几个拉伸动作。
这里有个概念叫“注意力距离”,意思是你要离刚才工作的内容越远越好。如果你刚写完代码又刷技术博客,那等于没休息。所以我的番茄钟在休息阶段不会弹出什么励志语录,就一个简单的全屏倒计时,配一句“站起来,离开屏幕”,提醒我物理上离远一点。加了这句之后,我下午的工作效率明显高了一截。
3. 手写一个番茄钟:从功能清单到核心实现
3.1 技术选型:为什么只用纯前端加本地存储
一开始我也想过要不要上个框架、配个后端,把数据同步到云端。但冷静下来想了想需求,就放弃了。我需要的场景非常明确:一台电脑,一个浏览器标签页,点击开始,知道什么时候结束。没有任何跨设备、多用户协作的需求,那引入框架和服务器的复杂度就是纯粹的负担。
最终方案就是一份HTML文件加上JavaScript和少量CSS,数据用localStorage存在本地。不用安装依赖,不用构建工具,双击就能运行。这个决定的性价比极高,因为整个项目只有一个文件,维护成本几乎为零,改起来也不怕改崩什么。如果以后真需要云端同步,再把数据层抽出来也不迟,前期完全没必要过度设计。
3.2 功能设计:先想清楚“该替你做哪些决定”
动手写代码之前,我先把需求写成了故事,不是功能列表,而是使用场景。早上到了工位,打开这个网页,先在任务框里写下今天要攻克的一件事,然后点开始。工作期间,屏幕上显示剩余时间,网页标题也同步显示倒计时,就算切到别的窗口也能瞥见。时间到了,浏览器会弹出一条系统通知,同时播放一个短促的声音,提醒我停手。休息结束后,再进入下一轮。
功能上我最终保留了这几项:开始/暂停/重置、任务备注、每日完成统计、自定义时长、浏览器通知、标题栏倒计时、休息提醒。砍掉的东西包括:排行榜、连续打卡天数、社交分享、复杂的数据报表。砍掉的这些不是因为做不出来,而是因为它们是上个工具让我分心的原因之一。一个番茄钟,界面上的每一个元素都应该服务于“帮你守住一个时间段”,而不是让你在工具本身身上花更多时间。
3.3 核心代码拆解:状态、计时与通知的实现逻辑
下面是我整理后的核心实现,代码不长,但每部分都有它可以说道的地方。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>我的番茄钟</title> <style> body { font-family: -apple-system, "PingFang SC", sans-serif; max-width: 480px; margin: 40px auto; text-align: center; background: #f7f7f5; color: #333; } .time { font-size: 96px; font-weight: 700; margin: 24px 0; font-variant-numeric: tabular-nums; } .status { font-size: 18px; color: #888; } button { font-size: 18px; padding: 10px 24px; margin: 8px; border: none; border-radius: 8px; background: #2d8cf0; color: #fff; cursor: pointer; } button.secondary { background: #aaa; } input { font-size: 16px; padding: 8px; width: 80%; margin: 12px auto; display: block; box-sizing: border-box; border: 1px solid #ddd; border-radius: 6px; } </style> </head> <body> <h1>番茄钟</h1> <p class="status" id="status">准备开始</p> <div class="time" id="timer">25:00</div> <input id="task" placeholder="这一轮想完成什么?" /> <button id="startBtn">开始</button> <button id="resetBtn" class="secondary">重置</button> <p id="todayCount">今日完成:0 个番茄</p> <script> // ===== 可调参数 ===== const CONFIG = { work: 25 * 60, // 工作时长(秒) shortBreak: 5 * 60, // 短休息时长(秒) longBreak: 15 * 60, // 长休息时长(秒) longBreakEvery: 4, // 每几个番茄后进入长休息 }; // ===== 运行时状态 ===== let state = 'idle'; // idle | work | shortBreak | longBreak let endTime = null; // 本轮结束的时间戳 let timerId = null; let pomodoroCount = 0; // 本轮连续的番茄数(用于判断长休息) let todayDone = 0; // 今日完成总数 // ===== 工具函数 ===== function formatTime(seconds) { const m = String(Math.floor(seconds / 60)).padStart(2, '0'); const s = String(seconds % 60).padStart(2, '0'); return m + ':' + s; } function getDuration() { if (state === 'work') return CONFIG.work; if (state === 'shortBreak') return CONFIG.shortBreak; if (state === 'longBreak') return CONFIG.longBreak; return CONFIG.work; } function nextState() { if (state === 'work') { pomodoroCount++; todayDone++; localStorage.setItem('pomodoroToday', String(todayDone)); if (pomodoroCount % CONFIG.longBreakEvery === 0) return 'longBreak'; return 'shortBreak'; } return 'work'; } function notify(title, body) { if ('Notification' in window && Notification.permission === 'granted') { new Notification(title, { body }); } } function playBeep() { try { const ctx = new (window.AudioContext || window.webkitAudioContext)(); const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.connect(gain); gain.connect(ctx.destination); osc.type = 'sine'; osc.frequency.value = 880; gain.gain.setValueAtTime(0.15, ctx.currentTime); gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.8); osc.start(); osc.stop(ctx.currentTime + 0.8); } catch (e) { // 某些浏览器没有Web Audio支持,忽略声音 } } function render() { let remaining = 0; if (endTime) { remaining = Math.max(0, Math.round((endTime - Date.now()) / 1000)); } document.getElementById('timer').textContent = formatTime(remaining); const statusText = state === 'work' ? '专注中' : (state === 'idle' ? '准备开始' : '休息中'); document.getElementById('status').textContent = statusText; document.getElementById('todayCount').textContent = '今日完成:' + todayDone + ' 个番茄'; document.title = state === 'idle' ? '番茄钟' : (statusText + ' ' + formatTime(remaining)); } // ===== 核心逻辑 ===== function startTimer() { if (timerId) return; if (state === 'idle') { state = 'work'; } const duration = getDuration(); endTime = Date.now() + duration * 1000; // 关键:基于时间戳而非计数器 timerId = setInterval(tick, 250); // 每250ms刷新一次界面 render(); } function pauseTimer() { if (timerId) { clearInterval(timerId); timerId = null; const remaining = Math.max(0, Math.round((endTime - Date.now()) / 1000)); endTime = Date.now() + remaining * 1000; } } function resetTimer() { clearInterval(timerId); timerId = null; state = 'idle'; endTime = null; render(); } function tick() { const remaining = Math.round((endTime - Date.now()) / 1000); if (remaining <= 0) { finishPhase(); } else { render(); } } function finishPhase() { clearInterval(timerId); timerId = null; const prevState = state; if (prevState === 'work') { notify('番茄完成', '干得不错,起来活动一下吧'); playBeep(); } else { notify('休息结束', '准备好了就继续开始下一轮'); playBeep(); } state = nextState(); endTime = null; render(); // 自动进入下一阶段,也可以改成手动开始 startTimer(); } // ===== 初始化 ===== document.getElementById('startBtn').addEventListener('click', function() { if (timerId) { pauseTimer(); this.textContent = '继续'; } else { if (this.textContent === '继续') { startTimer(); this.textContent = '暂停'; } else { if (state === 'idle') { const task = document.getElementById('task').value.trim(); if (task) { // 把任务写入本地日志,方便复盘 const log = JSON.parse(localStorage.getItem('pomodoroLog') || '[]'); log.push({ time: new Date().toISOString(), task: task }); localStorage.setItem('pomodoroLog', JSON.stringify(log)); } } startTimer(); this.textContent = '暂停'; } } }); document.getElementById('resetBtn').addEventListener('click', function() { resetTimer(); document.getElementById('startBtn').textContent = '开始'; }); // 读取今天的完成数 (function init() { const todayKey = new Date().toDateString(); const saved = localStorage.getItem('pomodoroToday'); if (saved && localStorage.getItem('pomodoroDay') === todayKey) { todayDone = parseInt(saved, 10); } else { localStorage.setItem('pomodoroToday', '0'); localStorage.setItem('pomodoroDay', todayKey); } render(); })(); </script> </body> </html>这里有几个关键实现点,值得单独拿出来敲黑板:
- 为什么基于时间戳而不是setInterval累加:如果每秒执行一次
remaining--,浏览器因为卡顿或切换到后台导致定时器被节流时,倒计时就会变慢。而用Date.now()计算结束时间与当前时间的差值,无论界面刷新频率怎么变,倒计时的时间都是准确的。这是计时类应用最核心的一个坑,很多人写定时器都是挂在计数器累加上。 - 刷新界面间隔设250ms而不是1s:这样在页面从后台切回来时,标题栏和显示区能较快地反映真实剩余时间,不会出现“切回来发现已经超时几十秒”的尴尬。
- Notification需要用户授权:第一次打开页面的浏览器会询问是否允许通知,点了允许之后,定时结束才能弹系统通知。如果拒绝,我的代码里保留了一个降级方案:把剩余时间写进
document.title,只要你开了其他标签页,也能瞥见倒计时。 - localStorage用法上的一个小坑:我最初把“今日完成数”只存了一个数字,第二天打开就发现归零了。后来改成同时存一个日期字符串,读取时先比对日期,不是今天就直接清零,是今天才恢复原来的计数。
4. 实操中的调优:让番茄钟从“能用”变成“好用”
4.1 我踩过的坑:浏览器后台休眠、通知失效、计时漂移
工具跑起来之后,真正放到日常工作流里用,问题才开始暴露。第一个大坑是浏览器对后台标签页的节流策略。Chrome为了省电,会把后台页面里的定时器降到每秒甚至更长才执行一次,我的setInterval直接被拖慢。但因为计时是时间戳方案,倒计时本身依然准确,只是界面刷新会延迟。这也从侧面验证了用时间戳是正确的选择。
第二个坑是系统通知不可靠。我一开始只调用了NotificationAPI,结果发现浏览器收到提醒,但如果当时弹窗焦点在别的应用上,就很容易错过。后来我给通知加了一个配套动作:同时播放提示音。Web Audio API直接生成一个短促的“叮”声,不依赖任何音频文件。别小看这个声音,它配合弹窗,成功率比单纯弹窗高很多。
第三个坑比较隐蔽:休息阶段结束时,如果人不在电脑前,自动进入下一轮会浪费时间的保护机制。我最初版本是自动开始的,后来发现休息时站起来喝水、跟同事说两句话,回来发现下一轮已经跑掉好几分钟了。所以我改成了“自动进入下一状态,但如果是休息结束,需要手动点开始才进入工作”。这个微调让工具从“机械闹钟”变成了“可控节拍器”。
4.2 参数调优:不是所有人都适合25+5
用了一个月之后,我开始调整参数。我个人的注意力特点:上午精神好但容易急躁,适合20分钟一个单元,快速见效;下午状态平稳,可以撑到35分钟再休息;晚上的碎片时间,就用15分钟的小单元来“见缝插针”。所以我把配置改成了分时段的参数,放在配置文件里,需要的话直接改数值就行。
调整的过程其实是在做自我观察。你会发现某个固定时长会让自己频繁看表,另一个时长则能自然忘记时间,这就是你的注意力周期。标准25分钟只是起点,不是终点。我周围有人用50+10模式,也有人用30+7,都很好用。关键是找到适合自己的节拍,而不是迷信某一个“科学数字”。
4.3 结合任务的番茄安排:估算、拆解、复盘
番茄钟真正发挥威力,是在跟任务管理结合之后。我现在的用法是:每天晚上花五分钟,把第二天的任务列出来,每个任务后面标一个预计番茄数。目标是“三到四个番茄”的任务,说明任务太大,要拆成更小的子任务;预计半个番茄的任务,就并到相近的任务里去。这个估算做多了,你对自己做事速度的判断会越来越准。
复盘也一样重要。一天结束后,我会看一眼今日完成的番茄数,以及每个任务实际花了多少番茄,跟昨晚上估的对比,找出偏差大的原因。是任务理解错了,还是中途被打断了,还是自己状态不好?这种记录和复盘的价值,胜过任何先进的时间管理理论。我的日志就存在localStorage里,一个pomodoroLog数组,每条记录带着任务名和时间戳,想复盘时自己写几行代码就能统计。
5. 常见问题速查与避坑记录
用了一段时间,我把遇到过的典型问题和排查思路整理成了速查表,给你做个参考。
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 倒计时偶尔偏慢 | 浏览器后台节流导致界面刷新延迟 | 时间戳方案继续保留,接受界面延迟;工作时打开该标签页即可 |
| 系统通知不弹 | Notification权限未授权 | 点击浏览器地址栏左侧图标,手动允许通知 |
| 通知弹了但没注意 | 缺少声音或声音太小 | 加入Web Audio提示音;或配合系统的“勿扰模式”关闭 |
| 休息回来发现浪费了时间 | 休息结束自动开启了下一轮 | 改成休息结束后需手动点击“开始” |
| 计步的数字第二天归零 | localStorage跨天逻辑没写日期判断 | 像初始化代码里那样,同时存日期和数字 |
| 手机浏览器上计时不稳 | 移动端浏览器对后台标签页限制更严 | 保证屏幕常亮或使用桌面端;或配合省电策略适当调整 |
再补充几个避开过的操作禁忌:
- 不要在工作时间内干“顺便看一眼”的事:看一眼邮件、刷一下朋友圈,都不是真正的休息,它们会打破你建立起来的沉浸状态。把这些事写进待处理清单,等休息时间统一处理。
- 不要频繁切换工具:一开始写这个番茄钟的时候,我同时还在测另外两个App,结果统计到处分散,反而更乱。选定一个主工具,至少坚持一周再评估效果。
- 不要忽略了前一天的任务规划:番茄钟只是个闹钟,它不能告诉你该做什么。没有任务清单的番茄钟,容易变成“为了凑番茄而工时”的自我感动。
6. 从工具到习惯,最后再分享一点我的实际体会
现在这个番茄钟我已经用了好几个月,代码其实没怎么大改过,更多是在使用方式上不断调整。我慢慢发现,手写一个小工具带来的不只是功能上的适配,更重要的是每一次调整代码,都是一次对自己注意力策略的反思。今天想把工作时间改成30分钟,说明我意识到自己的节奏变了;明天想把通知声音调小,说明我在优化提醒的侵入感。这种深度定制,是任何闭源App都给不了的体验。
如果你也想试着自己写一个,我给你的建议很直接:先别追求功能多,先做一个能响能停的倒计时,用上几天,把不舒服的地方记下来,再一个一个去改。这个“使用—发现问题—修改—再使用”的循环,本身就是一场和注意力的深度对话。到最后你会发现,真正难的不是那25分钟的倒计时,而是每天决定把这25分钟用来做什么。工具只是守住了边界,方向从来都在你自己手里。