静态博客构建系统

摘要复盘 Vector 如何扫描内容、并发渲染、调度图片与生成索引,以及 fire-and-forget、缓存和插件抽象怎样从优化变成系统边界问题。

文章目录5 节
  1. 构建主链:Markdown 转中间产物
  2. 扩展阶段:Hook、资源调度与目录路由
  3. 性能剖析:索引、哈希与原生加速
  4. 可靠性复盘:完成信号为什么不可信
  5. 从框架退回单体构建器

页面仍保留着 2021 年的初始日期。2026 年重新整理时,我把后来在 2023 年做的 Vector 也补了进来,所以正文的项目时间会晚于页面日期。

我当时没有把博客生成器只当成一段“Markdown 转 HTML”的脚本,而是把它拆成一条内容构建流水线。这个博客用过的 MD-Render,内部代号 Vector,就是那次尝试。

16fed2c695.jpeg

当时已经有 Hexo、VuePress 和 Hugo,我还是想自己写,倒不是缺一个博客生成器。我真正想验证的是构建边界:底层只把 Markdown 编译成稳定的 JSON 中间产物,页面用 Vue 还是 React,由主题自己决定。这样主题变化不会反向污染内容处理管线。

Vector 从 2023 年 2 月写到 3 月,停在了一个能用的 MVP。现在回看,它既碰到了并发渲染、资源调度、缓存和完成信号这些真正的构建系统问题,也提前造了不少没有用户的抽象。

这篇里最容易混在一起的是「任务已经开始」和「主流程等它完成」。Vector 的 Scheduler 做到了前者,却没有在构建收尾时做后者。先跑一次不等待的模式,再勾上「等待全部输出任务」对比,后面几段源码会好读很多。

构建流水线 · 时序模拟

fire-and-forget 为什么会提前报成功

时间经过压缩;模拟的是返回边界,不是真实磁盘速度。

扫描 Markdown主流程等待
解析与渲染主流程等待
写入文章 JSON输出任务等待
复制与下载图片输出任务等待
合并搜索数据输出任务等待
构建函数尚未返回点击按钮开始模拟。

未勾选时,三个输出任务会开始运行,但主流程不会等它们。

构建主链:Markdown 转中间产物

最早的核心逻辑很薄:front-matter 读标题、时间等元数据,markdown-it 把正文渲染成 HTML。

const rawContent = fs.readFileSync(filePath, "utf8");
const { attributes, body } = fm(rawContent);
const html = md.render(rawContent);

return {
    title: attributes.title,
    content: html,
    word: body.length,
    // ...
};

一篇文章到这里就处理完了。不过这几行原始代码里已经有两个小问题:拆出了 body,渲染时却还是把带 front-matter 的 rawContent 交给 markdown-itbody.length 算的也是字符数,不是真正的单词数。它能跑,但我现在不会再原样抄走。

接下来的代码,基本都是为了批量文件、图片、目录和后续扩展长出来的。

文件之间没有依赖,所以没必要一个个等。setup 会先找到所有 Markdown,一次性发起渲染,最后用 Promise.all 收口:

export async function setup() {
    await initEngineRuntime();
    const config = getRuntime().getConfig();
    const markdownFiles = await getMarkdownFiles(config.dataDir);
    const jobs = markdownFiles.map(renderMarkdownFile);
    const hookObj = await Promise.all(jobs);
    executeHooks(hookObj);
}

同一个文件在流程里会被算 md5、读正文、解析 front-matter。我用 memoize 给每个路径绑一个 FileReader,后面谁来读都拿同一份结果:

export const getFileReader = memoize(
    (filePath: string) => new FileReader(filePath)
);

FileReader 构造时不碰磁盘,第一次调 .ready() 才真正读,同一路径后面不再重复读取。这个懒加载并不会跳过草稿:except 要到文件读完、front-matter 解析完才能知道,过滤还在更后面的 hook 里。

图片复制和远程下载更慢,于是又加了一个十几行的 Scheduler

export class Scheduler {
    private tasks: Array<Promise<any>> = [];
    public async addAsyncTask(asyncTask: () => Promise<any>) {
        this.tasks.push(asyncTask());
    }
    public async waitForAllTasks(): Promise<any[]> {
        return Promise.all(this.tasks);
    }
}

addAsyncTask 收进来的任务会立即开始,数组里存的只是 Promise。设计意图是让主流程继续,最后再用 waitForAllTasks() 收口;可真实代码从没调这个方法,图片和写盘都成了 fire-and-forget。Scheduler 这个小壳子后来比 Vector 本身更常出现在我的代码里,但 Vector 里这版用得并不完整。

扩展阶段:Hook、资源调度与目录路由

文章渲染后还有几件事:丢掉草稿、从正文里找封面、生成 TOC、按目录聚合列表。我没有把它们全塞进主流程,而是分成 initbeforeSave 两组 hook。它们只是按顺序拼在同一个数组里执行,并没有一个真正隔开两个阶段的运行时边界。

const bundleHooks = [
    ...hooks.init, ...initHooks,
    ...hooks.beforeSave, ...beforeSaveHooks,
];
for (const hook of bundleHooks) {
    const res = hook(data);
    data = res instanceof Promise ? await res : res;
}

这种写法对一个框架很合理:核心只定检查点,功能通过 hook 挂进来。问题是我那时还没想清楚,这个博客到底需不需要“框架”。

图片处理只管 front-matter 里的字段,正文图片交给 markdown-it。代码会递归走过 YAML 对象,碰到图片路径就选本地或远程 handler:

