手搓一个零依赖的 Markdown 静态站点生成器,我学到了什么
不用框架,不用构建工具链,只用 Node.js 标准库和一个 marked.js。
为什么不用现成方案
Hexo 太重------npm install 能装 200+ 个包。Hugo 要装二进制。Gatsby 要学 GraphQL。我想要的是:
- 零依赖(除了一个 Markdown 解析器)
- 完全控制构建流程
- 能部署到任意静态托管
- 子路径自动适应
所以我决定自己写一个。不是为了替代它们,是为了完全理解构建流程的每一步。
一、Markdown 解析:选 marked 但 vendor 本地化
解析 Markdown 有两个选择:自己写 parser,或者用现成库。
自己写 parser 能做到零依赖,但 Markdown 语法比想象的复杂------GFM 表格、围栏代码块、任务列表、自动链接,每种都要处理边界情况。投入产出比不高。
我选了 marked.js(~35KB),但不通过 npm install。直接把 marked.min.js 放到 vendor/ 目录,用 require() 加载:
javascript
const { marked } = require('./vendor/marked.min.js');
function renderMarkdown(body) {
return marked(body, { gfm: true, breaks: true });
}
好处:构建时零网络依赖,不进 node_modules,不污染全局。
一个细节:breaks: true 让单个换行变成 <br>,对中文写作更友好(中文习惯每段一句一行)。
二、目录扫描:glob + frontmatter 解析
文章放在 content/posts/<分类>/ 目录下。构建时扫描所有 .md 文件,解析 YAML frontmatter 提取元数据。
frontmatter 解析不引入 js-yaml------自己写了一个简单的行扫描器:
javascript
function parseFrontMatter(text) {
const match = text.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
if (!match) return { meta: {}, body: text };
const meta = parseYaml(match[1]); // 自写的 YAML 解析器,约 80 行
return { meta, body: match[2] };
}
YAML 解析器只支持子集:标量、数组、嵌套对象、块标量(|)。不支持流映射、锚点、别名。约 80 行代码,够用就行。
增量构建的判断逻辑:比较文件的 mtime 和上次构建的 manifest。mtime 没变就跳过。简单但有效。
三、HTML 生成:不用模板引擎
我试过 ejs、pug、handlebars,最后决定不用任何模板引擎。
原因:模板引擎引入的复杂度(语法、转义、作用域)远超它带来的好处。HTML 模板本身就是可读的,直接用字符串替换就够了:
javascript
function renderPage(template, data) {
let html = template;
html = html.replace('{{title}}', escapeHtml(data.title));
html = html.replace('{{content}}', data.content);
return html;
}
布局嵌套用文件包含:base.html 包含 post.html,post.html 包含文章内容。构建时按顺序拼接。
一个坑:字符串替换要注意转义。用户输入的 <script> 标签如果没转义,构建产物就有 XSS 风险。我用了 escapeHtml() 函数处理所有动态内容。
四、CSS Token 系统:不用预处理器
不用 Sass、Less、PostCSS。直接用 CSS 自定义属性(--variable)。
css
:root {
--bg-primary: #0b0d12;
--text-primary: #f5f7fb;
--accent: #6f7cff;
--font-display: 'Inter', sans-serif;
--layout-width: 1120px;
}
主题切换就是覆盖这些变量:
css
/* paper 主题 */
:root {
--bg-primary: #faf6f0;
--text-primary: #2c2420;
--accent: #2563eb;
--font-display: 'Caveat', cursive;
}
好处:
- 浏览器原生支持,无需编译
- 运行时可切换(
document.documentElement.style.setProperty) - 主题作者只需覆盖变量,不需要理解组件结构
- 不用 CSS-in-JS 的原因更简单:运行时注入
<style>标签违背零依赖目标,且 SSR 场景下会有闪屏问题
一个设计决策:所有组件样式用 var() 引用变量,硬编码值只出现在 :root 中。这样主题只需要覆盖 :root 就能改变全局视觉。
五、搜索索引:lunr.js + CJK 分词
搜索有两种方案:服务端搜索(需要后端)和客户端搜索(纯静态)。
我选了客户端搜索,用 lunr.js(~29KB)。问题是 lunr.js 不支持中文分词------它按空格分词,中文会变成一整块。
解决方案:构建时预分词。把中文文本按字拆分,用空格连接,让 lunr.js 能索引:
javascript
function tokenizeCJK(text) {
return text.replace(/([\u4e00-\u9fff])/g, '$1 ');
}
按字拆分的代价是精确率下降("中国人"搜"中国"匹配不到),但召回率 100%,且 50KB 的索引文件对 100 篇文章来说,宁可多匹配也不要漏匹配。
构建时生成 search-index.json,前端加载后用 lunr.js 搜索。索引文件约 50KB/100 篇文章,首次加载后缓存。
为什么不用向量搜索?因为这是客户端方案,不需要服务端。向量搜索需要 Embedding API,引入了外部依赖。
六、踩过的坑
坑 1:Windows 路径
path.join() 在 Windows 上用反斜杠。但 HTML 中的 URL 必须用正斜杠。解决:所有输出路径统一用 .replace(/\\/g, '/')。
坑 2:Mermaid 渲染时序
Mermaid 图表需要在 DOM 加载后才能渲染。但 Markdown 构建时已经把代码块转成了 <pre><code>。解决:构建时保留原始代码,前端用 mermaid.render() 异步渲染。
坑 3:KaTeX 渲染时机
数学公式 $E=mc^2$ 在 Markdown 中可能和普通 $ 符号冲突。解决:构建时用正则提取公式,存入 data-math-inline 属性,前端用 KaTeX 渲染。
坑 4:热重载 SSE 实现
开发时需要文件变化后自动刷新。我用了 Server-Sent Events(SSE):
javascript
// 服务端
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
res.write('data: reload\n\n');
// 客户端
const es = new EventSource('/__reload');
es.onmessage = () => location.reload();
一个坑:SSE 连接在页面刷新时会断开,需要自动重连。EventSource 浏览器原生支持自动重连,但要设置 retry 间隔。
结尾
轮子造完了,能用。但我不建议每个人都造------除非你和我一样,想完全理解构建流程的每一步,或者你的需求确实特殊到现成方案满足不了。
如果你的目标只是写博客,Hugo 仍然是最高效的选择。如果你需要完全控制构建流程、或者像我一样想理解每一步发生了什么,手搓是你的选项。