最近我给自己挖了一个很有意思的坑:想好好写博客,结果先花了一个周末,给博客造了一个“驱动”。这个驱动叫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>;遇到连续缩进的行或者 ``` 包裹的内容,就保持原样放进<pre><code>里。
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 += `<pre><code>${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 `<li><a href="${p.url}">${p.title}</a><span>${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 渲染。最开始我的代码块处理是直接把内容原样塞进<pre><code>,结果写一篇讲前端<div>的文章,渲染出来一片空白,控制台还报错。
修复方案是在进入<pre>之前,先把&、<、>三个字符做 HTML 实体转义。这个转义函数放到工具函数里,行内代码也要用。代码如下:
function escapeHtml(str) { return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }做这个改完以后我特意写了几篇包含{{ }}和 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,我写博客的频率反而变高了。原因很玄学——因为部署这件事变得毫无压力,我写一篇文章,跑一下命令,传一下文件,整体不到一分钟。没有了“又要维护博客框架”的心理负担,写作这件事就纯粹多了。如果你也觉得自己正在被博客工具消耗热情,不妨也动手试试这种“原始”的极简玩法,说不定会重新找回写东西的快乐。