拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

一个“穴居人”式极简工具:零依赖本地计时器开发全记录

一个“穴居人”式极简工具:零依赖本地计时器开发全记录

那段时间我接了个有点奇怪的活儿:帮朋友做一个"什么都不要有"的网页工具。需求听上去特别简单——打开页面,点一下开始,然后这个页面就在那儿老老实实地计时,除此之外不弹通知、不联网、不收集数据、不搞暗黑模式之外任何花里花哨的东西。但真正动手的时候我才意识到,一个"什么多余功能都没有"的工具,实际上逼着你把一堆功能全部砍掉并想清楚哪些才是核心,这比堆功能难多了。

项目代号就叫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 class="cave"> <section class="hearth"> <p id="status" class="status">火焰熄灭 · 等待开始</p> <p id="time" class="time">00:00:00</p> <div class="controls"> <button id="toggle" class="btn primary">点燃</button> <button id="reset" class="btn ghost">重置</button> <button id="soundToggle" class="btn ghost">生火声</button> </div> </section> <section class="records"> <h2>今日的记录</h2> <ul id="sessionList"></ul> </section> </main>

界面上几乎没有装饰性元素,主要原因不是为了"性冷淡风",而是为了让读取信息的成本降到最低。当页面只有那一行数字,你的眼睛没有别处可去,就自然回到当前任务本身上来。这就是界面设计的克制,也是我用"穴居人"这个代号想传递的安静感。

2.2 状态机的简单抽象:点燃、燃烧、熄灭

计时器逻辑听上去简单,但写起来有一个经典陷阱:直接依赖setInterval累加计时,最终一定会有偏差。我早期做过一个快速原型,每1000毫秒给秒数加1,结果挂机半小时后跟真实时间差了将近40秒。原因后面细讲,但在这里我先定下状态机设计,这是整个工具的地基:

  • idle(熄灭):初始状态,计时器归零。
  • running(燃烧):正在计时,记录startTimestamp与累计的elapsedBeforePause。
  • paused(休眠):暂停状态,保留当前累计值,等待恢复。

切换关系很简单:idle可以到running,running可以到paused或idle,paused可以回到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进去的,就十来秒循环那种。但很快发现三个尴尬的问题:

  1. 本地环境下双击HTML页面,音频文件引用用的是本地相对路径,浏览器在某些安全策略下会限制自动加载本地媒体文件;
  2. 如果要部署到网上让别人用,就得找个托管位置,产生跨域问题;
  3. 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 class="empty">今天还没有记录</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写入被忽略的情况,排查了好久。后来老老实实起了个本地服务,一切正常。

验证的时候,建议测这几个功能点:

  1. 点燃后,每250毫秒刷新时间,但分钟变化规律准确
  2. 暂停后时间保持不变,且生成了一条记录
  3. 重置后时间归零且不会生成记录
  4. 点击"生火声"后听到低频风声,再次点击后声音停止
  5. 刷新页面后记录还在

一个小提醒:测试完用手动方式清一下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,哪怕只有几十行代码,那种"返璞归真"的感觉一定会让你上瘾。

返回列表