Quartz 是一个静态网站生成器。它是如何工作的?
要回答这个问题,最好从用户在命令行运行 npx quartz build
时发生的一系列操作开始追踪:
服务端流程
- 运行
npx quartz build
后,npm 会查看package.json
找到quartz
的bin
入口,该入口指向./quartz/bootstrap-cli.mjs
。 - 该文件顶部有一个 shebang 行,指示 npm 使用 Node 执行该文件。
bootstrap-cli.mjs
负责以下事项:- 使用 yargs 解析命令行参数。
- 使用 esbuild 将 Quartz 的其余部分(TypeScript 编写)转译并打包为常规 JavaScript。这里的
esbuild
配置略有特殊,因为它还通过 esbuild-sass-plugin v2 处理.scss
文件导入。此外,我们使用自定义的esbuild
插件来打包组件声明的 ’ 内联 ’ 客户端脚本(任何.inline.ts
文件),该插件会运行另一个针对浏览器而非node
的esbuild
实例进行打包。两种类型的模块都以纯文本形式导入。 - 如果设置了
--serve
则运行本地预览服务器。这会启动两个服务器:- 端口 3001 上的 WebSocket 服务器,用于处理热重载信号。当检测到服务端变更(内容或配置)时,跟踪所有入站连接并发送 ’ 重新构建 ’ 消息。
- 用户定义端口(通常为 8080)上的 HTTP 文件服务器,用于提供实际网站文件。
- 如果设置了
--serve
标志,还会启动文件监视器以检测源代码变更(例如任何.ts
、.tsx
、.scss
或打包文件)。变更时,我们使用 esbuild 的 rebuild API 重新构建模块(上述步骤 2),这大幅缩短了构建时间。 - 转译主 Quartz 构建模块(
quartz/build.ts
)后,我们将其写入缓存文件.quartz-cache/transpiled-build.mjs
,然后使用await import(cacheFile)
动态导入。不过,我们需要巧妙处理 Node 的 导入缓存,因此添加随机查询字符串让 Node 认为这是一个新模块。这会导致内存泄漏,所以我们只能希望用户不要在一个会话中热重载配置太多次 :))(每次重载约泄漏 ~350kB 内存)。导入模块后,我们调用它并传入之前解析的命令行参数,以及一个用于通知客户端刷新的回调函数。
- 在
build.ts
中,我们首先手动安装源映射支持以应对之前引入的查询字符串缓存破坏技巧。然后开始处理内容:- 清理输出目录。
- 递归扫描
content
文件夹中的所有文件,遵守.gitignore
。 - 解析 Markdown 文件。
- Quartz 检测可用线程数,并在需要解析的内容超过 128 个时选择生成工作线程(粗略启发式)。如果需要生成工作线程,会再次调用 esbuild 转译工作脚本
quartz/worker.ts
。然后创建基于 workerpool 的工作窃取池,分配 128 个文件批次给工作线程。 - 每个工作线程(若无并发则为主线程)根据 配置 中定义的插件创建 unified 解析器。
- 解析分三步:
- 将文件读入 vfile。
- 应用插件定义的文本转换。
- 对文件路径进行 slugify 处理并存入文件数据。详见 路径 页面了解 Quartz 中路径逻辑的细节(剧透:相当复杂)。
- 使用 remark-parse 进行 Markdown 解析(文本转 mdast)。
- 应用插件定义的 Markdown 到 Markdown 转换。
- 使用 remark-rehype 将 Markdown 转换为 HTML(mdast 转 hast)。
- 应用插件定义的 HTML 到 HTML 转换。
- Quartz 检测可用线程数,并在需要解析的内容超过 128 个时选择生成工作线程(粗略启发式)。如果需要生成工作线程,会再次调用 esbuild 转译工作脚本
- 使用插件过滤不需要的内容。
- 使用插件生成文件。
- 收集每个发射器插件声明的所有静态资源(如外部 CSS、JS 模块等)。
- 生成 HTML 文件的发射器需要额外工作:将解析步骤产生的 hast 转换为 JSX。这通过 hast-util-to-jsx-runtime 使用 Preact 运行时实现。最后,使用 preact-render-to-string 将 JSX 静态渲染为 HTML(即不处理
useState
、useEffect
或其他 React/Preact 交互逻辑)。在此过程中,我们还完成许多有趣的工作:从quartz.layout.ts
组装页面 布局,整合所有实际发送到客户端的内联脚本,以及所有转译后的样式。主要逻辑可在quartz/components/renderPage.tsx
中找到。其他值得注意的有趣细节:- 使用 Lightning CSS 压缩和转换 CSS,添加供应商前缀并降低语法版本。
- 脚本分为
beforeDOMLoaded
和afterDOMLoaded
,分别插入<head>
和<body>
。
- 最后,每个发射器插件负责将自己生成的文件写入磁盘。
- 如果检测到
--serve
标志,我们还会设置另一个文件监视器来检测内容变更(仅.md
文件)。我们维护一个内容映射,跟踪每个 slug 的解析 AST 和插件数据,并在文件变更时更新。新增或修改的路径会被重建并添加到内容映射中。然后,所有过滤器和发射器都会对结果内容映射进行处理。此文件监视器设有 250ms 的防抖阈值。成功后,通过传入的回调函数发送客户端刷新信号。
客户端流程
- 浏览器打开 Quartz 页面并加载 HTML。
<head>
还链接了页面样式(生成到public/index.css
)和页面关键 JS(生成到public/prescript.js
)。 - 加载完 body 后,浏览器加载非关键 JS(生成到
public/postscript.js
)。 - 页面加载完成后,会派发自定义合成浏览器事件
"nav"
。这允许组件声明的客户端脚本对需要访问页面 DOM 的内容进行 ’ 初始化 ’。- 如果在 配置 中启用了 enableSPA 选项,任何客户端导航时都会触发
"nav"
事件,允许组件注销和重新注册事件处理程序及状态。 - 如果未启用,我们将
"nav"
事件设置为仅在页面加载后触发一次,以确保 SPA 和非 SPA 上下文中的状态设置方式一致。
- 如果在 配置 中启用了 enableSPA 选项,任何客户端导航时都会触发
关于插件系统的架构和设计,此处有意保持较为模糊的描述,因为 创建自己的插件 指南中对此有更深入的讲解。