最近我把一个内部工具的依赖清到了零,整个项目就剩一个 HTML 文件,双击就能跑。同事看了一眼说:"你这代码写得跟穴居人似的。"我说谢谢,这正是我想要的——这就是 Caveman 式开发。
Caveman 这个词最近又火了起来,但这里聊的不是原始人生活方式,而是一种正在回归的开发心态:不装框架、不拉依赖、不开构建工具,像穴居人用石头和火那样,用最基础的技术直接解决问题。这篇文章就从这个词展开,聊聊我为什么主动"倒退",以及我用这套思路从零手写一个极简番茄钟的全过程。适合所有被 node_modules 体积和构建链复杂度劝退过的开发者,也适合想快速做出小工具又不想引入一堆依赖的新手。
1. Caveman式开发:我为什么选择倒退
1.1 所谓Caveman,其实是一种取舍
Caveman 字面意思是穴居人、原始人,放到软件开发语境下,不完全是贬义。它代表一种"就地取材、够用就好"的解决问题方式。穴居人不会去冶炼钢铁再去打猎,他们捡起石头和削尖的木棍就上。我们写代码也一样,很多时候本来只需要一块石头,结果搬了一座炼钢厂来。
我见过太多这样的场景:做一个给三五个人用的内部工具页,上来就是一套 Vue CLI 初始化,再配 TypeScript、ESLint、Prettier、Router、Pinia,最后 node_modules 占了 400 兆,构建要跑一分钟。而项目本身只是查个数据、做个简单表单。你说这算不算过度武装?
Caveman 式开发的核心,就是先判断问题有多大,再决定带多少装备。问题小,就直接用浏览器原生能力、手写几段代码,跑通就行。这不是技术退步,而是一种更清醒的取舍——知道什么时候该用重武器,什么时候捡根树枝更高效。
1.2 现代工具链的代价
这几年前端工具链的膨胀几乎到了荒谬的程度。一个空项目的依赖树能带来几百个包,其中很多你永远不知道它是干什么的。构建慢、升级难、漏洞多,每一次版本变更都可能带来连带崩溃。
工具链本身当然有价值,它解决了状态管理、组件复用、跨端一致性这些真实问题。但问题在于,很多场景根本到不了需要这些能力的规模。就像一个番茄钟,它需要响应式渲染吗?需要虚拟 DOM 吗?需要路由吗?都不需要。这些工具不是帮你,而是在你和最终结果之间多垫了一层,而每一层都是出问题时的排查成本。
我个人的体会是:工具链减到一定程度,开发体验反而会回升。没有构建步骤,改一行代码刷新就是最新的;没有类型检查报错,逻辑反而看得更清楚;没有几十个依赖的间接引用,出了问题一眼就能找到源头。
1.3 什么项目适合当"穴居人"
不是所有项目都该用 Caveman 方式硬扛,这必须说得直白一点。我一般按这三个标准判断:
第一,边界清晰。工具型应用、个人站点、数据展示页、内网系统,这类需求明确且范围有限,非常适合零依赖手写。第二,生命周期不需要长期演进,或者即便要演进,也不依赖复杂框架生态。第三,性能或可维护性上,原生方案已经足够。
反过来,如果项目要多人协作、要复杂交互、要长期迭代,那老老实实用框架和工程化体系。Caveman 是一种选择,不是教条。非要给大型电商网站手写原生 JS,那不叫极简,那叫给自己找罪受。
2. 从零手写一个Caveman番茄钟:需求与设计
2.1 为什么选番茄钟当练习项目
为了把 Caveman 思路讲透,我挑了一个经典小工具:番茄钟。它做起来够简单,却又覆盖了状态切换、定时逻辑、界面刷新、通知提醒、持久化这几个开发中的常见环节,非常合适。
更重要的是,它是我每天都在用的东西。我之前用过各种番茄钟 App,有的功能花哨但启动太慢,有的免费版到处是广告,有的动不动要我注册账号。后来想想,这种简单东西完全没有必要用别人的服务,自己写一个最原始、最趁手的不好吗?
2.2 功能清单与界面设计
我给自己定的功能需求,尽量精简:
- 三种模式:工作 25 分钟、短休息 5 分钟、长休息 15 分钟。
- 开始、暂停、重置三个动作就好,不需要统计图表。
- 时间到要提醒我,页面里要有声音,浏览器要弹通知。
- 关掉页面再打开,计时状态不能丢。
- 整个项目一个 HTML 文件,零依赖,双击就能跑。
界面也按 Caveman 风格来。我不打算用任何 UI 库,自己用 CSS 上色就行。主色调选土黄、赭石、岩灰这些石头和泥土的颜色,衬着黑底白字,看起来像洞穴壁画也像现代极简仪表盘。数字用等宽字体,计时器跳动的时候不会因为字体宽度不同而左右抖。
界面布局从上到下就三块:模式切换标签、巨大的剩余时间、三个操作按钮。不需要画蛇添足的一堆外围控件。
2.3 技术方案:零依赖是底线
这里我给自己立的规矩是:不许引入任何第三方库。哪怕是一个简单的日期处理库都不行。
HTML 结构用一个<main>包起来,CSS 全部写在<style>里,JavaScript 全部写在一个<script>标签里。所有代码在一个文件里,意味着你把它下载到本地,双击就能运行,不需要 npm install,不需要 dev server,不需要打包。甚至传给同事,对方在任意电脑上都能立刻打开用。
这个底线不是自我感动,而是实用需求:我可能在有网也可能在没网的环境用这个计时器,只有单文件才真正做到处处可用。
3. 核心代码实现与原理拆解
3.1 状态管理:不引入框架如何保持有序
没有框架里的响应式系统,状态管理全靠自觉。我的办法是:把所有可变状态集中到几个全局变量里,界面改动只通过一个刷新函数进行。
let timer = null; let endTime = null; let remaining = WORK_DURATION; let currentMode = 'work';这样写的好处是,任何时候你打开代码,一眼就能看清整个应用的面貌。timer存定时器编号,endTime存结束时刻的时间戳,remaining存剩余毫秒数,currentMode存当前处于哪个阶段。全局变量确实被框架们诟病,但在这个体量下,它们比引入一个状态管理库清晰得多。
界面刷新统一由updateDisplay()完成。无论是切换模式、用户点击开始还是计时器每跳一下,都只调用这一个函数去更新 DOM。这样不会出现"某个按钮改了数据但界面没变"这种割裂问题。
function updateDisplay() { const totalMs = endTime ? endTime - Date.now() : remaining; const totalSec = Math.max(0, Math.ceil(totalMs / 1000)); const min = String(Math.floor(totalSec / 60)).padStart(2, '0'); const sec = String(totalSec % 60).padStart(2, '0'); document.getElementById('timeDisplay').textContent = `${min}:${sec}`; document.title = `${min}:${sec} - Caveman Pomodoro`; if (totalSec <= 0) { completeCurrentSession(); } }padStart(2, '0')是个容易忽略但很实用的小方法,保证个位数秒数显示成09而不是9,整整齐齐。另外我把剩余时间同步写到了页面标题上,这样即使标签页被折叠,通过标签栏也能看到还剩多久,这对番茄钟来说是刚需。
3.2 计时准确性:为什么不能用setInterval计数
新手写计时器最容易踩的坑,是这么写:每秒把秒数减一,然后刷新界面。
// 错误示范 setInterval(() => { remainingSec--; updateDisplay(); }, 1000);表面看没问题,实际上有个隐患:setInterval 不保证每秒准时触发。浏览器为了省电,会在标签页后台时把定时器节流到每秒一次甚至更久,手机浏览器锁屏时还可能直接停掉定时器。到时候你看着它计时,实际可能慢了十几秒。
Caveman 方案解决的思路也很原始——不打节拍,直接看沙漏。记录目标结束时间,用当前时间反推剩余量:
function start() { if (timer) return; endTime = Date.now() + remaining; timer = setInterval(updateDisplay, 250); updateDisplay(); }这里两个关键点。第一,endTime是绝对时间戳,不是相对秒数。不管定时器被节流、被延迟、甚至暂停一小会儿再恢复,updateDisplay()里用endTime - Date.now()算出来的都是真实剩余时间。第二,刷新间隔我用 250 毫秒而不是 1000 毫秒,这样即使某个周期被浏览器延后一点,也会在下一个周期马上追上,数字平滑得多,看着很舒服。
3.3 通知与提示音:原生API的妙用
时间到光靠界面变数字是不够的,如果你在另一个窗口做事,根本看不见。所以我在completeCurrentSession()里同时做了三件事:弹浏览器通知、播放提示音、自动切换模式。
function notify(message) { if (Notification.permission === 'granted') { new Notification('Caveman Pomodoro', { body: message }); } } function beep() { const ctx = new (window.AudioContext || window.webkitAudioContext)(); const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.connect(gain); gain.connect(ctx.destination); osc.frequency.value = 880; gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 1); osc.start(); osc.stop(ctx.currentTime + 1); }这里没有引入任何音频文件,就用 Web Audio API 现场合成一个 880Hz 的短音。880Hz 是 A5 音,不算刺耳但足够清晰。音量包络用指数衰减,听起来是"叮"一声而不是持续的噪音。整个代码只有十行。
Notification API 的前提是用户授权,所以页面加载后我会主动询问一次权限,并把按钮绑定到用户点击上。这里有个实操要点:部分浏览器要求必须由用户手势触发 AudioContext 创建,所以在开始计时的那次点击里,就先把 AudioContext 创建好,后面到点再响就不会被拦截。
3.4 持久化与页面恢复
"关页面不丢状态"是番茄钟最实用的功能。Caveman 的解法还是最原始的那个——把事情记在石头上。在浏览器里,这块石头就是localStorage。
我在开始计时的时候,把结束时间戳和当前模式写进 localStorage。页面加载时,先检查有没有未完成的计时,有就直接恢复。
const STORAGE_END = 'caveman_pomodoro_end'; const STORAGE_MODE = 'caveman_pomodoro_mode'; function restoreState() { const savedEnd = localStorage.getItem(STORAGE_END); if (!savedEnd) return; const savedMode = localStorage.getItem(STORAGE_MODE) || 'work'; const end = parseInt(savedEnd, 10); if (end <= Date.now()) { localStorage.removeItem(STORAGE_END); return; } currentMode = savedMode; endTime = end; updateDisplay(); // 自动恢复计时 timer = setInterval(updateDisplay, 250); }这个实现里有个细节值得提:我只存了"结束时间戳",而不是存"剩余秒数"。这俩有啥区别?如果存的是剩余秒数,你刷新页面后还得自己往下数,这中间耽误的时间就丢了。存结束时间戳,哪怕页面关了 10 分钟再打开,重新算出来的剩余量也是正确的,中间流逝的时间被自动扣除,仿佛计时器从来没停过。
4. 完整代码与发布流程
4.1 单文件源码全貌
把上面的逻辑拼装起来,就是下面这个完整文件。你可以直接复制保存成pomodoro.html,双击就是成品。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Caveman Pomodoro</title> <style> :root { --bg: #1a1612; --stone: #c8b89a; --clay: #b5532a; --dim: #6b6151; } * { box-sizing: border-box; margin: 0; padding: 0; } body { background: var(--bg); color: var(--stone); font-family: "Courier New", ui-monospace, monospace; min-height: 100vh; display: flex; align-items: center; justify-content: center; } main { width: min(92vw, 420px); padding: 2rem; border: 2px solid var(--stone); border-radius: 16px; text-align: center; } .modes { display: flex; gap: 8px; justify-content: center; margin-bottom: 2rem; } .modes button { background: transparent; color: var(--dim); border: 1px solid var(--dim); padding: 8px 12px; border-radius: 8px; font-family: inherit; cursor: pointer; } .modes button.active { color: var(--stone); border-color: var(--stone); background: rgba(255,255,255,0.05); } .time { font-size: 5rem; font-weight: bold; letter-spacing: 4px; line-height: 1; margin: 1rem 0 2rem; } .controls { display: flex; gap: 12px; justify-content: center; } .controls button { background: var(--clay); border: none; color: #f2e9d8; padding: 12px 24px; border-radius: 10px; font-family: inherit; font-size: 1rem; cursor: pointer; } .controls button.secondary { background: transparent; border: 1px solid var(--dim); color: var(--dim); } </style> </head> <body> <main> <div class="modes"> <button>