“caveman”,这个词儿在开发者圈子里其实有两种完全不同的味道。一种是贬义的,形容代码写得太原始、不懂用工具;另一种,则是这几年慢慢流行起来的一种反叛姿态——主动选择“原始”,抛弃花花绿绿的现代框架,回到最朴素的方式去解决问题。我这篇要聊的,就是后者。
我一个人维护三四个小项目,慢慢发现一件事:很多时候让我们寸步难行的,根本不是业务复杂度,而是那些为了“省事”引进来的现代化工具链本身。依赖冲突、版本升级、构建报错、抽象泄漏……层层叠叠的复杂性像滚雪球一样。于是我开始尝试一种很土很野的做法,尽量做一个“caveman”——只穿兽皮、只会钻木取火、只用最基础的工具,但照样把活干完。这个过程有意思得很,踩的坑也不少,今天就完整复盘一下。
1. 为什么突然想不开:是谁逼我当原始人的
先说结论:不是我要回归原始,是现代工具链先动的手。
1.1 一次让我彻底破防的依赖地狱
年初接了一个内部工具的小需求,逻辑本身两百行能写完。结果npm install之后,node_modules占了800多MB,光构建就跑了三分钟,期间还冒出来两个版本冲突警告、一个deprecated提示。我当时的感受就四个字:实属离谱。
翻出package-lock.json数了一下,顶层依赖一共6个包,但传递依赖有1400个包。我写业务逻辑,结果三分之二的时间花在跟webpack、babel loader、polyfill配置搏斗上。这合理吗?我到底是在写功能,还是在给工具链打工?
1.2 从“用框架”到“写框架”的顿悟
后来我在做一个极简网页的时候,试着没引任何框架,纯HTML加原生JavaScript,一个外部依赖都没有。写完之后发现,体感竟然出奇地好:启动零等待、调试零转换、部署零构建。
那一刻我开始认真琢磨一个问题:如果我把这种“什么现成工具都不碰”的狠劲推到极致,用最原始的方式去实现一个完整的东西,到底能干到什么程度?会不会遇到一堵墙?带着这个好奇心,我正式立项了一个自己的“caveman project”。
1.3 给这个项目的定义
我给这个项目定的规矩很简单,也很苛刻:
- 不引入任何第三方运行时库(标准库除外)
- 不引入任何构建打包工具(直接用原生模块)
- 不使用任何前端框架(纯HTML/CSS/原生JS)
- 不依赖自动化测试框架(用最土的手写断言脚本)
- 不用配置框架(能用环境变量解决的,不写配置文件解析器)
这使得项目的骨架非常干净整齐。整个项目的目录结构:
caveman/ ├── public/ # 静态文件,一个文件夹里躺着所有东西 │ ├── index.html │ ├── style.css │ └── app.js ├── server.js # 一个文件打天下,既是服务端又是API层 ├── db.js # 基于JSON文件的“数据库”,零依赖 └── test.js # 手写冒烟测试一眼看到底,没有任何花活。接下来我详细拆一下,这套“原始组合拳”到底是怎么打出来的。
2. 原始工具链的选型逻辑:越土的东西越不会骗你
用了这么多年现代框架,突然回到“石器时代”,最核心的转变不是技术栈换了,而是思考逻辑完全变了:从“我需要什么工具”转变成“如果我连工具都得自己造,这个需求还成立吗”。
2.1 数据库的返祖:JSON文件当存储
项目里有一块需要持久化的数据,量不大,几百条记录,但是要求结构灵活。正常思路是引个SQLite或者MongoDB。但我想了想,这个体量的数据,存文件就好——直接存JSON。
// db.js - 用同步读写实现最原始的数据库 const fs = require('fs'); const path = require('path'); const DB_FILE = path.join(__dirname, 'data.json'); function readDB() { try { return JSON.parse(fs.readFileSync(DB_FILE, 'utf-8')); } catch (e) { return { tasks: [] }; } } function writeDB(data) { fs.writeFileSync(DB_FILE, JSON.stringify(data, null, 2), 'utf-8'); } function addTask(task) { const db = readDB(); db.tasks.push({ id: Date.now(), ...task, createdAt: new Date().toISOString() }); writeDB(db); } function getTasks() { return readDB().tasks; } module.exports = { addTask, getTasks };这个设计听起来简陋,但它把所有“数据库该干的事”都暴露在明面上,没有任何隐藏的锁机制、缓存层、查询优化器。数据量一旦真的上到十万条,这个方案会崩,会非常惨。但作为一个小工具,它够用了,而且重启进程数据还在。
2.2 为什么同步读写在这里是对的
很多人一听到fs.writeFileSync就皱眉头,觉得阻塞、不优雅。但在这类极低并发、一个用户自己用的小工具里,同步读写反而是最简单可靠的方案。它天然避免了异步条件下常见的写冲突和竞态问题,根本不需要引入事务机制。
这就是caveman思路的核心:不是选最优雅的,而是选最匹配场景的。写代码的人心里要有杆秤——知道哪里该讲究、哪里不该讲究。
2.3 HTTP服务器:Node自带模块一把梭
服务端用的是Node内置的http模块,不碰Express、不碰Koa。
// server.js - 原生http模块实现路由 const http = require('http'); const fs = require('fs'); const path = require('path'); const db = require('./db'); const MIME_TYPES = { '.html': 'text/html', '.css': 'text/css', '.js': 'text/javascript', '.json': 'application/json' }; const server = http.createServer((req, res) => { const url = req.url.split('?')[0]; if (url === '/api/tasks' && req.method === 'GET') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(db.getTasks())); return; } if (url === '/api/tasks' && req.method === 'POST') { let body = ''; req.on('data', chunk => { body += chunk; }); req.on('end', () => { const task = JSON.parse(body); db.addTask(task); res.writeHead(201, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true })); }); return; } // 静态文件处理 const filePath = path.join(__dirname, 'public', url === '/' ? 'index.html' : url); const ext = path.extname(filePath); fs.readFile(filePath, (err, content) => { if (err) { res.writeHead(404); res.end('Not Found'); return; } res.writeHead(200, { 'Content-Type': MIME_TYPES[ext] || 'text/plain' }); res.end(content); }); }); server.listen(3000, () => { console.log('caveman server running at http://localhost:3000'); });没上Express的原因很简单:我的路由一共四个,两个API加一个静态文件。用框架带来的收益几乎为零,而框架本身的抽象反而让“理解代码到底在干什么”变难了。
2.4 前端:回归HTML的原始愉悦感
前端就更直接了。一份HTML、一份CSS、一份原生JS,没有SPA路由、没有状态管理库、没有虚拟DOM。
<!DOCTYPE html> <html lang="zh"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Caveman Task Manager</title> <link rel="stylesheet" href="style.css"> </head> <body> <main class="container"> <h1>Caveman Tasks</h1> <form id="taskForm"> <input type="text" id="taskInput" placeholder="写点啥,原始人?" required> <button type="submit">加进去</button> </form> <ul id="taskList"></ul> </main> <script src="app.js"></script> </body> </html>app.js里的核心逻辑也就二十几行:
const form = document.getElementById('taskForm'); const list = document.getElementById('taskList'); async function loadTasks() { const res = await fetch('/api/tasks'); const tasks = await res.json(); list.innerHTML = tasks.map(t => `<li>${t.title}</li>`).join(''); } form.addEventListener('submit', async (e) => { e.preventDefault(); const title = document.getElementById('taskInput').value; await fetch('/api/tasks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title }) }); form.reset(); loadTasks(); }); loadTasks();没有脚手架,没有组件化,代码从头到尾一撸到底。初次体验下来最大的感受是四个字:心里踏实。每个变量从哪来到哪去,一眼看穿。
3. Caveman项目的核心摩擦点:没有框架庇护之后的破事
如果光说“原始真好”,那是不负责任的。真正用兽皮和石头过日子,第一周的新鲜感过了之后,各种问题就浮出水面了。
3.1 原生JS的禁区:你以为你没依赖,其实你创造了依赖
不引第三方库之后,我发现自己开始在项目里手写各种工具函数。比如日期格式化、防抖函数、深拷贝……写着写着我猛然意识到:这不就是在重新发明轮子吗?
这些函数虽然没有出现在package.json里,但它们本质上就是我自己的私房框架。我同样要维护它、测试它、修补它的边界情况。这跟用第三方库的差别在哪?
我的判断标准变成了这样:
| 考虑维度 | 引库 | 手写 |
|---|---|---|
| 代码量 | 少但不可见 | 多但全部可控 |
| 出bug概率 | 低(社区验证过) | 高(自己造的轮子不圆) |
| 维护成本 | 看上游脸色 | 完全自己做主 |
| 理解成本 | 需要读文档 | 不用读文档 |
后来我的结论是:只手写“和业务强相关、而且只有我自己才懂为什么这么写”的逻辑。像深拷贝这种通用问题、边界条件多如牛毛的基础设施,老老实实用工具库,别硬扛。Caveman不等于蠢,更不等于跟标准库以外的所有代码势不两立。
3.2 CSS的“伪依赖”:样式崩了没人背锅
前端没框架了,CSS布局只能自己调。以前Flexbox搞不定的地方,我打开Bootstrap文档抄一个类名就行。现在不行了,所有像素都得自己抠。
更扎心的是,我发现自己对CSS标准并不熟。平时被框架们保护得太好,很多属性只在框架源码里见过。一旦没有框架罩着,遇到一个奇怪的垂直居中问题,得从头去翻MDN文档。后来走了这么一条路:把常用的CSS布局模式自己沉淀成了一套“符合直觉”的手写工具类,但再也不引第三方库了,自己维护的那套东西放在style.css顶部,做就做到彻底。
3.3 没有类型系统的夜晚:留下了悔恨的泪水
这个项目最大的痛苦点不是别的,是JavaScript本身的动态类型。写小demo的时候很爽,但功能多了之后就不行了。有一次重构API,把一个字段的命名从taskName改成title,全局搜了一遍,漏掉了两处字符串拼接的地方。跑起来不报错,就是前端列表上显示undefined。排查了半天才找到。
这次教训让我做了一个违背caveman准则的决定:在server.js和app.js里加JSDoc注释,配合IDE的类型推导。它不是TS,但至少能在我悬停变量时给我一点点慰藉,能提醒我“注意,这个函数可能返回undefined”。
这大概就是caveman的弹性:原则要有,但不是用来殉道的。
4. 从零开始的生命周期管理:原始人怎么确保代码还活着
现代工程体系里有个重要的角色,叫“测试”。但作为caveman,我连测试框架都不想引。那我为什么还要测试?怎么测?
4.1 手写冒烟测试:从钻木取火到保留火种
我写了一个test.js,用最土的方式,把核心逻辑过一遍。不用Jest,不用Mocha,甚至不用assert模块。
// test.js - 手工冒烟测试,不合格就退出码非0 const db = require('./db'); const fs = require('fs'); const path = require('path'); // 重置测试数据 const TEST_DB = path.join(__dirname, 'test-data.json'); fs.writeFileSync(TEST_DB, JSON.stringify({ tasks: [] }), 'utf-8'); // 手动覆盖DB路径 process.env.DB_FILE = TEST_DB; const tests = []; function test(name, fn) { try { fn(); console.log(`PASS: ${name}`); } catch (e) { console.error(`FAIL: ${name}`); console.error(e.message); process.exitCode = 1; } } test('添加任务后列表长度+1', () => { const before = db.getTasks().length; db.addTask({ title: '测试任务' }); const after = db.getTasks().length; if (after !== before + 1) throw new Error(`期望${before+1},实际${after}`); }); test('任务包含必要字段', () => { const tasks = db.getTasks(); const last = tasks[tasks.length - 1]; if (!last.id || !last.title || !last.createdAt) { throw new Error('任务缺少必要字段'); } }); console.log('冒烟测试跑完了,退出码:', process.exitCode || 0);然后直接在package.json里加一行:
{ "scripts": { "test": "node test.js" } }就这两个脚本,覆盖了这个项目80%的“核心风险”。为什么够用?因为caveman项目的优势就是代码量少、模块边界清晰,手写几个测试就能覆盖绝大多数关键路径。上了一堆测试框架,反而给这么小的项目增加依赖负担。
注意:这个方案的前提是“你确实只有一个核心模块、一条主流程”。如果你的项目有四个模块、互相调用,那还是老老实实用正经测试框架吧,别把冒烟测试当万能药。
4.2 部署的返祖:rsync一梭子
现代部署方案是容器化、CI/CD流水线、Kubernetes集群。但对我来说,这个项目部署只需要一条命令:
rsync -avz --delete ./public/ user@my-server:/var/www/caveman/再加一条重启服务的命令:
ssh user@my-server "pm2 restart caveman-server"没了。没有镜像构建、没有编排文件、没有私有仓库。
为什么这么干?因为项目的全部产品就是一个静态服务加一个轻量API进程,把它塞进容器、走一遍镜像流水线的收益,不足以覆盖成本。尤其是在带宽和部署频率都低的情况下,一个rsync干完的事儿,为什么非要弄出七八个配置文件?
4.3 配置管理的返祖:环境变量裸奔
配置文件多的项目,我记得早就用过什么etcd、什么Apollo配置中心。但caveman项目的配置,就该用环境变量直接裸奔:
PORT=3000 DB_FILE=./data.json node server.js没有任何配置文件解析、没有分层配置、没有热更新。原因很简单:变量一共两个,用配置文件反而在引入“解析”“格式校验”“覆盖逻辑”这些额外复杂度。
5. 怎样的项目适合caveman式开发:边界条件一手摸清
前面说了这么多caveman的好处,但咱们得承认,原始人在刮骨疗伤,现代人也在用抗生素。这个方法论有它天然的适用边界,用错地方就是灾难。
5.1 试金石:三个问题问自己
考虑一个项目能不能用caveman方式,我建议先问三个问题:
- 核心业务逻辑有多复杂?如果超过了“两个模块、三条数据流”,那手写基础设施的成本就开始高于框架成本了。
- 你会不会长期维护?如果项目只需要跑一个月,那怎么糙怎么来。如果打算跑三年,我得考虑未来接手的人能不能理解我这套石器时代的规矩。
- 业务变化有多频繁?变化越快,越需要框架提供的可维护性;变化慢的稳定小工具,反而适合极简方案。
5.2 最适合caveman的项目类型
根据我这次的实践经验,下面这几类项目是caveman方案的主场:
- 内部工具、原型验证、一次性脚本
- 个人博客、极简内容展示站
- 数据量小、并发低、无复杂查询的业务
- 学习项目——用它来弄懂底层原理
这些项目有一个共同特征:可丢弃性高。就算代码写得丑、架构不优雅,出问题了大不了重写一遍,成本极低。这种项目引入重型工程体系反而是负资产。
5.3 千万别caveman的几种情况
当然,踩过坑才清楚禁忌在哪里。
首先,涉及多人协作、要长期演进的中大型业务,千万别用caveman。团队需要的不是你的个人英雄主义,而是约定俗成的工程规范。这时候硬要追求极简,会让所有同仁活在你的“独创迷宫”里。
其次,安全敏感型项目。你敢在一个存用户登录态的项目里自己手写加密算法,那就是给自己挖坑。这种领域老老实实用成熟方案,别拿caveman当挡箭牌。
第三,你要用到生态里特别强势的功能,比如前端的三维渲染、复杂的图表展示,去引库就好。真的,别用原始人的石头去画油画。
6. 写在最后:原始与文明,说到底是一种平衡
这次caveman项目的体验,给的收获倒是意外地丰厚。它让我重新审视了每一天都在用的工具——框架省下的那部分时间,我到底用在了哪儿?是被更多抽象层吃掉了,还是真的省下来写了业务?
后来我把这次经验的结论沉淀成了四个字:适度返祖。该野的时候野,该文明的时候文明。更重要的是,这次返祖经历把我从“只会用框架堆代码”的状态里拉了出来,很多以前觉得理所当然的工程设施,我总算搞明白了它到底在解决什么问题,又是怎么解决的。
如果你也在被自己手头工具链搞得疲惫不堪,我真心建议你也做一次“caveman实验”——不用多,一个周末的小项目就够。挑个以前想做的点子,把一切能砍的依赖全砍了,用最原始的方式把它造出来。你会发现两个结果里的至少一个:要么,工具链确实该死;要么,原来框架一直在为你负重前行。
顺带一提,如果你也想复刻我这个caveman任务管理器,那份完整代码我整理好了。核心文件不超过五个,总共加起来不到两百行。动手改一改,往里面加你自己的业务,你会发现写代码这回事,有时候越土越自在。