如果你也被动辄几个G的node_modules搞得心烦意乱,每次开个新项目先等半天工具链初始化,那caveman这个项目应该能让你舒服一点。caveman是我最近抽空写的一个极简静态网站生成器,整个项目只有一个JavaScript文件、零第三方依赖,把一篇篇Markdown文件直接变成可上线的静态网页。我给它起这个名字,纯粹因为它的理念像原始人:手上就那几样工具,但每一样都可靠、都能活命用——不需要复杂的脚手架,不需要几十个包的依赖树,甚至不需要安装任何框架。
这个项目最适合三类人:被重型框架拖累的小团队和个人开发者、想要一个低维护成本长期站点的人、以及纯粹对“工具链减肥”感兴趣的同行。如果你已经是个前端老手,你会觉得它的实现方式很复古,但恰恰是这种复古,让它几乎没有需要时刻盯着升级的脆弱环节。如果你是个刚开始写博客的新手,它也很友好——没有热更新也要先装五个依赖的怪圈,没有配置文件比内容还长的尴尬,你只需要看一个文件,就能搞懂网站是怎么从一行Markdown变成一堆HTML的。
1. 项目缘起:为什么我要写一个“原始人”工具
1.1 对重型工具链的疲劳感
说出来可能有点矫情,但真正促使我动手的,是一次不到五个页面的落地页需求。按常规思路,我初始化了一个前端框架项目,安装了路由、状态管理、构建工具、代码检查、格式化、测试框架……一切就绪之后,项目目录里光是node_modules就有将近2G,打开编辑器要等十几秒,第一次启动开发服务器转了快一分钟。
然后我真正要写的,不过是首页、关于页、两个产品页和一个联系表单。
这种场景我相信不少人都遇到过。当项目复杂度远低于工具链复杂度的时候,整个开发体验是扭曲的:你花在等构建、等重启、等编译器上的时间,比写页面的时间还多。更别提这些工具每过两三年就来一次大版本升级,每次升级都伴随破坏性变更,你需要重新学配置、重新踩坑、重新适配。几年积累下来,我越来越觉得自己不是在写业务代码,而是在给工具链打工。
那次落地页交付之后我就在想:有没有一种可能,让这个项目假想中的技术栈回到原始社会,用最少最稳的工具把事办了?
caveman就是那次思考的产物。
1.2 “原始人”的生存哲学
原始人为什么能活下来?不是因为他们工具多,而是因为他们手上的工具足够可靠,而且坏了能自己修。石斧断了就重新磨一块,长矛丢了就再削一根,没有供应链依赖,没有“这个框架已经不再维护了”的焦虑。
我做caveman时就给自己定了三条原则,后来整个实现都是围绕这三条展开的:
- 零第三方依赖。所有功能都用Node.js内置模块解决,不安装任何npm包。
- 单文件即可运行。整个构建工具就一个index.js,复制到任何有Node环境的机器上都能跑。
- 一分钟出活。从克隆项目到生成第一个可访问的页面,时间控制在30秒以内。
这三条原则看起来简单,实际执行起来会让很多“常规做法”直接失效。比如我最初的草图里想过用markdown-it来解析Markdown,也想过用chokidar来做文件监听,但为了守住“零依赖”这条线,最后都改成了自己动手写小工具。这个过程意外地有趣,也让我重新审视了很多平时随手就装、其实并不理解内部原理的包。
1.3 技术选型的三个决策
在确定实现方案时,我做了三个关键决策,这里把背后的思考也一并讲清楚,方便你复刻的时候理解为什么这么做。
第一个决策是用Node.js而不用Python或Go。原因很实际:对于绝大多数前端开发者、内容创作者或小团队来说,Node环境几乎是公理级的存在,你不需要额外安装运行时。而且JavaScript在处理字符串模板拼接时有天然优势,写起来很顺手,不需要引库就能完成大部分工作。Python也可以,但目标人群可能就要窄一点。
第二个决策是不做成npm包,就做成单文件脚本。做成npm包意味着你要处理依赖发布、版本更新、issue反馈,这对一个极简项目来说是巨大的维护负担。单文件脚本配合简单的命令调用,反而更符合“原始人”的精神——工具不是拿来分发的,是拿来用的。你要用到其他机器上,scp一个文件过去就行了,连环境安装都省了。
第三个决策是做静态站点生成器(SSG)而不再碰SPA。静态页面部署极其简单,扔到任意Nginx、CDN或对象存储上就能跑,天然具备SEO优势,而且因为产物是纯HTML,没有运行时框架,连浏览器兼容性的问题都少了一大半。像个人博客、产品文档、落地页这种内容型站点,静态生成是最合适的技术方案。
2. 核心设计:一百多行代码如何完成构建
2.1 目录设计与内容源格式
caveman的项目结构非常直白,不搞花活。整个站点就四个目录:
site/ ├── pages/ # 源文件(.md) ├── templates/ # 页面骨架(HTML模板) ├── public/ # 静态资源(CSS/JS/图片) └── dist/ # 构建产物(生成后自动创建)pages目录下直接放Markdown文件,每个文件代表一个页面。文件顶部用简单的键值对写元信息(front matter),后面是正文内容。我特意不用YAML这种需要引入解析库的格式,而是用最朴素的“key: value”两行,解析它就是一个split(':')的事情。
title: 首页 nav: true --- # 欢迎来到我的洞穴 这里是正文内容……为什么用文件而不是数据库存内容?因为对于一个内容量级在几十到几百的静态站点来说,文件本身就是最好的存储介质。文件可以Git版本管理,可以diff查看变更,可以用任意编辑器修改,甚至可以直接在GitHub网页上编辑。数据库带来的查询能力在这里毫无价值,反而增加了备份和迁移的负担。
2.2 自制Markdown渲染器的取舍
这是很多朋友最感兴趣的部分:不用依赖包,Markdown怎么解析?
我的答案很直接:自己写一个支持常用语法的迷你渲染器。这里说的“常用语法”明确锁定在:标题、段落、无序列表、代码块、内联链接、加粗、斜体和图片。这几样覆盖了我日常写作99%的场景。
function renderMarkdown(md) { // 先转义HTML特殊字符,防止XSS const escaped = md.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); const lines = escaped.split('\n'); let html = ''; let inCode = false; for (const line of lines) { const trimmed = line.trim(); if (trimmed.startsWith('```')) { html += inCode ? '</code></pre>' : '<pre><code class="code-block">'; inCode = !inCode; continue; } if (inCode) { html += line + '\n'; continue; } if (trimmed.startsWith('### ')) html += '<h3>' + inline(trimmed.slice(4)) + '</h3>'; else if (trimmed.startsWith('## ')) html += '<h2>' + inline(trimmed.slice(3)) + '</h2>'; else if (trimmed.startsWith('# ')) html += '<h1>' + inline(trimmed.slice(2)) + '</h1>'; else if (trimmed.startsWith('- ')) html += '<li>' + inline(trimmed.slice(2)) + '</li>'; else if (trimmed === '') html += '<br>'; else html += '<p>' + inline(trimmed) + '</p>'; } // 简单将连续的li包进ul return html.replace(/(<li>.*<\/li>)\n?(?!<li>)/gs, '<ul>$1</ul>'); } function inline(text) { // 处理 [文字](链接)、**加粗**、*斜体* return text .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>') .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>') .replace(/\*([^*]+)\*/g, '<em>$1</em>'); }这段代码看着简陋,但运行起来非常稳。它不做AST(抽象语法树),不搞复杂的状态机,只是逐行扫描加几个正则替换。这就是“原始人”式实现的典型风格:路径最短,逻辑最透明,出问题一眼能定位。
当然,这个渲染器有明确的边界。表格、引用块、任务列表、嵌套列表这类复杂语法都不支持。遇到这些需求时我的妥协方案是:在Markdown文件中直接嵌入原生HTML,因为我的渲染器会对非空行原样输出HTML,所以可以无缝混排。实战中这个方法意外地好用,毕竟需要复杂布局时直接用HTML,比强行用Markdown表达要省心得多。
2.3 模板替换与批量生成
构建的核心逻辑极其直白:把每个Markdown文件渲染成HTML片段,然后塞进模板的指定位置。
模板本身就是一个普通的HTML文件,里面用双大括号约定占位符:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>{{title}}</title> <link rel="stylesheet" href="/style.css"> {{headExtra}} </head> <body> <header class="site-header"> <a href="/" class="logo">Caveman Site</a> <nav class="site-nav">{{nav}}</nav> </header> <main class="content">{{content}}</main> <footer class="site-footer">Powered by caveman</footer> </body> </html>构建脚本做的事情用一句话就能说明白:读取模板字符串,把渲染后的正文和从front matter中提取的标题、导航等元数据,用字符串replace替换掉占位符,然后把结果写入dist目录对应的路径。
导航也是自动生成的。构建时会扫描所有pages目录下的文件,读取它们的title字段和nav标记,生成一组相对路径的链接。这里用相对路径而不是绝对路径,是为了让站点可以直接放在子目录下而不出问题。
性能实测给个参考数:在我那台三年前的笔记本上,构建一个包含30个Markdown文件的站点,耗时稳定在150毫秒以内,产物总大小不到120KB。这个数据是那些动辄需要几秒构建时间的重型框架很难比的,因为caveman做的事确实少——但它已经把该做的做好了。
3. 实操过程:从零搭一个自己的caveman站点
3.1 初始化与第一个页面
不管你是想复刻整个项目,还是纯粹想体验一下这种极简构建方式,跟着这个流程操作就行。前提是你机器上有Node.js,任何不低于12的版本都行,因为整个脚本只用到了fs、path、http这几个非常稳定的内置模块。
第一步是手动创建目录结构,不需要任何初始化命令:
mkdir -p caveman-site/{pages,templates,public} cd caveman-site接下来创建模板文件templates/default.html,把上面那段HTML模板粘进去。再创建一个最简单的CSS文件public/style.css,内容随意,哪怕一行body { font-family: sans-serif; }都可以。然后写第一篇Markdown源文件pages/index.md:
title: 首页 nav: 首页 --- # 欢迎 这是一个用caveman生成的页面。3.2 编写构建脚本与批量生成
接下来是主角:项目根目录下的index.js。为了让逻辑清晰,我把它拆成三个函数:readMetaAndMarkdown负责读取文件并分离元信息与正文,renderMarkdown负责转换内容,buildAll负责遍历目录并输出文件。
const fs = require('fs'); const path = require('path'); const ROOT = __dirname; const PAGES = path.join(ROOT, 'pages'); const TEMPLATE = path.join(ROOT, 'templates', 'default.html'); const PUBLIC = path.join(ROOT, 'public'); const DIST = path.join(ROOT, 'dist'); // 读取文件,返回 { title, nav, content } function readPage(file) { const raw = fs.readFileSync(file, 'utf8'); const lines = raw.split('\n'); const meta = {}; let bodyStart = 0; for (let i = 0; i < lines.length; i++) { const m = lines[i].match(/^(\w+):\s*(.*)$/); if (m) meta[m[1]] = m[2].trim(); else { bodyStart = i; break; } } return { title: meta.title || 'Untitled', nav: meta.nav || meta.title || 'Untitled', content: lines.slice(bodyStart).join('\n') }; } function buildAll() { if (fs.existsSync(DIST)) fs.rmSync(DIST, { recursive: true }); fs.mkdirSync(DIST, { recursive: true }); const template = fs.readFileSync(TEMPLATE, 'utf8'); const files = fs.readdirSync(PAGES).filter(f => f.endsWith('.md')); const pages = files.map(f => readPage(path.join(PAGES, f))); const navHtml = pages .filter(p => p.nav !== 'false') .map(p => `<a href="/${path.basename(files[pages.indexOf(p)], '.md')}.html">${p.nav}</a>`) .join(' '); files.forEach((f, i) => { const page = pages[i]; const html = template .replace(/{{title}}/g, page.title) .replace(/{{nav}}/g, navHtml) .replace(/{{content}}/g, renderMarkdown(page.content)); const outName = f.replace(/\.md$/, '.html'); fs.writeFileSync(path.join(DIST, outName), html); }); fs.cpSync(PUBLIC, DIST, { recursive: true }); console.log('build done in', Date.now() - start, 'ms'); } buildAll();运行node index.js后,dist目录里就会生成完整的站点。用任意静态服务器指向dist就能访问。没有安装任何服务器工具的话,Node本身就有一次性静态服务器能力,这点放在下面讲。
3.3 加一个本地开发服务器与热更新
开发时总不能每次都手动跑构建再刷新浏览器,所以我顺手在脚本里加了一个100多行的开发服务器。原理很简单:启动一个Node内置的HTTP服务,收到请求后查找dist目录中对应的文件并返回内容。这样就省掉了安装http-server或live-server这类全局工具的需求。
更关键的热更新功能,我用了两种方式组合。第一种是fs.watch监听pages和public目录,任何文件变化都会触发重新构建。第二种是在模板中注入一段轮询脚本,每隔600毫秒向服务器发送一个HEAD请求,如果服务器返回的Last-Modified时间变化了,就触发页面刷新。
// 模板中注入的自动刷新脚本(通过replace写入) const refreshScript = ` <script> setInterval(() => { fetch(location.href, { method: 'HEAD', cache: 'no-store' }) .then(r => { const newTime = r.headers.get('Last-Modified'); if (window.__lastTime && newTime !== window.__lastTime) location.reload(); window.__lastTime = newTime; }); }, 600); </script> `;为什么不用WebSocket?因为WebSocket需要独立的握手协议管理,服务端代码会增加不少,而轮询方案简单可靠,对一个静态页面的刷新需求来说完全够用。再说了,这本来就是开发辅助功能,保持“原始”反而让代码更易维护。
3.4 发布与自动化部署
静态站点的部署是我见过最省心的环节。构建完成后,把dist目录整个扔到任意静态托管平台或服务器上就能跑。
如果你有自己的VPS,用rsync是最直接的方式:
rsync -avz --delete dist/ user@your-server:/var/www/caveman/--delete参数保证远端多余的文件会被清理,避免旧页面残留。如果是托管平台,直接把dist文件夹拖上去就行。由于产物是纯静态页面,没有服务端逻辑,也没有需要编译的依赖,所以CDN、对象存储、GitHub Pages这类方式都畅通无阻。
4. 使用中的坑与排查技巧
4.1 UTF-8编码与BOM问题
这是我在实际使用中踩到的第一个坑。在Windows上用记事本编辑了pages下的Markdown文件后,构建出来的页面开头会莫名其妙多出三个不可见字符(),浏览器解析时偶尔会触发奇怪的样式错位。这个问题的根源是Windows记事本保存文件时默认会在文件头部添加UTF-8 BOM标识。
排查方法很直接:用hexdump查看源文件头部是不是EF BB BF。也可以在构建脚本里做防御:
function readFileWithoutBom(file) { let raw = fs.readFileSync(file, 'utf8'); if (raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1); return raw; }更根本的解决办法是让团队编辑器统一使用“UTF-8无BOM”格式。VS Code默认就是无BOM,但Windows记事本和部分老式编辑器需要手动调整。
4.2 路径分隔符的跨平台问题
开发过程中我还在macOS上跑过一次构建,结果路径拼接时抛了个奇怪的错误。排查后发现,我在构造输出文件名时用了'/' + file这种硬编码分隔符,在Windows上没问题,但在Linux和macOS上反斜杠直接变成了非法字符。
解决方法是坚决杜绝手动拼接路径分隔符,一律用path.join或path.resolve。正则匹配路径时也统一用正斜杠,避免混淆。
4.3 增量构建与“幽灵页面”
最初的构建脚本在每次构建时不会清理dist目录。随之而来的问题是:如果你删除了pages下的某个Markdown源文件,重新构建后,dist里对应的HTML却依然存在——我把这种现象称为“幽灵页面”。它会持续给访问者展示早已下架的内容,非常误导人。
解决方案很简单:每次构建前先清空dist目录,就是从源头保证产物和源文件严格一一对应。虽然牺牲了一点增量构建的速度,但caveman本身的构建速度足够快,全量重建也不过几百毫秒,这种牺牲完全可接受。
还有一个小坑是fs.watch在文件保存过程中可能触发多次事件,导致短时间内的多次重建。我的缓解方案是加一个简单的debounce逻辑:记录最后一次触发时间,如果两次间隔小于50毫秒就忽略后一次。
4.4 部署目录与链接路径不一致
有朋友把caveman生成的站点放到服务器子目录里访问,结果发现CSS全挂、导航链接全部404。原因在于模板里写死了/style.css这种以根路径开头的绝对路径。站点部署在根目录时一切正常,但一旦整体挪到/blog/下,这些链接就会直接指向域名根目录去。
解决方式是在构建时增加一个base变量,然后模板中用{{base}}/style.css的方式生成路径。构建时传入站点部署实际路径即可。这算是我在真实部署中踩过的一个很典型的静态站点的坑,写在这里供参考。
下面是一份简明的排查速查表:
| 症状 | 可能原因 | 处理方式 |
|---|---|---|
| 页面乱码 | 文件不是UTF-8 | 统一用UTF-8无BOM保存 |
| 开头有异常字符 | Windows BOM | 构建时剥离BOM |
| Linux/macOS路径错误 | 硬编码分隔符 | 使用path模块统一拼接 |
| 删除页面后dist还有旧文件 | 未清理产物 | 构建前删掉dist |
| 子目录部署样式全挂 | 绝对路径缺少base | 引入base变量动态拼接 |
| 开发时页面不自动刷新 | watch触发多次/轮询失效 | 加debounce或者检查Last-Modified |
5. 关键经验与后续扩展方向
5.1 我在实际使用中的三个体会
用caveman管理这个站点跑了大约三个月,有几个体会我觉得值得单独拿出来说说。
第一个体会是简单工具不等于简陋工具。简单是指架构和依赖层面足够克制,但功能层面可以围绕核心需求不断打磨。我后来也为caveman添加了RSS生成、站点地图生成、代码高亮注入这些功能,但都是一个个独立的小函数,加起来也就多一百来行代码,整体复杂度依然在一个很舒服的范围。
第二个体会是低维护成本带来的是长期的安心感。用重型框架时代我总担心依赖升级、漏洞修复、构建器兼容性问题,现在三个月没有更新过任何依赖,因为根本没有任何依赖。这种“不需要时刻惦记它”的感觉,对个人项目来说是极大的心态优势。
第三个体会是明白工具边界比追求全能更重要。caveman适合的就是内容型静态站点,你非要让它在无头CMS、复杂交互、动态路由、权限系统这些方向上发力,那不叫完善,那叫重新发明Fortran。工具的边界清晰,选择它的人才不容易做出错误的判断。
5.2 这个项目还能怎么扩展
如果你也想动手做一个类似“原始人”风格的工具,这里给出几个我验证过确实有价值的扩展方向:
RSS与sitemap自动生成。内容型站点几乎必备。实现方式很简单:构建时解析每篇文件的front matter中的日期字段,按时间排序后生成一个XML文件。RSS的XML格式并不复杂,拼字符串大概30行就能搞定,sitemap更是20行以内的事。
文章标签系统。在front matter里增加tags: a, b, c,构建时扫描所有文件,为每个标签生成一个列表页面。这个功能大概需要额外40行代码。
自定义短代码。比如在Markdown里写{{youtube abc123}},渲染时替换成对应的嵌入HTML。这可以让你的内容源保持轻量,同时支持更多媒体类型。
增量构建。用每个源文件的mtime对比dist产物是否存在且更新,来决定是否重建该页面。这个优化能把构建速度再提升一个量级,但说实话,在目前的速度下并不是刚需。
我个人目前最想加的是图片自动压缩功能,毕竟站点体积的主要贡献者往往是未优化的照片。不过这个方向涉及二进制文件处理,以“零依赖”原则来做会有些绕,我还在权衡是否值得打破这个规矩。
最后再分享一个小技巧:如果你把源文件的日期直接写进front matter,配合构建脚本自动生成存档页,你就能得到一个完全不需要数据库、不需要后台、甚至不需要CMS的博客系统。内容还是那些Markdown文件,但访问者看到的,已经是一个结构完整、自带导航和归档的正式站点。这种“用手边最简单的材料做出一套可靠方案”的感觉,大概就是caveman最迷人的地方吧。