function processObject(filePath: string, obj: any) {
    for (const key in obj) {
        let objValue = obj[key];
        if (typeof objValue === "string") {
            if (!isImageURI(objValue)) continue;
            if (!isHttpURI(objValue)) {
                objValue = path.resolve(path.dirname(filePath), objValue);
            }
            const handler = isHttpURI(objValue) ? urlImageHandler : localImageHandler;
            obj[key] = handler(objValue);
        } else if (typeof obj[key] === "object") {
            processObject(filePath, obj[key]);
        }
    }
}

handler 不等图片复制完,而是将任务放进 imageSchedule,先把未来的访问 URL 返回给上层。这里也没有最后的 waitForAllTasks()

export function localImageHandler(imagePath: string) {
    const destinationPath = getImageSavePath(imagePath);
    imageSchedule.addAsyncTask(async () => {
        await ensureDirExists(path.dirname(destinationPath));
        await fs.promises.copyFile(imagePath, destinationPath);
    });
    return getImageRenderUrl(imagePath);
}

目录本身就是路由。比如 data/tech/ 下的文章,会生成一份 tech/index.json 和若干文章 JSON。前端进入 /tech 取列表,进入详情再取对应 ID 的数据。加文章只需要放文件,不用再维护一份路由表。

加载 vector.config.js 时,我又用了一次 eval

const data = await fs.readFile(path.resolve(rootDir, "vector.config.js"));
const obj = eval(data.toString());
Object.assign(userConfig, obj);

它绕开了 CommonJS 和 ESM 的兼容问题,也意味着配置文件可以执行任意代码。对只加载自己配置的工具,这个取舍我当时能接受:既然代码最后还是交给 JS 引擎,有时可以直接借它的 parser。

性能剖析:索引、哈希与原生加速

开源仓库的搜索模块还是空的,内部版本的做法是在页面加载后预取全文数据。打开搜索框时,数据已经在内存里,前端只需要查字符串和高亮。

9bccd9c9e2.png

全文索引不值得每改一个字就重算。我把最近修改的文章标成 hot,先不并入总数据,等它稳定一会再统一合并。这个延迟合并实测省了大约 10% 的构建时间。

profile 之后,更大的耗时在 md5。大图片用 Node crypto.createHash 算一次要十几毫秒,累积起来比 Markdown 渲染显眼得多。

96baae55aa.png

我先给字符串 md5 加了 memoize

export const getStringMD5 = memoize(function (input) {
    const hash = createHash("md5");
    hash.update(input);
    return hash.digest("hex");
});

然后为大文件接了一个 N-API 的 C++ 实现,再将主题模板、配置和中间数据这些小文件在启动时读进内存。当时在 i7-9750H、100M 带宽的环境里,我记下的一次“同步修改”用了 1.173 秒。

e2125f3b64.png

当时我也跑了 Hexo 做对照。只有一页的官方默认主题用了 4.066 秒,第三方主题通常更慢。

b2c12eb757.png

这不是一个严格的 benchmark。我没有留下当时的文章数、冷热缓存、执行命令和网络是否计入等完整口径;而且 Hexo 要兼容主题、插件和历史版本,Vector 只服务我的数据。这两个数字适合当成当时的一次记录,不适合当成框架快慢的证明。

可靠性复盘:完成信号为什么不可信

三年后重新看 Vector,有几处很难装作没看见。

代码里有一段被我注释成“双重校验锁”:

} catch (err) {
    if (err.code === "ENOENT") {
        // 双重校验锁
        if (dirCache.has(filePath)) return;
        await fs.mkdir(filePath, { recursive: true });
        dirCache.add(filePath);
    }
}

这个词用错了。真正保证它能工作的,是 mkdir({ recursive: true }) 本身的幂等性;dirCache 只是让重复调用早点返回。我那时候给一段普通的缓存逻辑安了个显得很厉害的名字。

saveObj 往 Scheduler 里放了写盘任务,却没有显式调用 waitForAllTasks。Promise 本身不会阻止 Node 退出,当时真正撑住进程的是已经发起的底层文件 IO。这种收尾方式能跑,但不保证产物写完后主流程才返回。image.cache.ts 只留了一行 return [false, ""]sync.cos.ts 也还是空函数。

它们不是几个快要完成的功能,而是我对一个 MVP 的未来做的假设。项目停下后,这些空壳也就一直留在那里。

从框架退回单体构建器

Vector 的技术路线没有走不通。我停掉它,只是因为这个博客一年更新不了多少文章,实际用不上两段式 hook、通用 Scheduler 和一套插件协议。

替掉 Vector 的早期生成器就在仓库里的 web/scripts/build.mjs。它当时只有大约 400 行:扫一遍 data/,用 markdown-it 渲染,套 HTML 模板,然后写入 dist/。对当时的几十篇文章,这就够了。

小脚本并没有永远停在 400 行。到这次重写文章时,build.mjs 已经长到 2235 行,因为博客后来又加了搜索、图片、页面模板和其它生成逻辑。这次我不再把“变长”自动看成设计失败:真实需求回来了,代码就会回来。

真正该删的不是“超过多少行”,而是没有用户的抽象。Vector 的两组 hook、通用 Scheduler 和插件协议在当时都跑在需求前面,所以我把它们删了。后来博客真的长出更多功能,一个更长的生成器反而是正常结果。那个月也不算白花:缓存、调度和 hook 的设计后来都用到了别处。