上个月翻仓库翻出一个五年前的旧项目。当年为了显得“正规”我给它配齐了webpack、babel、eslint、jest、husky一份小得可怜的业务代码外面裹了三层构建工具。项目早就没人维护了而那天我光是让它重新跑起来就花了一个下午。那一刻我特别想回到“穴居人时代”——一根棍子、一块石头直接把东西做出来。这就是caveman这个项目名的由来。我给自己定了个规矩接下来一个月用最原始、最少依赖的方式重新做一个真实可用的网站不允许引入任何框架和构建工具链所有东西尽量用浏览器和操作系统自带的能力解决。这篇文章就是这次“穴居人开发实验”的完整记录为什么这么做、技术选型怎么取舍、具体怎么写、上线以后踩了哪些坑以及最后我得出的结论——哪些项目适合这种原始路线哪些不适合。如果你正被node_modules、容器镜像和不断升级的框架搞得心累或者你想知道“离开了构建工具到底还能不能干活”这篇文章应该能给你一些不一样的参考。1. 这个项目为什么叫caveman工具链负担的真实样本1.1 从一次“考古式跑通”说起我怀疑很多人都有类似的经历一个项目明明功能没变复杂度却在一个月内翻了倍。上一次提交记录还写着“升级构建工具链”下一次打开项目发现npm install的时间已经从两分钟涨到十分钟node_modules体积轻松突破一个G里面到底装了什么没人说得清。不少团队的荒诞之处在于功能本身没变复杂度却滚雪球。框架从Vue 2升到Vue 3路由、状态管理、代码分割、ESLint规则、Prettier配置每一层都觉得自己“很必要”但把它们叠在一起你做的只是显示一个列表。我见过一个小团队花了整整三个星期把webpack 4升级到webpack 5最后只是为了消除一个构建警告。那三个星期能做好多少用户真正需要的功能那段时间我脑子里反复出现一个比我们项目的名字caveman。穴居人没什么家当每件工具都能拿起来就用坏了就重新削一根木棍。那时候的人不追求拥有一堆石器只追求活下去和抓得到猎物。而我们呢光是维持工具本身不坏就已经精疲力尽。1.2 “穴居人”的定义不是倒退是重新选择复杂度所以我的caveman实验不是要否定现代工程而是想用一个真实的项目测试一个命题**做减法之后复杂度到底去了哪里**是把复杂度从工具链里转移到了手写代码里还是真的能整体消失我给caveman定了几条规矩允许不允许原生HTML、CSS、JavaScript前端框架React/Vue/SvelteGit版本管理打包器Webpack/Vite/Rolluprsync、Nginx等系统工具CSS预处理器Sass/LessNode.js内置模块fs、pathnpm包管理器安装的依赖必要的轻量CDN库唯一例外Docker容器、数据库系统自带命令grep、curl等任何需要复杂配置的CI/CD这张表初看很“作”但每一条限制背后都有理由。禁掉框架是为了逼自己面对原生DOM操作禁掉打包器是为了让产出物就是源码本身浏览器打开便是最终结果禁掉预处理器是为了让样式文件不再需要一层转换禁掉数据库是为了验证JSON文件到底能满足多少场景。这套“允许清单”其实说明了一句话**穴居人不是完全不使用工具而是只使用自己能完全理解、完全掌控的工具。**火已经是被驯服的力量削尖的石头也是。我把Git保留下来因为版本管理是现代开发者的安全网放弃它属于自讨苦吃Node.js保留但仍然只是内置模块因为偶尔需要写点脚本做文件检查而不是引入一个庞大的运行时生态。2. 立项与选型从“做一个什么”倒推技术方案2.1 项目边界一张个人工具导航页我给自己选的实验对象是一张个人工具导航页。这个选择是经过思考的不是我随手捡了个最简单的东西。导航页有三个好处第一它天然是静态的不需要数据库、用户登录、实时推送适合纯文件方案第二它需要真实的交互——搜索过滤、分组展示、主题切换、点击复制链接足够检验原生JS能不能扛住第三它是我每天都会用到的东西做完不会变成扔在角落的demo。功能列表再具体一些工具按类别分组展示类别可折叠顶部搜索框输入关键词瞬间过滤所有工具点击卡片复制工具链接到剪贴板深色/浅色主题手动切换并记住用户偏好适配手机屏幕保持基本可用这些功能放在“现代工程”里大概率会写成一个React应用一个状态管理store一个UI组件库四个页面文件三条路由。而我想验证的是它们能不能压缩成一张HTML、一份CSS、一段原生JS和一个JSON数据文件。2.2 三套候选方案为什么我选了最激进的那个做开发的人面对这类项目通常有三条路现代SPA工程、轻量静态站点生成器、以及这次我走的原生三件套。我把三条路的实际账算了一遍维度SPA工程化React/Vue轻量SSGAstro/11ty/Hugo原生三件套本次选择依赖数量数百个数十个0构建步骤必须构建需要构建无需构建部署产物压缩后的JS bundle生成的静态文件源码即产物SEO需要额外处理天然良好天然良好维护成本升级框架、生态变动模板语法、版本更新无工具可坏适合场景中大型应用博客、文档站个人工具、内容页说实话第三条路并不是所有情况下的“最优解”。Hugo或者Astro其实已经非常轻了但它们仍然多了一层模板引擎、一层构建命令、一层主题机制。我想知道的是如果连这一层都剥掉会发生什么所以在这次caveman实验里我刻意选择了最激进的原生路线。这样即使项目失败也能明确知道“原始方案的能力边界”在哪里。后来的结果证明这个选择带来的问题比想象中少但也确实存在几处不小的坑后面会专门讲。2.3 保留的“现代文明”Git和错误模式的校验极简不是简陋。项目从一开始就加了.gitignore所有数据文件纳入版本管理。每一次改动都有提交记录这让我可以放心地做实验、回滚、对比。你可能觉得奇怪既然连数据库都不用为何还要那么看重版本管理因为人是会犯错的动物。手一抖删了半个文件、写了一行有问题的替换逻辑没有版本历史你只能瞪着眼睛找半天。Git是极简主义者的安全气囊不该省。同样我在脚本里加了几个最基础的文件校验逻辑检查HTML引用的CSS和JS是否真实存在防止因为改名或者误删除导致线上页面静默失效。这个小校验在后来的实际运行中救了我一次这个故事留到第五章说。3. 徒手搭建一张导航页的完整过程3.1 目录结构能少一层就少一层很多项目从一开始就铺好了src/enums/constants/utils/hooks/services这种目录美其名曰“为未来做准备”。但事实上这些空目录在项目被删掉的那一天都没有被填满。caveman的目录结构极简化成了这样caveman/ ├── public/ │ ├── index.html │ ├── style.css │ ├── app.js │ ├── data.json │ └── md/ │ ├── marked.min.js │ └── purify.min.js ├── scripts/ │ ├── build-site.js │ └── check-refs.js └── .gitignore没有dist目录因为public就是线上目录没有config目录因为配置都写在代码顶部注释里没有测试目录因为交互简单到不配拥有测试框架。这种扁平结构的一个隐藏好处是**任何人第一次打开项目都能在两分钟内搞清楚哪个文件对应哪个功能。**这不比一份50页的架构文档实用多了3.2 数据结构与渲染一张JSON一段fetch数据文件是核心结构非常直观[ { title: 颜色提取器, url: https://example.com/color-picker, category: 设计, tags: [颜色, 工具], desc: 从图片中取色并生成多种格式色值。 }, { title: JSON格式化, url: https://example.com/json-formatter, category: 开发, tags: [json, 调试], desc: 粘贴一段JSON立刻获得格式化版本。 } ]渲染逻辑用了一个分组函数早期版本我用了比较新的原生分组API后来考虑兼容性换成了普通reduce。这里有个细节值得展开讲讲**数据是自己维护的所以直接用innerHTML拼字符串风险可控如果数据来源是用户输入必须做转义或者消毒处理。**而我在后面加入Markdown渲染时遇到的XSS问题正是从这里埋下的伏笔。fetch(./data.json) .then(res res.json()) .then(data { const grouped data.reduce((acc, item) { (acc[item.category] || []).push(item); return acc; }, {}); render(grouped); });搜索过滤的逻辑也很简单监听输入事件对标题和标签做一次toLowerCase比较然后重新渲染。整套交互代码加起来不到150行我没有为它写一个测试因为将来接手的人读完代码的速度可能比跑测试框架还要快。3.3 原生CSS能做到什么程度我承认在写下第一行CSS之前我最担心的板块就是样式。习惯了Tailwind和Sass之后手写原生CSS仿佛回到了石器时代。但真写起来发现现代CSS远比我们印象中强得多。布局方面CSS Grid足够干净利落地完成卡片网格.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }主题切换用的是CSS自定义属性配合data-theme属性:root { --bg: #fafafa; --surface: #ffffff; --text: #1f2328; } [data-themedark] { --bg: #1e1e1e; --surface: #2d2d2d; --text: #eaeaea; }切换逻辑就是改根元素的data属性再存一份到localStorage。没有任何状态的复杂调度没有任何样式覆盖优先级的问题。当时我在想如果所有项目都这么直接前端er的头发至少能多留两年。3.4 零依赖也别逞强Markdown解析的取舍实验进行到第三周时我决定给导航页加一个“使用说明”页面。用纯粹的HTML写说明文档实在太啰嗦我想用Markdown写然后渲染成页面。这时候我遇到了第一个真正的坎。一开始我想保持零依赖手动写了一个简单的解析器支持标题、粗体、斜体、链接、列表这几种语法。五十行代码之后问题开始冒出来正则会误匹配代码块里的星号、链接里如果带括号会断掉、内容里包含script标签时会被原样输出、嵌套列表的缩进处理更是噩梦。我不想为了省一个依赖把无数个小时烧在一个自己都不会用的“半吊子Markdown引擎”上。最后我引入了两个CDN脚本marked负责解析MarkdownDOMPurify负责消毒生成的HTML防止XSS。这一步看着“破戒”了但它完全符合caveman的精神**用最少的工具解决真实的问题而不是为了极简而极简。**真正的敌人是无意义的复杂度而不是依赖本身。const dirty marked.parse(mdText); const clean DOMPurify.sanitize(dirty); document.querySelector(#content).innerHTML clean;使用CDN有一个小诀窍给这两个脚本加上SRISubresource Integrity校验防止CDN被篡改。你可以去CDN页面把对应的hash抄出来加进integrity属性里顺便加上crossoriginanonymous。如果像我一样希望离线也能渲染就把这两个文件下载到本地public/md目录一行script标签引用就完事。我最后选了本地文件方案毕竟caveman本意是少依赖网络。4. 从本地上线没有CI/CD的部署与缓存控制4.1 一条rsync命令的满足感项目做完之后部署环节我刻意没上任何容器化或者CI流水线。我把整个public目录直接rsync推到了服务器上rsync -avz --delete public/ useryour-server:/var/www/caveman/命令虽然简单但值得啰嗦两句。--delete选项表示远端目录里如果有本地已经删除的文件会被同步删除保证两端完全一致。这个一致性在“源码即产物”的模式里特别重要因为你部署的就是本地文件系统的准确镜像不会出现构建产物里有、源码里没有的幽灵文件。没有CI的好处是部署逻辑完全透明git push之后不用猜流水线里哪个环节出了问题。每次部署最多三十秒出现意外时回滚也只需要checkout旧版本再rsync一次。对于个人项目、小团队、内部工具这种体量这一条命令带来的满足感和可控性碾压任何花里胡哨的发布系统。4.2 Nginx配置让静态文件跑得漂亮服务器用的是Nginx配置也算得上“穴居人友好”。核心配置如下server { listen 80; server_name caveman.example.com; root /var/www/caveman/public; index index.html; location / { try_files $uri $uri/ 404; } location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff2)$ { expires 30d; add_header Cache-Control public, immutable; } gzip on; gzip_types text/css application/javascript application/json; }try_files $uri $uri/ 404保证找不到的文件返回404而不是被某个奇怪的降级规则吞掉。静态资源加上30天过期时间配合文件名版本的更新是整站提速最朴素也最有效的手段。gzip对文本类资源的压缩率通常有70%以上打开一个开关就能省一半带宽实在没有理由不开。4.3 缓存策略没有构建工具怎么给文件名打版本既然不想上构建工具CSS和JS的缓存更新问题就摆上了台面。最简单粗暴的方式是手动在文件名里加版本号style.v2.css改一次删一次旧文件。但人总会偷懒也有可能忘记同步修改HTML里的引用。更可靠的方案是写一个极小的脚本在部署前计算文件内容哈希生成带哈希的文件名再自动替换HTML里的引用。代码量不大const crypto require(crypto); const fs require(fs); const style fs.readFileSync(public/style.css, utf8); const hash crypto.createHash(sha1).update(style).digest(hex).slice(0, 8); fs.copyFileSync(public/style.css, public/style.${hash}.css); // 随后在HTML的引用处把 style.css 替换为 style.hash.css这就是一个迷你的“内容寻址版本化”。有了它旧的浏览器缓存不会再用到过期的样式同时静态资源依然可以放心地设置immutable缓存。这种几十行代码解决一个真实问题的做法比装一个完整的打包工具链要轻得多。4.4 手写Sitemap与SEO不依赖工具的兜底因为前端不渲染框架HTML内容本身就是真实内容搜索引擎天然能爬到每个字。有人可能担心“纯静态不是老古董吗SEO好吗”恰恰相反搜索引擎最爱的就是这种不需要执行JavaScript就能读取全文的页面。框架们的SSR、预渲染、静态生成本质上就是在模仿纯静态HTML的天然效果。我额外用脚本生成了一份sitemap.xml和robots.txt。脚本的逻辑很简单扫描public目录下的所有HTML文件拼出URL列表写入sitemap。大约四十行Node.js搞定没有引入任何npm包。这个步骤对个人导航页来说其实可有可无但有总比没有好。5. 踩过的坑与“还能不能继续当穴居人”5.1 复盘一次真实事故部署后页面样式突然失效项目上线后大约一周我收到一个反馈有台Windows机器打开页面后样式全丢了整个页面光秃秃的像纯文本。第一反应是服务器的Nginx配置有问题因为本地跑得好好的迭代了十几个版本都没出过事。我按顺序排查了一遍。先看Network面板发现CSS请求返回了404。再看看HTML源码发现link标签里写着link relstylesheet hrefstyle.abc12345.css而服务器上实际文件叫style.f00d1234.css。问题出在本地的一轮部署脚本重新生成了文件名哈希却因为某次手动操作没有同步更新HTML里的引用。本地没暴露的原因是浏览器缓存里还留着旧页面我没有强刷。**构建工具链的缺失让“文件改名”这种小事变得完全依赖人肉同步。**这恰恰是很多工程化工具的核心价值帮你自动管理引用关系。解决方式也很caveman在scripts里加了一个check-refs脚本扫描所有HTML文件提取href和src解析相对路径然后判断对应文件是否存在。逻辑不到三十行const htmlFiles walk(public).filter(f f.endsWith(.html)); for (const file of htmlFiles) { const html fs.readFileSync(file, utf8); const refs html.matchAll(/(?:href|src)([^])/g); for (const match of refs) { const ref match[1]; if (ref.startsWith(http) || ref.startsWith(/) || ref.includes(://)) continue; const target path.resolve(path.dirname(file), ref.split(?)[0]); if (!fs.existsSync(target)) { console.error([缺失引用] ${file} - ${ref}); process.exitCode 1; } } }部署前跑一遍有输出版本号就拒绝推送。这之后类似问题再没出现过。5.2 复杂度是流动的当原始方案开始“吃力”的三个信号这次实验进行到后期我逐渐意识到一个关键事实**复杂度不会消失只会转移。**框架把复杂度隐藏了所以你觉得轻松原生方案把复杂度摊开所以你觉得费神。真正重要的不是选哪个方案而是你能否感知到复杂度去了哪里。当下面三个信号出现时我会提醒自己该考虑引入工具或者换一种思路了。第一个信号我发现自己手写的东西开始像框架的雏形。如果某天代码里出现了自定义的数据响应系统、手写的事件总线、自己实现的组件化基类说明我正在“重新发明轮子”。此时引入一个成熟框架反而是更明智的选择。第二个信号多个页面开始重复维护同一段HTML。当页面数量超过五个“模板化”的需求会越来越强烈。那时候再手写HTML效率就会大幅下降引入一个轻量SSG是合理的升级路径。第三个信号协作场景下每个人都必须理解你的私有约定。个人项目可以一个人拍拍脑袋决定“data.json的字段随便命名”但团队项目里这种随性就是灾难。极简化方案“简单”的前提是所有使用者都在同一认知水平上。5.3 我的取舍清单哪些项目值得做caveman哪些不该经过一个月的实操我总结出一张相对清晰的判断清单。它不一定适用所有人但至少能帮你避免走向“为了极简而极简”的极端。适合做caveman不适合做caveman个人工具页、导航页用户系统、需要权限控制的应用小型静态内容站后台管理、复杂状态界面活动落地页、原型验证实时协作、实时推送功能内部文档、笔记工具需要持久化数据的关系型业务非技术的个人展示页长期多人协作的复杂产品这张表的判断标准很简单**页面数量是否有限、交互是否轻度、数据是否有复杂关联、维护者的数量是否只有自己。**四个问题如果都倾向于“否”那caveman就不适合你反之它可能是最舒服的舒适区。5.4 最后一点体会说实话这次caveman实验没有让我变成一个“拒绝所有框架”的原教旨主义者。它改变了的是我对工具的态度以前我习惯“先装一堆可能性”现在我会先问自己“哪个问题的哪一部分真的需要它”。我确实享受一条rsync命令上线的畅快但我也知道未来一旦项目的页面数量和交互复杂度增长我会坦然地上轻量工具而不是绑在原地。带着这次实验的经验以后我再开新项目会先回答三个问题我要解决的到底是什么问题用户真的会用框架提供的那部分能力吗如果三年后接手的人看到这套代码他会感谢我还是骂我想清楚这三个问题选技术方案就变成了一件轻松的事。