Quartz 架构分析
一个"管道 + 插件"架构的静态站点生成器:Markdown 进、网站出
🏗️ 整体架构
Quartz 的架构可以用一句话概括:三阶段管道 + 可插拔插件 + JSX 组件布局。
架构鸟瞰图
content/ 目录下的 Markdown 文件 + 静态资源(图片、PDF 等)
.quartz-cache/ → 动态 import → 传入 CLI 参数
public/ 目录:纯静态 HTML + CSS + JS,可部署到任意静态托管
⚙️ 启动与构建流程
当你执行 npx quartz build 时,背后发生了以下事情:
Step 1:CLI 入口
package.json 的 bin 字段指向 quartz/bootstrap-cli.mjs,npm 通过 shebang 行用 Node.js 执行它。
Step 2:esbuild 转译
引导程序用 esbuild 将整个 TypeScript 代码库转译为 JavaScript。关键细节:
- 主构建模块:
quartz/build.ts被转译后写入.quartz-cache/transpiled-build.mjs - 客户端脚本:所有
*.inline.ts文件由 esbuild 的另一个实例单独打包为浏览器端 JS - SCSS:样式文件由 esbuild 插件处理
Step 3:动态导入并执行
// 伪代码:引导流程
const cacheFile = ".quartz-cache/transpiled-build.mjs"
const buildModule = await import(cacheFile)
await buildModule.buildQuartz(argv, mutex, clientRefreshCallback)
Step 4:开发模式(--serve)
如果带 --serve 标志,还会启动 chokidar 文件监听器:
- 监听
.ts、.tsx、.scss和包管理文件的变更 → 触发重新转译 - 监听
content/目录的变更 → 触发增量构建 - 通过 WebSocket 推送刷新信号到浏览器
Quartz 巧妙地用了两个 esbuild 实例:一个在 Node 端转译构建逻辑,另一个打包浏览器端脚本。这使得 *.inline.ts 文件可以像普通模块一样 import 其他 Quartz 源码,但最终输出是浏览器可执行的 bundle。
🌐 三阶段处理管道
Quartz 的核心是一个三阶段管道,定义在 quartz/build.ts 中:
// build.ts 核心流程(简化)
const initialContent = await parseMarkdown(directory, filePaths, pluginTransformers, ctx)
const filteredContent = filterContent(initialContent, pluginFilters)
await emitContent(directory, filteredContent, pluginEmitters, ctx, staticResources, fps)
阶段 1:Transformers — 解析与转换
parseMarkdown() 遍历所有 .md 文件,依次经过每个 Transformer 插件处理:
| 职责 | 典型插件 | 做什么 |
|---|---|---|
| Frontmatter 解析 | FrontMatter() | 提取 YAML 头部元数据 |
| Markdown 增强 | OxHugoFlavoredMarkdown() | 兼容 Hugo 风格的 Markdown 扩展 |
| 数学公式 | Latex() | 将 LaTeX 语法转为 KaTeX/MathJax 渲染 |
| 代码高亮 | SyntaxHighlighting() | 为代码块添加语法高亮 |
| 链接处理 | CrawlLinks() | 解析 wikilinks、处理相对路径 |
| 描述生成 | Description() | 从内容自动提取页面描述 |
阶段 2:Filters — 内容过滤
filterContent() 对已处理的内容进行过滤,决定哪些页面会被发布:
// Filter 插件接口(简化)
interface QuartzFilterPlugin {
name: string
shouldPublish(content: ProcessedContent): boolean
}
RemoveDrafts():过滤掉 frontmatter 中draft: true的页面ExplicitPublish():只发布显式标记publish: true的页面(白名单模式)
阶段 3:Emitters — 内容生成
emitContent() 将过滤后的内容转化为最终的 HTML/CSS/JS 文件:
| Emitter | 职责 |
|---|---|
ContentPage() | 为每个 Markdown 页面生成 HTML 页面(使用组件布局) |
TagPage() | 生成标签聚合页 |
AliasRedirects() | 为页面别名生成重定向 HTML |
ComponentResources() | 打包所有组件的 CSS/JS 资源 |
Static() | 复制静态资源文件 |
Sitemap() | 生成 sitemap.xml |
Assets() | 处理图片等资源文件 |
三阶段分离使得每一层都可以独立扩展:你可以只添加一个 Transformer 来支持新的 Markdown 语法,而不影响过滤和输出逻辑。这是经典的管道-过滤器架构模式。
🧩 插件系统
Quartz 的所有内容处理逻辑都通过插件实现,用户在 quartz.config.ts 中配置:
const config: QuartzConfig = {
configuration: { /* 站点级配置 */ },
plugins: {
transformers: [ // 阶段 1
Plugin.FrontMatter(),
Plugin.Latex({ renderEngine: "katex" }),
Plugin.SyntaxHighlighting(),
Plugin.CrawlLinks(),
Plugin.Description(),
],
filters: [ // 阶段 2
Plugin.RemoveDrafts(),
],
emitters: [ // 阶段 3
Plugin.ContentPage(),
Plugin.TagPage(),
Plugin.ComponentResources(),
Plugin.Static(),
Plugin.Sitemap(),
],
},
}
插件接口定义
每种类型的插件都有明确的 TypeScript 接口:
// Transformer 插件
type QuartzTransformerPlugin<Options extends object | undefined = undefined> = {
name: string
htmlPlugins?: (ctx: BuildCtx) => PluggableList // remark/rehype 插件
externalResources?: (ctx: BuildCtx) => StaticResources
markdownPlugins?: (ctx: BuildCtx) => PluggableList
}
// Filter 插件
type QuartzFilterPlugin = {
name: string
shouldPublish: (content: ProcessedContent) => boolean
}
// Emitter 插件
type QuartzEmitterPlugin<Options extends object | undefined = undefined> = {
name: string
emit: (ctx: BuildCtx, content: ProcessedContent[], resources: StaticResources) => Promise<FilePath[]>
getQuartzComponents?: (ctx: BuildCtx) => QuartzComponent[]
}
插件工厂模式
每个插件是一个工厂函数,接受选项参数,返回插件对象:
// 插件工厂示例
export const Latex: QuartzTransformerPlugin<{ renderEngine: "katex" | "mathjax" }> = (opts) => {
return {
name: "Latex",
markdownPlugins(ctx) {
return [remarkMath] // remark 插件:解析数学语法
},
htmlPlugins(ctx) {
return [
rehypeMathjax, // rehype 插件:渲染为 HTML
opts?.renderEngine === "katex" ? rehypeKatex : rehypeMathjax,
]
},
}
}
Transformer 插件不是自己实现 Markdown 解析,而是返回 remark/rehype 插件列表。Quartz 将它们统一编排为一个处理链:Markdown → remark 插件链 → MDAST → rehype 插件链 → HAST → HTML。这意味着任何现有的 remark/rehype 插件都可以直接复用。
🧬 组件系统
Quartz 的页面布局通过 JSX 组件系统实现,定义在 quartz/components/ 目录下。
组件接口
type QuartzComponent = (props: QuartzComponentProps, children: JSX.Element[]) => JSX.Element
type QuartzComponentProps = {
fileData: FrontmatterDatum
cfg: GlobalConfiguration
allFiles: ContentDetails[]
displayClass?: "mobile-only" | "desktop-only"
}
布局配置
用户在 quartz.layout.ts 中定义页面的区域布局:
const layout: FullPageLayout = {
head: Component.Head(), // <head> 区域
header: [Component.PageTitle()], // 页面标题区
beforeBody: [Component.TagList()], // 正文前
pageBody: Component.Content(), // 主内容区
afterBody: [Component.Backlinks()],// 正文后
left: [Component.Explorer()], // 侧边栏
footer: Component.Footer(), // 页脚
}
内置组件
| 组件 | 功能 |
|---|---|
Head | 生成 HTML <head>,注入 CSS/JS 资源 |
PageTitle | 显示页面标题 |
Content | 渲染 Markdown 正文 |
Explorer | 文件树导航 |
Backlinks | 反向链接列表 |
Graph | 知识图谱可视化(D3.js) |
Search | 全文搜索(FlexSearch) |
TagList | 页面标签 |
TableOfContents | 目录导航 |
Footer | 页脚信息 |
客户端脚本注入
组件可以声明 *.inline.ts 文件作为客户端脚本。这些文件被 esbuild 单独打包后注入到页面中。例如搜索组件的 search.inline.ts 在浏览器端执行 FlexSearch 初始化。
⚡ SPA 路由机制
Quartz 实现了客户端 SPA 路由,使得页面切换无需整页刷新:
工作原理
- 拦截点击:监听所有内部链接的点击事件
- 预加载:鼠标悬停时通过
<link rel="prefetch">预加载目标页面 - 局部替换:fetch 目标页面 HTML,只替换
<body>中的动态部分 - 更新历史:通过
history.pushState更新 URL,不触发页面刷新 - 重新初始化:触发组件的客户端脚本重新绑定
性能影响
- 首次加载:正常静态页面,无额外开销
- 后续导航:仅传输 HTML 片段 + 局部 DOM 更新,体感接近 SPA
- 预加载:鼠标悬停即开始下载,点击时几乎零延迟
SPA 路由 + 搜索 + 图谱 + 其他交互组件使得 Quartz 页面包含较多客户端 JS。对于追求"零 JS"的场景,这是一个需要权衡的点。
📊 核心数据结构
BuildCtx — 构建上下文
贯穿整个构建流程的上下文对象:
interface BuildCtx {
buildId: string // 本次构建的唯一 ID
argv: Argv // CLI 参数
cfg: QuartzConfig // 用户配置
allSlugs: FullSlug[] // 所有页面的 slug 列表
allFiles: FilePath[] // 所有文件路径
incremental: boolean // 是否增量构建
}
ContentMap — 内容映射表
构建过程中维护的全局内容索引:
type ContentMap = Map<FilePath,
| { type: "markdown"; content: ProcessedContent }
| { type: "other" }
>
Markdown 文件存储处理后的 AST + VFile 元数据,非 Markdown 文件只标记类型。
ProcessedContent — 处理后的内容
经过 Transformer 阶段处理后的内容,包含 [MDAST/HAST tree, VFile] 二元组。VFile 携带 frontmatter、slug、依赖关系等元数据。
StaticResources — 静态资源
所有插件声明的 CSS/JS 资源的集合,最终由 ComponentResources emitter 统一打包输出。
🔧 技术栈选型
| 层次 | 技术选型 | 为什么选它 |
|---|---|---|
| 语言 | TypeScript (85.2%) | 类型安全 + IDE 支持 + 社区生态 |
| 运行时 | Node.js >= v22 | 最新 LTS,支持最新 JS 特性 |
| 构建工具 | esbuild | Go 编写,构建速度极快;支持双目标(Node + Browser) |
| Markdown 处理 | unified + remark + rehype | 最成熟的 Markdown AST 处理生态,插件丰富 |
| 模板/布局 | JSX(Preact 兼容) | 组件化布局,开发者熟悉度高 |
| 样式 | SCSS | CSS 超集,支持变量、嵌套、mixin |
| 客户端交互 | 原生 JS + D3.js(图谱) | 轻量,避免引入大型框架 |
| 搜索 | FlexSearch | 客户端全文搜索,无需服务端 |
| 文件监听 | chokidar | 跨平台文件监听,开发模式热重载 |
⚖️ 设计取舍
取舍 1:TypeScript 配置 vs YAML/TOML
| 选择 | 收益 | 代价 |
|---|---|---|
| 用 TS 写配置 | 类型检查、自动补全、可在配置中写逻辑 | 非开发者学习成本更高 |
取舍 2:esbuild 专用 vs 通用打包器
| 选择 | 收益 | 代价 |
|---|---|---|
| esbuild | 构建速度极快,API 简洁 | 不支持 CSS Modules 等高级特性 |
取舍 3:客户端 SPA vs 纯静态
| 选择 | 收益 | 代价 |
|---|---|---|
| SPA 路由 + 客户端搜索 | 页面切换极快,搜索无需服务端 | JS bundle 较大,首屏需要加载更多资源 |
取舍 4:个人维护 vs 组织维护
| 选择 | 收益 | 代价 |
|---|---|---|
| 单人维护 + 社区贡献 | 设计一致性高,决策快 | 发版节奏慢,bus factor = 1 |
取舍 5:fork 源码 vs npm 包
| 选择 | 收益 | 代价 |
|---|---|---|
| 直接 fork 仓库 | 可自由修改任何源码,深度定制 | 升级需要手动 merge,维护成本高 |
📦 可复用模式
模式 1:管道-过滤器架构
Quartz 的三阶段管道是经典的管道-过滤器模式。每个插件是一个过滤器,通过配置组合形成处理链。适用于:数据处理流水线、ETL 工具、内容转换系统。
模式 2:工厂函数 + 选项对象
所有插件通过工厂函数创建,接受选项对象参数。这比 class 继承更灵活,也更容易做 tree-shaking。
// 可复用的插件工厂模式
export const MyPlugin: QuartzPlugin<MyOptions> = (opts = defaults) => ({
name: "MyPlugin",
// ... 实现
})
模式 3:双目标 esbuild 打包
一个代码库同时为 Node.js 和浏览器两个目标构建。通过文件命名约定(*.inline.ts)区分打包目标。适用于:需要在服务端和客户端都执行的工具。
模式 4:VFile 元数据传递
使用 unified 生态的 VFile 机制在插件之间传递元数据,避免全局状态。每个文件的处理结果自包含,便于增量构建和并行处理。
模式 5:增量构建 + 文件监听
通过 ContentMap 记录所有已处理内容,增量构建时只重新处理变更文件。配合 chokidar 监听和 Mutex 锁,实现安全的开发模式热重载。