墨线与电路 INK & WIRE

工程 · 2026.09.09

从零写一个静态站点生成器:为什么我放弃了框架

站点只有十几篇文章,却背着几十 MB 的 node_modules。花一个下午写了 300 行构建脚本,顺便想清楚了一件事:构建工具的复杂度应该和内容规模成正比。

陈墨 5 分钟阅读 #工程#构建#设计

这个博客最开始用的是某个流行框架。它很好,三个月后 node_modules 有 240MB,:hover 的颜色改了三层配置才生效,构建要 11 秒。而站点里只有十二篇文章。

问题不在于框架烂,而在于工具的成本应该跟内容规模匹配。十二篇文章配 240MB 依赖,像是用卡车送一封信。

一、先划清能力边界

动手之前列清楚「要什么」和「明确不要什么」,比选技术栈更重要。

要的:

  • Markdown → HTML,支持标题、列表、表格、代码块、引用。
  • 代码块带语言标签和复制按钮。
  • 自动生成目录、上一篇/下一篇。
  • 零运行时依赖,构建产物就是纯静态文件。

不要的:

  • 客户端 JS 框架。整站 JS 只有交互增强那 60 行。
  • 增量构建。全量构建 200ms,没有优化的必要。
  • 插件系统。只有一个人在写。
  • 图片处理流水线。图片提前压好就行。

这个「不要」清单让代码少了十倍。

二、Markdown 渲染的核心是一个状态机

不需要完整实现 CommonMark。够用的子集大约 200 行:按行扫描,识别块级元素,行内元素用正则做二次处理。

块级分派的主干:

js
function renderBlocks(lines, ctx) {
  let i = 0;
  const out = [];
  while (i < lines.length) {
    const line = lines[i];

    if (/^\s*$/.test(line)) { i++; continue; }

    // 围栏代码块:必须优先于其他规则
    const fence = line.match(/^\s*(`{3,}|~{3,})\s*([\w+#.-]*)\s*$/);
    if (fence) { /* 收集到闭合围栏 */ }

    // 标题
    const h = line.match(/^(#{1,6})\s+(.*)$/);
    if (h) { /* 生成带 id 的标题,并登记到目录 */ }

    // 其余:引用 / 列表 / 表格 / 段落
  }
  return out.join('\n');
}

两个容易做错的地方:

其一,代码块必须最先判断。 如果先匹配标题,代码块里的 # 注释 会被当成一级标题。同理,引用块里也可能有代码。

其二,行内解析要先保护代码。 反引号里的内容不能被加粗或链接规则改写,所以先把行内代码挖出来占位,处理完其他行内语法再填回去:

js
export function inline(text) {
  const store = [];
  const hold = (html) => { store.push(html); return `\u0000${store.length - 1}\u0000`; };

  let s = String(text);
  s = s.replace(/`([^`]+)`/g, (_, code) => hold(`<code>${escapeHtml(code)}</code>`));
  s = s.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (_, alt, src) => hold(...));
  s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, href) => hold(...));

  s = escapeHtml(s);                 // 到这里才转义
  s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
  s = s.replace(/\u0000(\d+)\u0000/g, (_, i) => store[Number(i)]);
  return s;
}

顺序是:挖出代码 → 挖出链接 → 整体转义 → 处理强调 → 填回。转义必须在挖出之后,否则已经生成的 <code> 标签会被二次转义成 &lt;code&gt;。

三、构建脚本长什么样

build.js 干四件事:读文件、解析 frontmatter、渲染、写盘。

js
async function build() {
  const files = (await fs.readdir(CONTENT)).filter((f) => f.endsWith('.md')).sort();
  const posts = [];

  for (const file of files) {
    const raw = await fs.readFile(path.join(CONTENT, file), 'utf8');
    const { data, body } = splitFrontmatter(raw);
    const { html, headings } = renderMarkdown(body, { toc: true });

    posts.push({
      slug: data.slug || slugify(file.replace(/\.md$/, '').replace(/^\d+-/, '')),
      title: data.title,
      summary: data.summary,
      tags: toTags(data.tags),
      date: parseDate(data.date),
      reading: estimateReading(body),
      html,
      headings,
    });
  }

  posts.sort((a, b) => (a.date.iso < b.date.iso ? 1 : -1));
  // ... 写 index / archive / about / 每篇文章
}

全局排序用字符串比较日期就够了,ISO 格式天然可比较。别在这里引入日期库。

页面的 HTML 拼接用一个返回字符串的 layout() 函数,通过参数传 title、active、body。模板就是字符串拼接加模板字面量——这是少数几个我觉得模板引擎完全没必要的地方。

四、为什么没有热更新

有热更新很爽,但它要求一个常驻 watcher、一套文件变更去重逻辑、以及「浏览器怎么知道刷新」的通信机制。对一个每天最多改一次内容的博客来说,这些都在为「我自己开发时的舒适度」买单。

替代方案是 30 行的 server.js,改完内容跑一次 npm run build,刷新页面。构建 200ms,比大多数热更新的连接建立还快。

js
// server.js —— 本地预览用,生产环境交给 Nginx
const server = http.createServer(async (req, res) => {
  let file = decodeURIComponent(new URL(req.url, 'http://x').pathname);
  if (file.endsWith('/')) file += 'index.html';
  const full = path.join(ROOT, path.normalize(file));
  if (!full.startsWith(ROOT)) return send(res, 403, 'Forbidden');
  // ...
});

注意 path.normalize 之后必须校验前缀,否则 ../../etc/passwd 会成功穿越。这个检查在任何静态服务器里都不能省。

五、样式:排版比装饰重要

整站只有一套 CSS、一个主题变量集。核心是字符宽度和行高的比例:

css
.prose {
  font-size: 1.02rem;
  line-height: 1.92;   /* 中文正文行高给足,1.9 左右最舒服 */
  max-width: 42rem;    /* 约 38~42 个汉字一行 */
}

中文长文的可读性主要靠三件事:行长控制在 40 字上下、行高 1.9 左右、段间距大于行距。这三条比任何配色都有效。

配色只用了两个语义色:墨色(正文)和朱红(强调)。强调色只出现在链接、行内代码、引用边线上,不参与装饰。

深色模式通过 data-theme 属性切换变量,不重写任何组件样式:

css
:root { --ink: #16161a; --paper: #fbfaf7; }
[data-theme="dark"] { --ink: #eceae3; --paper: #121211; }

因为所有颜色都从变量取值,切主题只换变量表就行。

六、结果与代价

最终:构建脚本 300 行,运行时代码 60 行,CSS 700 行。构建 200ms,产物 120KB(含字体不加载的情况下)。

放弃的东西:

  • 没有标签页、没有搜索。搜索可以后面用 Ctrl+F 顶着,真要加就写个 50 行的 JSON 索引。
  • 没有评论系统。想讨论的发邮件。
  • 没有自动图片优化。

这些不是投降,是排序。内容先于功能,功能后于复杂度。

七、什么情况下你该用框架

反过来说清楚:如果你有几百篇文章、多语言、需要标签/搜索/分页/SEO 静态化,或者团队里有多人要写,那么现成框架的复杂度就是值得的——它替你处理了这些边界情况。

判断标准不是「框架好不好」,而是:你拒绝它的理由,是不是「它做的事我确实不需要」。如果答案是「它做的事我不会写」,那还是老老实实用框架。

写这个生成器的真正收获,不是省了 240MB 依赖,而是终于能完整说出从一行 Markdown 到浏览器里一个 <p> 之间发生的每一件事。