
那段时间我接了个有点奇怪的活儿帮朋友做一个什么都不要有的网页工具。需求听上去特别简单——打开页面点一下开始然后这个页面就在那儿老老实实地计时除此之外不弹通知、不联网、不收集数据、不搞暗黑模式之外任何花里花哨的东西。但真正动手的时候我才意识到一个什么多余功能都没有的工具实际上逼着你把一堆功能全部砍掉并想清楚哪些才是核心这比堆功能难多了。项目代号就叫caveman。中文语境里就是穴居人的意思。我想象中的使用者就像原始人蹲在山洞里只维护眼前那一堆火一样只需要守住眼前这一件事不被数字世界那些花花绿绿的干扰牵着走。这篇文章就从我起这个名字的动机开始记录我从零到上线完整做这个极简本地工具的全过程——包括功能怎么取舍、代码怎么写、踩了什么坑、以及最后它变成什么样。1. 穴居人式产品观为什么现代工具让我想回到山洞1.1 站在工具过剩的废墟上先说说我为什么想做这么个东西。我平常的工作状态是浏览器里至少开二十个标签页手机上各种专注类App装了四五个桌面上还常驻一个番茄钟。按理说工具够多了效率应该高得离谱才对。可实际上呢番茄钟App弹通知的时候我嫌烦把权限关了白噪音App要联网加载慢偶尔还插广告那些一站式效率平台更是离谱一个App里集成了笔记、日历、打卡、数据大屏我打开它的时间比真正开始干活的时间还长。有一天我关掉所有标签页深吸一口气发现自己真正需要的只是一个能倒计时的页面。可怕的是这么简单的需求在当下这个生态里竟然没有一款工具让我觉得纯粹、安静、无负担。这个痛点就是caveman的出发点。我试着在纸上写下游手好闲时脑子里冒出来的功能清单番茄钟计时25分钟工作5分钟休息环境音效雨声、篝火声、白噪音今日任务列表每日专注数据统计桌面小组件多端同步排行榜、社区、分享写完之后我盯着这串列表看了很久突然意识到这不就是又一个我即将卸载的效率App吗我把那些想避免的东西——通知、社交、复杂数据流——又全部捡了回来。1.2 从穴居人角度重新定义产品边界caveman这个名字给了我一个非常清晰的决断标尺一个穴居人的工具应该长什么样原始人手里只有石头、木棍和火种他不会有一把多功能瑞士军刀。他能关注的就是眼前这堆火、这个洞口、这道栅栏。于是我把功能清单打回重做只留下三样东西一个倒计时/正计时计时器这是火种是核心中的核心。一个可选的环境音效生成器模拟火苗、风声或雨声。注意这里我刻意强调生成而不是播放因为连音频文件都不想加载。一个极简本地会话记录把每次专注的起止时间存在浏览器本地仅用于统计当天的次数不做云端、不做图表曲线。其余全部砍掉。砍的过程并不痛苦反而有一种长长出了一口气的轻松感。任务列表这应该由你自己的工作本去管理而不是让计时工具来绑架你。数据统计一条记录清单就够不用曲线图来制造我很努力的幻觉。同步穴居人连网都没上要什么同步。技术选型也循着同样的逻辑不引入框架不引入依赖不引入构建工具。就一个index.html、一个style.css、一个app.js三个纯文本文件双击就能在浏览器里跑。换句话说这个项目的最终形态连工程化三个字都不配拥有但它恰好够用。这就是我理解的穴居人式开发——用最少的工具做一件最必要的事。现在回头复盘这一刀切得干净的好处不仅在精神层面。真正开发时因为零依赖我没遇到版本冲突、构建报错、跨域问题、CDN挂了连带着App白屏这些糟心事。用一句可能有点鸡汤但极其真实的话来说功能少坑就少依赖少风险就少。你可以把这当成一次返璞归真的实验心态去理解。2. 最小可用版本三个文件就把火种点起来2.1 信息架构与界面设计越粗糙越安心页面做成什么样我考虑了很久。按我过去的习惯肯定会先上一套组件库做个圆角阴影、渐变动效、毛玻璃背景。但那些设计语言和穴居人气质严重不符反而会给用户一种哦这又是一个精致的效率App的熟悉感。所以我在界面设计上反着来整体配色只用琥珀色和深褐色模拟火光映照岩壁的感觉背景用手写CSS纹理模拟粗糙岩壁颗粒感不引用任何图片字体优先使用系统等宽字体带一点点石刻味道主界面只放一个巨大的时间数字下方一排极简按钮开始、暂停、重置、音效、记录这块视觉打磨花了我不少时间但没什么可炫技的。核心就一句话怎么让人第一眼就觉得这块屏幕没有在跟我要任何东西。测试后同事反馈说进去就想干活那会儿我就知道方向对了。给你看一眼最终的HTML骨架大概是这个节奏main classcave section classhearth p idstatus classstatus火焰熄灭 · 等待开始/p p idtime classtime00:00:00/p div classcontrols button idtoggle classbtn primary点燃/button button idreset classbtn ghost重置/button button idsoundToggle classbtn ghost生火声/button /div /section section classrecords h2今日的记录/h2 ul idsessionList/ul /section /main界面上几乎没有装饰性元素主要原因不是为了性冷淡风而是为了让读取信息的成本降到最低。当页面只有那一行数字你的眼睛没有别处可去就自然回到当前任务本身上来。这就是界面设计的克制也是我用穴居人这个代号想传递的安静感。2.2 状态机的简单抽象点燃、燃烧、熄灭计时器逻辑听上去简单但写起来有一个经典陷阱直接依赖setInterval累加计时最终一定会有偏差。我早期做过一个快速原型每1000毫秒给秒数加1结果挂机半小时后跟真实时间差了将近40秒。原因后面细讲但在这里我先定下状态机设计这是整个工具的地基idle熄灭初始状态计时器归零。running燃烧正在计时记录startTimestamp与累计的elapsedBeforePause。paused休眠暂停状态保留当前累计值等待恢复。切换关系很简单idle可以到runningrunning可以到paused或idlepaused可以回到running也可以重置到idle。每次状态变化都会触发重新渲染并在running - paused或running - idle时写入一条本地会话记录。为什么把状态划分得这么干脆因为如果引入显示计时中但逻辑上暂停这种暧昧状态后续所有计算都会变得混乱。一个小工具状态越少心智负担越低出错概率也就越低。这个道理跟产品功能取舍完全一致。对应的状态切换代码我用一个极其普通的对象方式管理不引入Redux之类的东西const state { mode: idle, // idle | running | paused startTimestamp: null, // 最近一次从暂停恢复的时刻 elapsedBeforePause: 0, // 暂停前已经累计的毫秒数 tickingId: null }; function startTimer() { state.mode running; state.startTimestamp Date.now(); state.tickingId setInterval(renderTimer, 250); renderStatus(火焰燃烧中); } function pauseTimer() { state.elapsedBeforePause getElapsed(); state.mode paused; clearInterval(state.tickingId); state.tickingId null; saveSession(); renderStatus(火焰休眠); } function resetTimer() { clearInterval(state.tickingId); state.mode idle; state.startTimestamp null; state.elapsedBeforePause 0; state.tickingId null; renderTimer(); renderStatus(火焰熄灭 · 等待开始); }注意这里getElapsed()是按时间戳差值计算的而不是setInterval执行次数。后面在踩坑章节里我会展开讲这是整个计时器精度的关键。2.3 时间显示与精度为什么250毫秒刷新一次有人可能会问倒计时显示只需要精确到秒刷新频率设成1秒不就完了为什么要250毫秒刷一次两个原因第一用户点暂停或重置的时候页面上的时间应该极其接近当前真实时间。如果以1秒为刷新周期那么当你按下暂停时画面可能还停留在1秒之前的数据视觉上会有一个滞后感很突兀。用250毫秒刷新肉眼基本察觉不到延迟。第二更细粒度的渲染有利于后续扩展比如我想在最后10秒加一个闪烁提醒那时候250毫秒的刷新频率会让动画更顺滑。现在虽然没做但给未来留了一扇窗。时间格式函数也很简单function formatTime(ms) { const totalSeconds Math.floor(ms / 1000); const hours String(Math.floor(totalSeconds / 3600)).padStart(2, 0); const minutes String(Math.floor((totalSeconds % 3600) / 60)).padStart(2, 0); const seconds String(totalSeconds % 60).padStart(2, 0); return ${hours}:${minutes}:${seconds}; }这段代码没什么花头但padStart这个细节值得提一句。早期我用String(...)加三元判断去补零逻辑一多就容易错。换成padStart之后代码短了一大截并且可读性高得多。写小工具也别忘了善用这些小API。3. 声音不靠加载文件靠浏览器现场生成3.1 为什么拒绝音频文件跨域、体积与离线版本一我是打算放一个白噪音MP3进去的就十来秒循环那种。但很快发现三个尴尬的问题本地环境下双击HTML页面音频文件引用用的是本地相对路径浏览器在某些安全策略下会限制自动加载本地媒体文件如果要部署到网上让别人用就得找个托管位置产生跨域问题MP3文件再小也得有个几百KB这会打破整个项目三个文件加起来不到60KB的洁癖底线。所以最终方案放弃音频文件改用Web Audio API 现场生成噪声。这样连音频字节都不用传输浏览器直接在内存里合成声波你说原始不原始穴居人连火柴都没有直接钻木取火。虽然这条路有点小众但拿来做环境音效非常合适。3.2 Web Audio API 生成风声/火声的基本原理Web Audio 的核心概念是节点你先创建一个AudioContext然后往它上面挂各种处理节点比如振荡器、增益器、滤波器最后连到destination也就是扬声器。用代码算出声音本质上就是往这个管道里注入数据。对我们这个场景来说白噪音其实就是大量随机样本点的集合。每秒钟播放的样本帧数量由采样率决定比如44100Hz意味着每秒有44100个随机数转化成声音。直接生成纯随机数听到的是一种很刺激的沙沙声像收音机雪花声不够柔和。要让它接近风声或篝火声需要做两步处理第一步生成一个较长的随机噪声缓冲区buffer第二步对这个缓冲区做低通滤波削减高频成分让它变成类似自然界的隆隆声我实现了一个小型噪声合成器let audioCtx null; let noiseSource null; let filterNode null; let gainNode null; function createBrownNoiseBuffer(ctx) { const lengthInSamples ctx.sampleRate * 2; // 2秒循环 const buffer ctx.createBuffer(1, lengthInSamples, ctx.sampleRate); const data buffer.getChannelData(0); let lastOut 0; for (let i 0; i lengthInSamples; i) { const white Math.random() * 2 - 1; lastOut (lastOut 0.02 * white) / 1.02; data[i] lastOut * 3.5; } return buffer; } function startSound() { if (!audioCtx) { audioCtx new (window.AudioContext || window.webkitAudioContext)(); } const buffer createBrownNoiseBuffer(audioCtx); noiseSource audioCtx.createBufferSource(); noiseSource.buffer buffer; noiseSource.loop true; filterNode audioCtx.createBiquadFilter(); filterNode.type lowpass; filterNode.frequency.value 400; // 低频滚动的风声感 gainNode audioCtx.createGain(); gainNode.gain.value 0.6; noiseSource.connect(filterNode); filterNode.connect(gainNode); gainNode.connect(audioCtx.destination); noiseSource.start(); } function stopSound() { if (noiseSource) noiseSource.stop(); noiseSource null; }这段代码里最值得注意的就是布朗噪声生成算法。它本质上是一个自回归过程当前的输出等于上一次输出加上一个很小的随机扰动再除以一个略大于1的系数。这会让相邻的样本值高度相关低频成分占主导听上去就是那种厚重的、像远处山风和火堆闷燃的声音。跟纯白噪声比起来完全不是一个氛围。这个方案还有一个隐藏优势内存占用极小。2秒钟的噪声缓冲区撑死也就几百KB内存而且循环播放永远不会有文件结束的突兀感。你根本不需要一个音频文件长时间在那里读取一切都在内存里。3.3 浏览器自动播放策略的坑这是我踩得最早的一个坑。因为在大多数现代浏览器里如果用户没有和页面进行过交互你是不能直接调audioCtx.resume()或noiseSource.start()的会被策略拦截控制台还会给你一段长长的警告。解决办法很朴素不让声音自动启动而是在用户点击点燃按钮之后才初始化AudioContext。因为点击这个行为本身就是用户交互浏览器会放行。我的做法是在点燃按钮的点击事件回调里先调用一次audioCtx.resume()之后再启动噪声源。顺便说一句这个交互设计和真实的使用场景也吻合——你不是一打开页面就要听声音而是真正坐下准备开始工作了才点一下生火。这种由用户操作触发一切的模式比自动播放更自然。所以如果你在自己项目里复刻这个方案记住一条原则所有跟AudioContext有关的启动动作必须被包在某个用户手势的事件回调里。不然哪怕代码逻辑没问题音乐就是不会响。4. 会话记录存储在本地但别让记录变成负担4.1 为什么选择 localStorage 而不是 IndexedDB记录专注会话这件事我一开始想得比较简单把每次的开始时间、结束时间、持续时长存下来用localStorage就够了。但后来想到万一用户一天专注很多次还希望看看上周的总时长那点数据量用IndexedDB也不过分啊。深思熟虑之后我决定就localStorage。原因不是技术上限而是产品定位。观察caveman的极简主义记录本身就应该是近在眼前的一次性信息而不是庞大的历史数据库。用户想了解历史趋势完全可以自己导出或手动记录到自己的手账里这个工具不提供数据大屏式的分析功能。要知道一旦加了历史周报、统计图表代码量会指数级膨胀而用户在这个工具上得到的宁静感也会随之蒸发。数据模型我设计得非常克制const STORE_KEY caveman_sessions_v1; const MAX_RECORDS 50; function saveSession() { const elapsed getElapsed(); if (elapsed 10000) return; // 少于10秒不记录避免一堆手滑操作污染数据 const now new Date(); const record { id: Date.now(), startAt: now.toISOString(), durationMs: elapsed }; const records loadRecords(); records.push(record); // 只保留最近MAX_RECORDS条 const trimmed records.slice(-MAX_RECORDS); localStorage.setItem(STORE_KEY, JSON.stringify(trimmed)); renderSessionList(); } function loadRecords() { const raw localStorage.getItem(STORE_KEY); if (!raw) return []; try { return JSON.parse(raw); } catch { return []; } }几个细节值得说10秒以下的记录直接忽略。别小看这个门槛我实际用下来的体验是如果记录里全是我点了一下又取消了的垃圾数据那统计就没法看了。这个阈值不是技术限制而是数据质量的过滤器。MAX_RECORDS限制在50条防止无限膨胀。配合清理策略这相当于给客厅设了一个满溢阈值一旦超过就自动丢最旧的东西。JSON解析包在try/catch里。原因很简单本地存储可能因为用户清缓存、改代码、或者别的原因被写入非法数据如果不处理整个页面会直接白屏。对小工具来说容错能力比功能丰富重要得多。4.2 记录列表渲染与时间友好化渲染记录列表这部分本质上就是把时间戳转成人类能看的模式function renderSessionList() { const listEl document.getElementById(sessionList); const records loadRecords(); listEl.innerHTML ; if (records.length 0) { listEl.innerHTML li classempty今天还没有记录/li; return; } records.slice().reverse().forEach(rec { const li document.createElement(li); const start new Date(rec.startAt); const hh String(start.getHours()).padStart(2, 0); const mm String(start.getMinutes()).padStart(2, 0); const mins Math.round(rec.durationMs / 60000); li.textContent ${hh}:${mm} 开始 · 专注 ${mins} 分钟; listEl.appendChild(li); }); }为了保持列表的轻快我没有给每条记录加删除按钮也没有详情页。如果用户真想清理最直接的方式是浏览器里清除该站点的存储数据。这种粗颗粒度的管理方式和整体产品气质完全一致。当然还有一个绕不开的细节用户可能在多个标签页同时打开这个工具导致localStorage数据互相覆盖。这个问题我查了一圈发现最便宜的解法是监听storage事件或者退一步接受一个用户只会打开一个专注计时器。以这个工具的使用场景来看我选择相信后者不再给代码加复杂度。4.3 数据安全与隐私本地保存的优势最后顺带说一句隐私相关的。这套方案的所有数据都保存在用户自己的浏览器里没有服务器、没有API、没有追踪脚本。对于一个专注工具来说这其实是一个很大的卖点——用户不用再担心我的专注时长数据被拿去做什么了。开发者在做这类功能时经常没有意识到不收集数据本身就是一个巨大的产品力。caveman天然就有这种气质这也算是穴居人式取舍带来的福利。5. 完整实操从零搭建 caveman 的全过程前面我把设计思路拆完了这一节给大家一份可以直接照做的从零到一清单。一共四步每一步都说清楚你要新建什么文件、写下什么内容、然后怎么把它跑起来。5.1 第一步环境准备与目录结构这个项目对环境可以说没有要求。不需要Node.js不需要npm不需要装全局工具。你需要的是一个现代浏览器Chrome、Edge、Firefox、Safari都行和一个能写代码的编辑器我用VS Code但你用记事本也行。新建一个文件夹命名caveman进去之后建三个文件caveman/ ├── index.html ├── style.css └── app.js对就这么简单。没有node_modules没有package.json没有配置文件。如果你想把它部署到公网随便找一个静态文件托管服务把这三个文件传上去就完事。5.2 第二步编写HTML与CSS——让页面看起来像火光照岩壁HTML结构上面已经给过骨架这里直接把它补全成一个可用版本。从body底部结构看我分成了两大部分核心计时区和记录区。注意为了让按钮状态切换更直观我在HTML里没有做太多动态结构而是把状态变化交给JavaScript里的classList来控制。CSS方面我只讲两个关键点配色我用了一组深棕色背景#1a1410琥珀色文字#e8a75d按钮边框用#6b4f35。这套配色不需要复杂的设计系统推导纯粹就是模拟火光映在岩壁上的感觉。岩壁质感用了一个基础的径向渐变叠加背景色或者直接用一个radial-gradient模拟光影不均匀body { background-color: #1a1410; background-image: radial-gradient(circle at 30% 20%, rgba(232, 167, 93, 0.08), transparent 60%); color: #e8a75d; font-family: ui-monospace, Cascadia Mono, Courier New, monospace; min-height: 100vh; margin: 0; display: flex; align-items: center; justify-content: center; }其他的无非是给按钮、列表加了些基础样式。没有动效库过渡动画全用CSS原生的transition只在按钮hover和active时有一点亮度反馈。够用且不过度。5.3 第三步补齐 JavaScript 逻辑——状态切换、渲染与记录把 2.2、3.2、4.1 中的代码片段拼到一起加上renderStatus、renderTimer、事件监听和初始化调用就是一个可用的应用。如果你按顺序写可能会在saveSession和getElapsed这两个函数上碰到作用域问题。我的建议是getElapsed一定要读state里的累计值加当前时间戳差值不要用setInterval次数saveSession只在running - paused或者running - idle的状态转换点调用不在每次渲染时调用所有事件监听在window.addEventListener(DOMContentLoaded, init)里统一绑定init里其实就是两件事恢复上次渲染的界面状态、绑定按钮。这样页面一打开就能看到上次的计时数据当然如果上次是idle状态当然就是空。5.4 第四步本地验证与常见小毛病直接在浏览器里双击index.html或者用VS Code的Live Server插件启动一个本地静态服务。见过好多人忘了如果将来要用到localStorage直接用file://协议访问在某些浏览器下是允许的但差异可能存在最稳妥的方式是用本地服务器访问。我因为偷懒直接双击遇到过Safari下localStorage写入被忽略的情况排查了好久。后来老老实实起了个本地服务一切正常。验证的时候建议测这几个功能点点燃后每250毫秒刷新时间但分钟变化规律准确暂停后时间保持不变且生成了一条记录重置后时间归零且不会生成记录点击生火声后听到低频风声再次点击后声音停止刷新页面后记录还在一个小提醒测试完用手动方式清一下localStorage尤其是如果你设置了10秒门槛前面那些短促测试数据很容易积累起来让我在开发期一度以为列表坏了。定期清狗粮数据是开发这种小工具时很朴素的习惯。6. 调试避坑实录三个让我差点放弃的瞬间这一部分可能是全文最有价值的。因为代码本身不难难在你以为写对了但行为不符合预期的时候那种挫败感非常耗人。我挑三个印象最深的坑按现象-原因-修复的方式来记录。6.1 setInterval 累加偏差时间越久错得越离谱现象我把计时器挂后台去写别的代码半个小时后回来一看页面上的时间比手机秒表快了将近一分钟。原因setInterval(callback, 1000)的语义是至少每1000毫秒执行一次但如果浏览器主线程忙碌、后台标签页被降频回调时机就会延后甚至堆积。你每执行一次就1秒实际上可能已经过了1020毫秒甚至更久误差不断累积。后台运行时浏览器为了省电甚至会把定时器降到每秒一次但你依然每一秒只加1所以误差会双向放大。修复不要依赖 tick 次数来推导当前时间。计时器永远找真实时间戳做差。关键代码我在 2.2 已经给了记录state.startTimestamp任何时候需要当前经过的时间直接Date.now() - state.startTimestamp state.elapsedBeforePause。这时刷新频率只影响界面的更新节奏不再影响时间精度。改完之后哪怕我把页面放在后台两个小时从时间戳算出来的结果和手机上的秒表分毫不差。这一点对任何涉及计时的应用都通用比如秒杀倒计时、在线考试倒计时、直播观看时长统计。只要底层时间基准是Date.now()或者performance.now()不管界面怎么刷新都不会漂移。6.2 浏览器自动播放策略拦截声音连用户点击都会被拒现象Firefox 下我第一次写代码时会在startTimer()里直接初始化AudioContext并启动噪声源。用户点击点燃按钮后计时器开始走但控制台报了The AudioContext was not allowed to start. It must be resumed (or created) after a user gesture on the page.声音不出。原因某些版本的浏览器对自动播放的限制极其严格即使有用户手势如果这个手势不是直接创建/恢复 AudioContext 的那个事件也可能被拦截。具体到我的情况是因为第一次点击时我先调用了计时器初始化然后异步去启动音频这个延迟创建的手势链条被浏览器判定为不在用户激活状态下。修复把AudioContext的创建和resume()动作放到用户点击事件最顶层同步执行。就是我在 3.3 里写的方案——点击生火声时同步创建AudioContext然后在同一次事件循环里继续创建噪声源并启动。之后别的操作不会再触发resume()因此也不会出现上下文状态已暂停无法启动的报错。如果你多端开发记住一个朴素的规则所有音频API调用尽量放在按钮点击handler的第一行别隔层、别异步、别等网络回包。否则浏览器会拿非用户激活状态拒绝你。6.3 localStorage 数据在部分浏览器中被静默拒绝现象我用file://协议直接打开页面Chrome下localStorage.setItem()能正常写入。但在Safari里跑第一次点击暂停时记录写不进去控制台报QuotaExceededError或者看起来一切正常但列表始终是空的。原因Safari 对file://协议下的localStorage支持历史上一直很不稳定会把其视为无来源页面存储行为是未被定义的。配额限制也比Chrome严格得多尤其是第三方cookie被禁用之后会影响站点的本地存储可用性。修复第一步就是把访问方式从双击HTML改成python3 -m http.server或者VS Code Live Server用带主机名的地址访问。第二步在代码里给所有localStorage.setItem包一层try/catch一旦写入失败就静默降级为本次不记录。毕竟工具的核心价值是计时记录只是附加价值如果为了写一条记录让整个页面报错那才叫本末倒置。function persistRecords(records) { try { localStorage.setItem(STORE_KEY, JSON.stringify(records)); } catch (e) { console.warn([caveman] 本地存储写入失败本次会话不保留记录, e); } }其实这第三个坑是最常见的非技术因素导致的故障。开发任何本地优先的Web工具都要把存储可能失败当成基本盘而不是意外情况。7. 穴居人精神的延伸最小的工具能走多远做完这个项目之后我还真玩出了几个新用法算是给caveman这团火又添了几根柴。第一个是婚礼倒计时。我改了一下index.html里的目标时间页面就变成一个距离某天还有XX天XX小时XX分XX秒的倒计时板。因为有正计时的核心结构改成倒计时只需要几十行代码的变化逻辑上完全是同源的。这让我深刻体会到保持底层结构清晰上层功能再怎么变都不慌。第二个是付费课直播间的专注仪式。有个做线上课的朋友看上了这个页面想把它用在课程开播前的等待页面上。用户点开页面后看到的是一个安静的火堆计时画面不用聊天不用刷礼物等正式开播再切走。这可比那种动不动就弹预约直播间的浮窗舒服多了。第三个是给我自己的写作窗口。我发现人一旦打开caveman盯着那串时间数字流动就像有人在旁边守着你一样不敢轻易走神。这不是工具本身有什么魔法而是在信息过载的时代一个什么都没有的页面反而成了一种稀缺资源。这种限制恰恰放大了你的注意力。所以这个小工具的使命不是帮你管理时间而是帮你构建一个只属于当前事务的心理容器。如果再往后扩展我可能会给caveman加上这样的功能支持自定义目标时长比如45分钟、90分钟倒计时结束时有柔和的提示音支持快捷键比如空格键开始/暂停、R键重置不用鼠标点一个可选的PWA配置让用户可以安装到手机桌面获得全屏体验不过真到做的时候我还得拦一下自己每加一个功能都要问一遍穴居人需要它吗如果答案模棱两可那就先不做。这大概就是这次项目给我留下的最大收获——在工具越来越臃肿的时代我们缺少的不是更强的功能而是敢于砍掉功能的勇气。我个人现在每天写代码前都会先双击打开caveman点一下点燃听着那阵低沉的风声然后才开始工作。仪式感这东西放在其他场景可能有点形式主义但在这个工具里它恰好就是我给自己搭的那个山洞。如果你也被各种效率App绑架得不胜其烦不妨也试着造一个属于自己的caveman哪怕只有几十行代码那种返璞归真的感觉一定会让你上瘾。