就为了一句“能用就行”,我把整个工程从深渊里拉了回来。这个念头,就是“caveman”这个词给我的。
如果你现在打开我手边这台落灰的旧电脑,会看到里面躺着一个标签为caveman_notes的文件夹,里面既没有 package.json,也没有 node_modules,更没有那上万层的依赖树,只有几个.html文件、一个.css、一个.js,以及一份写满注释的原始数据文件。
这个项目,是我故意做着玩的,也是我在连续踩了三天现代前端工程化的坑之后,赌气做出来的。结果它成了我最近半年最常用、最稳定的工具。今天想聊聊这种“野人式”的开发方式——以及它背后一套足够硬核的取舍逻辑。
1. 项目崩了之后,我想当一回caveman
1.1 那个让项目轰然倒下的下午
故事得从一次同步会议说起。客户要做一个小型内部数据库,要支持模糊搜索、要能导出报表、要在三天后演示。团队里有人建议用某重型框架,有人建议引入可视化图表库,还有人建议把后端也顺手重构一下。
于是文件夹里迅速堆满了node_modules,大小直奔 1.2GB。启动开发服务器的命令敲下去,终端刷了十分钟日志。我打开页面,白屏;刷新,白屏;清了缓存,白屏。
我盯着那行报错看了二十分钟,最后发现是某个依赖的间接依赖升级了主版本,把 API 改没了。项目里没有任何人显式装过这个包,但它就在那里,安静地让整个应用瘫痪。
那一刻我脑子里蹦出来的词就是 caveman。我想回到一个只要双击 HTML 文件就能跑起来的年代,没有构建、没有依赖地狱、没有一帮我不认识的“间接朋友”在我代码底下打架。
当天我就干了件很极端的事。把这个项目的代码全部抽出来,只剩三个原生文件,数据存 JSON,查询用Array.filter,报表用原生表格加一个按钮导出 CSV。三个小时后,功能跑通了。第二天演示,毫无意外地通过了。
1.2 这个词最初触动我的地方
我特意查过 “caveman” 的本意理解,在网络语境里常被用来调侃“原始粗野但有效”的做法。但你别把它理解成贬义。在我这里,caveman 代表一种更底层的判断标准:
一个方案如果连“用最原始的工具能不能实现”这关都过不了,那它就不该被更复杂的工具包装。
数据库就存数据,查询就遍历,展示就循环。这些底层的事,任何语言都干得动。你的老板和客户永远不关心你用的是 Vue 还是 React,他们只关心演示时点按钮有没有反应,搜关键字能不能出结果。
我试过很多次,越基础的做法,往往越抗造。不是因为它功能强,而是因为它没有藏着掖着的东西。一个 HTML 文件里写的每行代码,每一个字符都是我自己敲进去的,它出错了我可以直接改,根本不用等编译器、等热更新、等构建脚本重新跑完那将近一分钟的漫长流程。
2. 所谓caveman式开发,到底砍掉了什么
2.1 砍掉一切“可能有用”的依赖
大多数人写代码的开场白是:先跑个初始化命令,装一套全家桶,再动手写业务。caveman 式开发反过来——先写业务,遇到真正绕不过去的需求,才考虑要不要引入东西。
我给自己定过一条规矩:每加一个依赖,必须问自己三个问题。
- 这个问题原生解决要多久?如果不超过四小时,就不许装包。
- 装了这个包,它能稳定维护多久?我有能力在它停更后自己修吗?
- 它带来的构建复杂度、体积增幅、安全风险,我是否全都清楚?
拿这个标签为caveman_notes的项目来说,它最初的需求只有四个:存便签、按关键词搜、按日期分组、导出成文本。这四个需求,原生三个文件全部能解决,完全没有引入任何外部库。
我身边很多朋友会不自觉地进入“库驱动开发”的模式,上来就装lodash,其实数组操作原生已经写得不错;上来就装moment.js,其实Intl.DateTimeFormat一行就能搞定格式化。大部分时候,我们不是在解决问题,而是在用新依赖掩盖自己不去读文档和规范的事实。
2.2 工具回归“手边三件套”
caveman 式开发不是让你扔掉电脑回到石器时代,而是把你的工具链收敛到闭着眼都能用熟的程度。
我现在不少小项目的标配是:
- 编辑器:任何文本编辑器都行,我用的是 VS Code,但说实话记事本也能干。
- 浏览器:Chrome 的开发者工具就是我的调试台和自动化测试仪。
- 命令行:偶尔用来跑一个本地静态服务器,或者用
grep快速找文件。
没有代码压缩、没有混淆、没有热更新、没有 Mock 服务。文件改了,手动切到浏览器按下刷新键,实话讲有时候是挺“返祖”的,但效率并不低,因为你省掉了心智负担,不用记那一大堆快捷键和配置项。改动一个样式,从保存到看到效果的时间是两秒;而大型工程里,单是热更新的轮询就要走不少时间。
2.3 流程简化成一张纸条
现代软件工程喜欢把流程做成瀑布、做成敏捷、做成看板。但你现在马上做的个人项目或者内部工具,真的需要那么多角色和仪式吗?
我在那个caveman项目上采取的是“一张纸条”流程:
- 想清楚数据存在哪里。
- 想清楚页面长什么样。
- 想清楚哪三个动作是核心。
- 把这三件事画在一张纸上。
- 打开编辑器,从第一行代码开始写。
没有任务拆解、没有 Code Review、没有专门的测试用例。后端就一个 JSON 文件,前端就三个页面。出了 bug,打开控制台看报错;报错看不懂,就加日志;日志还不够,就用debugger断点直接看。
我不建议大团队这么干,但对你个人的小项目、对临时性极强的内部工具来说,这张纸条就是全部的需求文档和验收标准。它不会过期,不会产生额外维护成本,也足够清晰。
3. 一次真实项目的“返祖”重构
3.1 项目背景和原方案的问题清单
这个项目说是内部数据库,其实就是一堆 Excel 表格,几十万行销售记录。原本的正规军方案是:后端用框架、数据库上专业引擎、前端搭一个单页应用,再套一层接口文档。
听起来专业,但问题是:
- 服务器上根本没有比较新的运行环境,所有包都得手动编译。
- 数据量虽然几十万行,但查询模式就是几个固定维度,根本不需要多复杂的索引策略。
- 真正的用户就三个人,峰值并发不超过三个浏览器标签页。
- 公司局域网内不能用外网,装个包还得找运维审批。
这套正规方案部署下去,光环境问题就足以让人崩溃。
于是我把方案整个推翻,做成了纯静态方案。
3.2 纯命令行的数据重灌
数据从 Excel 里导出来,先转成 CSV,再用一段脚本把 CSV 拆成一个data.json。
把几十万行数据全塞进一个 JSON 会很蠢吗?看场景。当时需求要求的是“能搜、能看、能导出”,不是“每秒处理亿万级并发的查询优化”。一个 18MB 的 JSON,加载进浏览器内存,占用的资源并不夸张。
我当时的做法是:
# 先从 Excel 导出 csv,假设文件叫 sales.csv python3 parse_csv.py sales.csv > data.jsonparse_csv.py核心只有几行:
import csv import json rows = [] with open('sales.csv', newline='', encoding='utf-8') as f: reader = csv.DictReader(f) for row in reader: rows.append(row) with open('data.json', 'w', encoding='utf-8') as f: json.dump(rows, f, ensure_ascii=False)然后把data.json跟 HTML、JS 放在同一个目录里。之后浏览器用fetch加载它就行。当然本地打开 HTML 文件时fetch会受协议限制,所以我顺手起了一个静态服务:
python3 -m http.server 8000这就是全部“后端”。更新数据的流程也简单:拿到新的 Excel,导出 CSV,跑脚本,刷新页面。
3.3 换掉前端工程化,功能照样齐
原本前端要装路由、状态管理、请求库、UI 组件库。重写之后我发现,这些需求在这个项目里根本不成立:
- 路由:三个页面而已,分别写三个
html文件,用原生链接跳转,要多简单有多简单。 - 状态管理:全局变量加自定义事件,足够。
- 请求库:原生
fetch就够。 - UI 组件:不就一个表格、一个搜索框、一个按钮吗,用原生加一点 CSS 就九分像样了。
搜索功能是最舒服的部分。原生数组方法带来的巨大收益,反而是以前写惯了框架的人最容易忽略的:
const keyword = searchInput.value.trim().toLowerCase(); const results = allRows.filter(row => row.name.toLowerCase().includes(keyword));几万行数据在内存里全量过滤,实测耗时不到 20 毫秒。你以为这是性能问题,其实大部分场景根本不是。你要是真做到了千万行,再考虑 Web Worker、索引、分页这些不迟;几十万行还在那儿纠结性能,属于自嗨式的过早优化。
导出 CSV 同样一行核心逻辑:
function exportCSV(rows) { const headers = Object.keys(rows[0]); const lines = rows.map(r => headers.map(h => `"${String(r[h]).replace(/"/g, '""')}"`).join(',')); const blob = new Blob([headers.join(',') + '\n' + lines.join('\n')], { type: 'text/csv' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'export.csv'; a.click(); URL.revokeObjectURL(a.href); }3.4 部署变成两个命令
原方案里,部署要经过编译、打包、上传、配环境变量、重启服务,至少五分钟,还可能出各种“只能在我的机器上跑”的幺蛾子。
caveman 版部署只有两步:
scp -r cavean_notes/ user@some-server:/var/www/notes/然后你在服务器上加一行 nginx 配置指向那个目录,完事。
我清楚地记得第一次访问线上地址时的感受:没有转圈圈、没有 Loading、没有报错,速度几乎让我怀疑网络是不是坏了。三个纯文件,加起来不到 800KB,服务端连一行代码都没写。
4. 原生方案如何应付真实需求
有人听到“原生”两个字就开始慌,觉得能力不够。我一开始也这么觉得,直到我把需求一个个拆开,逐一验证,才发现原生能覆盖的范围远比我以为的大得多。
4.1 表单与校验
做内部工具,表单是重灾区。很多人下意识就会装一套校验库,但原生表单 API 其实自带一套带错误提示的答案。
<form id="noteForm"> <input type="text" name="title" required maxlength="50" /> <input type="url" name="link" /> <button type="submit">保存</button> </form>你只需要通过 JS 判断form.checkValidity(),原生的冒泡提示框就会自动告诉你哪里没填、格式对不对,根本不用写错误码。
const form = document.getElementById('noteForm'); form.addEventListener('submit', (e) => { e.preventDefault(); if (!form.checkValidity()) { form.reportValidity(); return; } // 保存逻辑 });这样一个配置项,抵得上一个几十 KB 的校验库。
4.2 图表与可视化
真实场景里,数据可视化听上去很高大上,但内部工具需求绝大多数是趋势线、占比、排名,根本没有必要上重型图表库。
canvas原生绘制折线图,核心不超过二十行:
const canvas = document.getElementById('chart'); const ctx = canvas.getContext('2d'); const points = data.map((v, i) => ({ x: i / (data.length - 1) * canvas.width, y: canvas.height - v / max * canvas.height })); ctx.beginPath(); ctx.moveTo(points[0].x, points[0].y); for (const p of points) ctx.lineTo(p.x, p.y); ctx.stroke();你说专业图表库有动画、有坐标轴、有 tooltip,也对。但内部工具要的是一眼能看出趋势,不是发布会上的大屏动态效果。
同样,柱状图可能十行代码就画完,饼图用ctx.arc画扇形也没多复杂。我在那个项目里唯一一次破例,是客户要一张带分层嵌套的交互式地图,那确实绕不开现成的地理信息可视化库。但这种情况一年遇不到两回,为它常年拖着一个几百 KB 的资源包,非常不划算。
经验之谈是:
- 图表的复杂度低于“地图”级别,一律原生。
- 出现类似地图这样的高级需求,再针对性引入专项库,而不是全盘工程化。
4.3 权限与鉴权
权限这个事,做复杂了能养活一个安全团队。但内部工具往往只是“某些人能不能看到某个按钮”的问题。
我没上复杂登录方案,直接用的浏览器内置 HTTP Basic Auth,配合服务器的 nginx 配置:
server { listen 443 ssl; location / { auth_basic "Restricted"; auth_basic_user_file /etc/nginx/.htpasswd; } }普通同事访问时弹个框,输入账号密码,完事。密码文件用openssl passwd生成,能承载日常需求,连数据库用户表都不需要。
但我要强调一点:如果你的工具暴露在公网、涉及真实资金或个人敏感数据,这套“极简方案”不适用。安全场景下,该上的库、该做的审计、该走的流程一步都不能省。
4.4 兼容性
有人会担心原生方案在老浏览器上能不能跑。实际上,你只需要守住一条线:用 ES6 语法,别碰最新版才有的 API,兼容性基本无忧。
我在项目里尽量避免使用Array.prototype.flat、String.prototype.replaceAll这类新 API,一律手写循环或正则代替。这不是老古董,而是刻意降低代码的运行环境门槛,让这套逻辑在任何一个支持 ES6 的浏览器里都能原样跑。
如果真遇到旧浏览器就连箭头函数都不认,那也只能说明项目定位有问题,应该先推动升级浏览器,而不是惯着那个远古环境。
5. 哪些场景真的不适合“返祖”
我吹了这么多原生玩法,但不能把话说满。caveman 式开发有它的适用边界,踩过了照样会翻车。
5.1 三种不宜返祖的场景
第一种,多人协作的大型项目。如果你有二十个开发者在同一套代码上写业务,没有类型约束、没有构建工具、没有模块机制,那会是一场灾难。原生是给你一个人或两人小队准备的轻量选项,不是让团队裸奔的理由。
第二种,需要长期演化的复杂业务。如果你的领域逻辑三个月后会翻倍,或者从一开始就知道权限、状态、交互都复杂,那么老老实实引入成熟的工程化底座,比后期靠“野人方案”改头换面更省钱。
第三种,涉及高并发、大数据量、跨团队接口对接。靠静态 JSON 是撑不住大规模秒级计算的;同样,多个团队之间需要频繁交接 API 时,没有接口文档生成工具和类型定义,基本处于不可维护的沼泽里。
5.2 小团队必须踩的刹车
我在跟几个做内部工具的团队聊天时发现,他们最容易犯的错就是“把简单问题复杂化”,然后在下一次迭代里继续复杂化,最后所有人一起淹没在配置与环境问题里。
我会给他们的建议是:
- 用一张表列出所有需求,逐一打标:原生可覆盖、需少量库、必须上重武器。
- 每一个“复杂需求”单独评估,而不是让一个最重的依赖统领全部。
- 给团队定两个“不”字:不引进没有人完全读懂的框架,不引进只在某一次迭代里用了一回的工具。
内部工具的本质是“以最低成本解决问题”,工程化和原生方案之间的分界线,不在谁更“高级”,而在谁能更快交付且更稳地跑完整个生命周期。
6. 我总结的几条“野人守则”
6.1 依赖每加一层,要在心里记账
每多一层依赖,意味着你背后多了一个你无法完全掌控的开源维护者、一个版本兼容的隐患点、一个可能随时消失的 CDN 链接。这个账,如果你从来不算,最后一定会以“莫名其妙的报错”形式来找你结算。
我现在每引入一个依赖前都会做一个标注,哪怕只是在文件夹下建一个deps.md,写上为什么用、用在哪里、什么时候可以移除。半年后再回来看,那些“当初觉得必须用”的依赖,大概率已经能移除了。
6.2 用不了的核心才值得封装
野人守则里最重要的一条:不要用原生代码去硬扛和业务核心无关的复杂度,也不要用造好的轮子去替代你三分钟就能写好的循环。
核心业务逻辑,尽力做得简单、直接、可读,让任何接手的人都能一口气读下来。非核心功能,如果原生做起来费劲,再引入库也不迟。
我把这个判断标准叫做“三小时法则”:一个问题,原生方案三小时内能敲完,绝不引库;超过三小时,才允许考虑现成方案。一旦用了现成方案,必须记得写完注释,标明它解决的是什么问题,以及如果它停了,你该怎么办。
6.3 留下逃生通道
最后一条,永远给自己留一个“用记事本就能继续改代码”的出口。也就是说,你的项目不依赖任何私有的复杂配置项、不依赖专有命令脚本、不依赖非标准的环境变量。如果有一天所有工具链都消失了,你把当前目录一打包,拿到另一台有操作系统的机器上,能直接打开继续改。
我那个caveman_notes项目现在就是这么运作的。新同事入职看代码,不用先装环境,直接打开文件夹,跑一下静态服务器就能上手,半小时内能完全理解所有逻辑。这份可迁移性、可读性和最低维护成本,是我在大型工程上体会不到的安心。
现在回头看那个让我白屏了一下午的依赖地狱,我反而有点感谢那场事故。它逼着我把“能用就行”当成最高评价,也让我重新认识了什么叫工程韧性。一个解决方案的价值,不在于它用了多少高级技术和花哨架构,而在于它在多大程度上能让人放心地长期用下去。
以后你再听到 caveman,如果想到的是“原始”“粗野”“不够酷”,那都没关系。我的理解更简单:先拿木棍把火升起来,再把伟大的文明盖上去——前提是那根木棍得攥在自己手里。而且说实话,我越来越觉得,这个时代缺的不是更复杂的架构,而是有多少人还愿意把那些看似不起眼的小项目维护得干干净净。