
最近我给自己挖了一个很有意思的坑想好好写博客结果先花了一个周末给博客造了一个“驱动”。这个驱动叫caveman一个我完全从零手写的极简静态网站生成器。名字起得确实有点随性——因为它的实现思路实在太过“原始”没有数据库、没有构建缓存、没有插件生态连模板引擎都是我手搓的字符串替换。但真正跑起来之后我反而觉得这种“原始”恰恰是它最大的价值整个项目只有一个几百行的构建脚本部署到任何服务器都是秒开维护成本趋近于零。这篇文章不打算讲什么高深的理论就想把我从想法、设计、写代码、踩坑到最后上线的全过程原原本本摊开给你看。如果你也想搭一个自己的博客、小网站或者纯粹对“网站是怎么生成出来的”感兴趣那这篇文章应该能给你一个完全不同的角度——一个原始人视角的最小可行方案。1. 项目背景与设计定位1.1 市面上的静态生成器哪里让我不舒服我从大二就开始鼓捣个人博客前前后后用过不少静态站点生成器。Hexo 是入门首选插件多、主题多但等你想换主题或者跨版本升级的时候那种依赖树崩掉一半的酸爽折腾过的人都懂。后来换到 Hugo单二进制、编译飞快很香但它的模板语法和配置体系真不是两天能摸熟的我光是记住那些.Param、.Scratch、range的写法就差点放弃了。再之后是 Jekyll虽然没有编译黑魔法但一套 Ruby 环境装完你感觉自己在维护的不是博客而是 Gem 依赖。说实话这些工具本身都不差。问题在于我只是想写文章、偶尔改改样式却被迫维护一套远比内容本身复杂的工程体系。就像一个原始人只是想烤个肉你递给他一台带分子料理功能的专业烤箱这真的合理吗1.2 我和 caveman 立下的三条军规我的需求其实非常朴素把 Markdown 文件变成 HTML 文件仅此而已。顺着这个需求我给 caveman 定下了三条硬性规则也是整个项目的“宪法”零运行时依赖除了 Node.js 本体不引入任何 npm 包。所有功能自己写哪怕丑一点、糙一点也要保证没有供应链风险。一个命令完成构建node build.js之后当前目录下直接会出现一个完整的public站点目录。不需要配置脚本、不需要环境变量、不需要.env文件。内容就是文件所有文章、页面都是 Markdown 文件文件名就是 URL 路径没有数据库、没有 CMS、没有后台删一个文件就等于下架一篇文章。这几条规则之后帮了我大忙。因为约束足够简单后面几乎每一个设计决策都不需要纠结答案非常明确。1.3 这不是“又造一个轮子”而是为了弄懂轮子有人可能会说你这不是重复造轮子吗是的我承认。但造这个轮子的目的不是想跟 Hugo、Hexo 抢用户而是想彻底搞明白一个静态网站生成的底层逻辑到底是怎么运作的。当你真正从零写过一个 markdown 解析器、一个模板渲染器之后再看那些框架的文档理解完全不是一个量级的。更重要的是这个工具完全归我控制我想加什么功能就加什么功能没有任何人逼我升级。2. 技术选型与整体架构2.1 为什么是 Node.js而且零依赖caveman 我选定了 Node.js。原因有三。第一它不需要编译写完直接node build.js就能跑迭代速度快特别适合这种一个人折腾的小项目。第二大部分做技术博客的人机器上都有 Node.js 环境将来你想 fork 自己改不会卡在环境搭建这一步。第三Node 的fs、path模块已经足够强大处理文件扫描、目录创建这些事情非常顺手。至于零依赖这个决定一开始很多人不理解。写 Markdown 解析直接用marked不香吗生成模板用ejs不省事吗确实如果目标是“最快做出一个静态生成器”那我应该全用现成的。但我的目标还包括“完全掌控代码逻辑”和“长期不维护也不会烂掉”。引入十来个依赖半年回来一看全是安全漏洞报告和 breaking change那才是最麻烦的。所以 caveman 的全部功能都是我用原生 JavaScript 和 Node.js 内置模块实现的。2.2 目录结构与数据流设计caveman 的工作目录设计得非常直白整个项目长这样caveman/ ├── pages/ # 顶层页面比如 index.md、about.md ├── posts/ # 文章目录每篇一个 Markdown 文件 ├── templates/ # HTML 模板文件 ├── public/ # 构建产物生成的网站就在这里 └── build.js # 核心构建脚本所有逻辑都在这一个文件里数据流的核心逻辑可以概括为一句话扫描文件 → 解析内容 → 渲染模板 → 写出 HTML。其中扫描阶段要把posts/下所有.md文件读出来顺便按文件名提取日期、标签这些元信息解析阶段负责把 Markdown 转成 HTML 片段渲染阶段再把 HTML 片段塞进模板生成最终的页面文件最后写盘。整个过程10毫秒级完全没有性能压力。2.3 核心模块的职责划分虽然只有一个文件但我还是把代码分成了三个逻辑模块各自干各自的活互不干扰content-loader负责读取pages/和posts/得到一个内容对象数组每个对象包含title、date、body、url等字段。markdown-renderer把 Markdown 文本转换成 HTML。这个模块独立写后期想换解析逻辑只动它就行。site-builder负责加载模板、渲染页面、生成归档列表、创建目录并写文件。这三层各自独立之后我后面加“标签页”、“分页”等功能时基本只改site-builder其他模块几乎不受影响。3. 核心实现解析从零搭出构建器3.1 Markdown 解析写一个够用的迷你渲染器Markdown 解析是整个项目中我花时间最多的部分。完整实现当然很难但我的需求其实有限平时写文章用到的语法无非是标题、段落、列表、粗体、斜体、链接、图片、行内代码和代码块。于是我决定自己写一个只支持这些语法的最小解析器。核心思路是分块处理。先把文本按空行拆成“块”然后逐块判断它是什么类型再分别转换。比如遇到以#开头的行就转成h1遇到以-开头的连续行就包成ul遇到连续缩进的行或者 包裹的内容就保持原样放进precode里。function renderMarkdown(md) { const blocks md.split(/\n\s*\n/); let html ; for (const block of blocks) { if (block.startsWith(# )) { html h1${renderInline(block.slice(2))}/h1\n; } else if (block.startsWith(## )) { html h2${renderInline(block.slice(3))}/h2\n; } else if (block.startsWith(- )) { const items block.split(\n).map(l l.replace(/^- /, ).trim()); html ul${items.map(it li${renderInline(it)}/li).join()}/ul\n; } else if (block.startsWith()) { html precode${escapeHtml(block.replace(//g, ).trim())}/code/pre\n; } else { html p${renderInline(block)}/p\n; } } return html; }块级处理之后还需要一个处理行内元素的小函数renderInline负责把code、**bold**、*italic*、[text](url)这些行内语法转成对应标签。顺序很关键一定要先处理行内代码再处理链接和加粗否则反引号里的内容会被其他规则误伤。这个迷你解析器当然不完美嵌套列表、任务清单、表格它都搞不定。但它覆盖了我 95% 以上的写作场景而且代码就70多行出问题能一眼看穿。我把完整代码保持在renderMarkdown.js中之后想扩展语法只需要在分块那段加一个else if分支。3.2 模板填充乱中有序的占位符方案模板系统我一开始差点引入现成引擎后来想明白了一个道理一个博客网站其实只有两种页面类型。一种是文章页需要把正文渲染进一个带侧边栏的模板另一种是列表页和首页需要把文章标题列表循环渲染出来。所以模板系统只需要解决两件事变量替换和循环列表。变量替换最简单用正则把{{title}}、{{content}}这种占位符替换成真实内容。循环列表稍微复杂一点因为我需要支持像“把文章标题循环生成成一组li”的操作。我的办法是让把循环逻辑写在 build 脚本里先把每篇文章的标题和链接渲染成一段连续的 HTML 字符串然后把整段字符串作为一个变量塞进模板。const articleItems posts.map(p { return lia href${p.url}${p.title}/aspan${p.date}/span/li; }).join(); const pageHtml template .replace({{site_title}}, Caveman 部落) .replace({{article_list}}, articleItems) .replace({{content}}, renderedPost);这个方案初看很“土”但有它的优势模板文件里只有四种占位符{{site_title}}、{{content}}、{{article_list}}、{{nav}}任何人打开模板猜一猜就知道是干什么的。没有复杂的继承、没有过滤器、没有嵌套逻辑完全符合 caveman“原始人”的设定。3.3 列表页与归档页的生成逻辑博客不能只有文章页还得有个列表页让大家能一眼看到所有文章。我的实现是构建时从posts/目录读取所有 Markdown 文件提取每个文件的标题和发布日期然后按日期倒序排好生成一个归档列表。这一步用 Node 的fs.readdirSync加一个简单的排序函数就够了。const files fs.readdirSync(./posts).filter(f f.endsWith(.md)); const posts files.map((file, idx) { const content fs.readFileSync(./posts/${file}, utf8); const title content.match(/^# (.)$/m)?.[1] || file.replace(.md, ); const date content.match(/^date: (.)$/m)?.[1] || 2025-01-01; const url ${file.replace(.md, .html)}; return { title, date, url, idx }; }).sort((a, b) b.date.localeCompare(a.date));我顺手还实现了“最新文章”和“文章总数”两个辅助变量这样首页模板里可以显示“目前共12篇文章最近更新于某篇文章”之类的信息。整个过程最深的感触是列表页的本质不是魔法就是对内容数组做一次排序和格式化输出。想通了这一点后面加标签页、分类页都是同一套路。4. 踩坑记录与排查技巧4.1 中文文件名与 URL 编码第一个坑来自中文文件名。我习惯把文章命名成从零搭建博客系统.md但在生成链接时直接用文件名拼 URL在浏览器里点击就会得到一个带着未编码中文字的地址。虽然现代浏览器能自动编码但在一些旧移动端和研究型浏览器上还是会出现404。解决方法是在生成url字段时对文件名做一次编码处理const encodedUrl file.replace(.md, .html) // url先保持原始然后在写盘时把中文文件名原样存成 HTML 文件而在 HTML 里的链接对href属性中的文件名进行encodeURI编码。这样磁盘上的文件名和 URL 都正确浏览器点击时不会乱码。这里有一个很重要的点需要特别强调涉及到中文文件名时宁可多写一行 encodeURI也不要依赖浏览器自动修正。4.2 代码块里的反引号和尖括号第二个坑非常经典当文章里的代码块中包含 HTML 标签时我在 Markdown 解析阶段如果不做转义浏览器会直接把标签当成真实 DOM 渲染。最开始我的代码块处理是直接把内容原样塞进precode结果写一篇讲前端div的文章渲染出来一片空白控制台还报错。修复方案是在进入pre之前先把、、三个字符做 HTML 实体转义。这个转义函数放到工具函数里行内代码也要用。代码如下function escapeHtml(str) { return str.replace(//g, amp;).replace(//g, lt;).replace(//g, gt;); }做这个改完以后我特意写了几篇包含{{ }}和 HTML tag 的文章来测试确认都没有问题。这个坑是每个手写 Markdown 渲染器的人都会遇到的拦路虎新手在这里栽跟头太正常了记住先转义再输出就行。4.3 Windows 与 Linux 下的路径分割符差异我平时在 Windows 上写文章最后部署到 Linux 服务器。刚开始整合脚本时路径拼接我直接用了反斜杠\在 Windows 本地一切正常可一放到 Linux 上用path.join连接符不一致就会产生public/posts\xxx.html这种带着混杂斜杠的目录结构访问时直接404。解决方式很简单凡是路径拼接一律用 Node 自带的path.join或path.posix.join不要自己拼字符串。如果要在 URL 中使用路径更保险的做法是导入path模块的统一处理再对所有输出路径先做一次replace(/\\/g, /)。这个小坑我一度以为是自己写博客生成器写出了幻觉后来才意识到是平台差异在作怪。这里补充一个排查技巧构建完成后直接在public/目录下用find . -type f看一眼所有文件路径如果发现路径里有反斜杠那肯定是拼接逻辑出了平台兼容问题。4.4 后来我补上的三个小功能第一版 caveman 只支持文章和页面真正写了半个月文章之后我发现几个痛点陆续补了三个功能RSS 订阅文件在构建时顺手生成一个feed.xml用一段模板把所有文章标题和链接输出成 RSS 格式。置顶文章机制在 Markdown 文件的头部加一行top: true构建时筛选出来排在最前面。分页列表当文章超过10篇后自动把列表页拆成index.html、page/2.html、page/3.html每页10篇。这三个功能加起来总共又加了大约80行代码。因为架构里列表渲染是独立模块所以分页实现就是posts.slice((pageNum - 1) * pageSize, pageNum * pageSize)一个小循环非常省事。5. 部署上线与实际使用体验5.1 构建物到底该怎么部署caveman 构建生成的public/目录就是一份没有任何动态逻辑的纯静态文件。部署方式可以有很多种我目前用了最省事的一种把public/目录通过scp直接扔到云服务器上配置好 Nginx 指向该目录并开启 gzip 缓存。另外我也试过把public/推送到对象存储服务配一个网页托管也能达到类似效果。由于产物里没有任何服务器端脚本安全性上天然比动态站省心悸。Nginx 配置只做了两件小事一是把所有不含扩展名的路径尝试加.html后缀用来支持在地址栏不带.html的干净链接二是对 CSS、图片这类静态资源设置较长的浏览器缓存时间。整体配置量不到十行。5.2 从 300 行到 200 行一次自我审视的重构跑了一个半月之后我做了第一次重构。当时统计加上 RSS 和分页功能后build.js已经膨胀到 300 多行。我意识到这个规模还在可控范围内但已经不那么“原始”了。于是我做了一次极简主义的外科手术把重复的fs.writeFileSync调封装成一个writePage(url, html)函数把模板加载统一到一个loadTemplate(name)函数里。重构完核心脚本降到 200 行左右但功能一个不少。这次重构让我体会到极简不是不做设计而是做那种一眼能看到全部脉络的设计。当你的工具足够简单你会更愿意去维护它当你更愿意维护它这个工具就能跟你走得很远而不是变成一年后都懒得打开的旧项目。5.3 适用边界什么时候你可以照抄作业如果你也是在 Plan A 里写着“静态博客”四个字那 caveman 这套思路很适合你够用、清晰、改得动。但如果你需要评论系统、多用户协作、可视化编辑、主题市场这些能力那它显然不再适用老老实实去选更重的方案就好。这也是我特别想强调的任何工具都有它的边界极简工具不是万能的但在它的能力范围内它能给你一种别人没有的清爽感。最后再说一个我个人的真实体会自从用了 caveman我写博客的频率反而变高了。原因很玄学——因为部署这件事变得毫无压力我写一篇文章跑一下命令传一下文件整体不到一分钟。没有了“又要维护博客框架”的心理负担写作这件事就纯粹多了。如果你也觉得自己正在被博客工具消耗热情不妨也动手试试这种“原始”的极简玩法说不定会重新找回写东西的快乐。