Astro 7.2.0 实验性增量构建:从 0 命中到 1254 个 restored 的踩坑实录
AI 辅助声明:本文的代码实现与排查过程使用了 AI 编程助手(ZCode)辅助进行源码分析、patch 调试与文档撰写。所有结论均经过构建实测验证。
2026 年 8 月,Astro 发布了 7.2.0,带来了一个我眼馋很久的实验性功能——增量静态构建(experimental.incrementalBuild)。我的 Astro 博客主题 Stalux 下的 myblog 博客有 635 个页面,每次构建全量渲染要 30-40 秒,文章多了之后实在肉疼。
满怀期待升级之后,我却发现了一个诡异的现象:.md 端点能复用缓存,HTML 页面却一个都不命中。这一查就是两天,最后把 Astro 的源码都 patch 了。
这篇文章完整记录这趟折腾之旅。说句实话,排查过程比结果精彩,也让我把 Astro 的构建管线摸了个透。
前言
先交代一下背景。我的博客架构是这样的:
| 项目 | 角色 | 说明 |
|---|---|---|
stalux |
Astro 博客主题库(npm 包) | 我维护的 Astro 主题,被 myblog 以插件模式消费 |
myblog |
真实博客(635 页) | xingwangzhe.fun,跑在 stalux 集成模式上 |
Astro 7.2.0 的增量构建,官方文档是这么说的1:
这个实验性功能会复用上一次构建的输出,未变化的页面不再重新渲染。启用后,Astro 可以跳过由
getStaticPaths()生成的静态页面——只要它的数据和它依赖的代码自上次构建以来都没变。
关键点在最后一句:数据和代码都没变。数据由 cacheKey 标记,代码由 Astro 对页面模块依赖图做 hash。两者都匹配上次构建,才会 (restored) 复用旧输出。
增量构建的正确姿势
先说配置,官方文档1给得很清楚:
export default defineConfig({ experimental: { incrementalBuild: true, },});cacheKey 是 getStaticPaths() 返回对象的一个顶层属性,与 params、props 平级:
export async function getStaticPaths() { return posts.map((post) => ({ params: { post: post.data.abbrlink }, cacheKey: post.data.updated ?? post.data.date, props: { post }, }));}但文章页有 prev/next 导航,邻居变了当前页也得重渲染,所以我的 cacheKey 把前后邻居也拼进去了:
const cacheKey = [ post.data.updated ?? post.data.date, // 自身 prevPost ? (prevPost.data.updated ?? prevPost.data.date) : "", // 上一篇 nextPost ? (nextPost.data.updated ?? nextPost.data.date) : "", // 下一篇].join("|");taxonomy(tags/categories)页面则把分类下所有文章的更新时间拼起来,categories/[category].astro、tags/[tag].astro、posts/[post].md.ts 都补上了 cacheKey。
缓存判定的完整条件(canSkip,来自 Astro 源码2):
| 条件 | 说明 |
|---|---|
cacheKey 匹配 |
页面数据未变 |
dependencyHash 匹配 |
页面依赖图(布局/组件/import 的文件编译后 code)hash 一致 |
contentHashes 匹配 |
内容模块(渲染的 md 文件)hash 一致 |
任何一个不匹配,页面就重新渲染。
坑一:HTML 永远 0 restored
配置都对了,构建也过了,结果第二次构建一看日志——只剩 .md 端点 restored,HTML 一个都没有:
├─ /posts/03e0f0c0.md (restored)├─ /posts/03e0f0c0/index.html (+31ms) ← HTML 在重新渲染!├─ /posts/05db3c92.md (restored)├─ /posts/05db3c92/index.html (+28ms)restored 总数: 627 | HTML 页面 restored: 0。.md 端点能缓存,说明机制本身是通的;HTML 不行,说明问题出在 HTML 页面特有的依赖上。
排查过程
我一开始还怀疑是缓存目录脏了,rm -rf node_modules/.astro 清缓存、重装 node_modules,全都没用。最后还是走了最笨但最有效的路:patch Astro 源码,把 [post].astro 依赖图里每个模块的编译产物 hash dump 出来对比。排查在 stalux 演示站(43 页)上进行——构建快、依赖图小,模块 hash 的差异一眼就能看出来;myblog 的 635 页只是放大同一个问题。
function hashModules(graph, sortedIds) { const hasher = crypto.createHash("sha256"); const mods = []; for (const id of sortedIds) { hasher.update(id); hasher.update("\n"); const code = graph.getModuleInfo(id)?.code; if (code != null) { hasher.update(code); } hasher.update("\n"); mods.push(id + " :: " + (code ? crypto.createHash("sha256").update(code).digest("hex").slice(0, 12) : "NO-CODE")); } console.error("[DEPHASH-MODS] " + mods.join("\n[DEPHASH-MODS] ")); return hasher.digest("hex");}跑两次构建,对比模块 hash 差异,结果一目了然:
| 变化模块 | 类型 | 数量 |
|---|---|---|
src/assets/background/pattern-*.min.svg |
背景 SVG | 36 个 |
virtual:astro:assets/fonts/runtime/font-file-url-resolver |
字体虚拟模块 | 1 个 |
坑二:背景 SVG 的资源占位符每次构建随机
36 个背景 SVG 的编译产物每次构建都变。把 code 截出来看差异:
构建1: meta.src: "__ASTRO_ASSET_IMAGE__P4DPfw***__"构建2: meta.src: "__ASTRO_ASSET_IMAGE__8HSUfh***__"原因找到了。背景 SVG 原本是通过 import.meta.glob 引入的:
const bgModules = import.meta.glob<{ default: { src: string } }>( "../assets/background/pattern-*.min.svg", { eager: true },);一旦走了 import.meta.glob,SVG 就进了 Astro 资源管线,被换成 ASTRO_ASSET_IMAGE 开头的随机占位符(形如 双下划线 + ASTRO_ASSET_IMAGE + 随机hash + 双下划线)——这个 hash 每次构建 session 重新生成。虽然最终产物文件名是稳定的,但模块编译的中间态是随机的,直接把 dependencyHash 污染了。
修复
彻底绕开资源管线:SVG 移进 public/background/,用静态 URL 硬编码,客户端脚本直接引用:
/** 背景 SVG 静态 URL 列表(与主题包 public/background/ 的 1-42 号一一对应) */const BACKGROUND_URLS: string[] = Array.from( { length: 42 }, (_, i) => `/background/pattern-${i + 1}.min.svg`,);public/ 目录的文件是普通静态资源,不经过资源管线,不进依赖图 hash。背景随机也从“构建时 Math.random()“移到了”客户端运行时“,保证 SSR 产物完全确定。
唯一的坑是:Astro 不会自动合并集成的 public 目录。所以我在集成钩子里加了一个判别式同步,把主题包的 SVG 复制到用户项目:
function syncBackgroundSvgs(srcDir: string): { copied: number; skipped: number } { // 判别式:同名文件内容一致跳过(幂等)、不同才覆盖、用户新增文件保留 for (const name of readdirSync(srcBg)) { if (!name.endsWith(".svg")) continue; if (existsSync(destFile)) { if (readFileSync(srcFile).equals(readFileSync(destFile))) { skipped++; continue; } } copyFileSync(srcFile, destFile); copied++; } return { copied, skipped };}坑三:<Font> 组件的随机端口
SVG 修完了,重新 dump 模块 hash,还剩最后一个:font-file-url-resolver。
构建1: "address":"::","family":"IPv6","port":46563构建2: "address":"::","family":"IPv6","port":34989端口号嵌进了模块 code。这来自 Astro 的字体系统:astro.config 里配置了 fonts: 块,页面里用了 <Font cssVariable="--font-code" /> 组件,在 prerender 阶段 Astro 会启动一个本地 HTTP server 来服务字体文件:
const server = createServer((req, res) => { // ...字体文件中间件}).listen(() => { r(server); // 随机端口});createServer().listen() 不指定端口,系统就随机分配一个,这个端口号被 vite-plugin-fonts 嵌进了 font-file-url-resolver 虚拟模块——dependencyHash 又被污染了。
修复
我的主题其实已经有自己的一套确定性字体子集系统(font-subset.ts,基于 harfbuzz WASM 的 subset-font),代码字体完全可以直接用静态 @font-face 提供。于是:
| 改动 | 说明 |
|---|---|
删 astro.config.mjs 的 fonts: 块 |
不再触发 Astro 字体管线 |
删 head.astro 的 <Font> 组件 |
不再引入随机 resolver |
font-subset.ts 生成静态 code.css + GoogleSansCode.woff2 |
确定性 @font-face,文件名固定 |
:root { --font-code: "Google Sans Code", "JetBrains Mono", "Fira Code", "Consolas", "Courier New", monospace;}@font-face { font-family: "Google Sans Code"; src: url("/fonts/GoogleSansCode.woff2") format("woff2"); font-display: swap;}这俩坑修完,重新 dump 模块 hash:变化的模块 = 0。HTML 增量 restored 从 0 直接跳到 35 个(stalux 演示站 43 页)。
坑四:myblog 侧残留的 fonts 块
stalux 主题修好了,结果 myblog 升级到 1.17.0 之后又现原形——posts 的 227 个 HTML 全部不缓存,但 tags/categories 的 400 个能缓存。
你猜怎么着?myblog 自己的 astro.config.mjs 里还残留着 fonts: 块(1.17.0 之前手动配置的 Google Sans Code)。posts 页面渲染代码块要用 --font-code,依赖图就把随机端口的 resolver 拉了进来;tags/categories 不用代码字体,所以侥幸能缓存。
症状和原因完全对应:posts 0/227 restored,tags/categories 400 全命中。
fonts: [ { provider: fontProviders.local(), name: "Google Sans Code", cssVariable: "--font-code", ... }, ],删掉这个块(--font-code 已由主题的静态 code.css 提供,纯冗余),posts 立刻 227/227 全命中。
坑五:顺带挖出的 font-subset 雷
增量构建全通了,我又顺手验证了一遍字体子集,结果发现 55 个页面引用的字体 CSS 根本不存在——字体静默回退到系统字体,这就是“鸳鸯文凯失效”的真相。
根因是 font-subset.ts 用手写正则解析 frontmatter,有两个 bug:
| Bug | 现象 | 根因 |
|---|---|---|
| 带引号 abbrlink | 生成 subset-posts-"90508808".css,HTML 引用不带引号 → 404 |
正则 ^abbrlink:\s*(.+)$ 把引号也抓进去了 |
| 单行 categories/tags | categories: Bing 提取不到 → 分类页无字体 CSS |
正则只匹配块状列表 key:\n - item |
修复方案很朴素:别自己解析 YAML,用 Astro 内容集合的同款解析器 js-yaml(Astro 的 loader 底层就是它3),解析结果和 getCollection() 拿到的 data 完全一致——引号剥离、类型还原、单行转数组都交给 YAML 解析器:
import { load as parseYaml } from "js-yaml";
function parsePostFrontmatter(content: string) { const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); if (!m) return { abbrlink: undefined, tags: [], categories: [] }; let data = parseYaml(m[1]) ?? {}; // 复刻 posts schema 的 preprocess:单行字符串转数组 const toList = (v: unknown): string[] => { if (Array.isArray(v)) return v.map((x) => String(x)); if (typeof v === "string") return v.trim() ? [v.trim()] : []; return []; }; return { abbrlink: data.abbrlink == null ? undefined : String(data.abbrlink), tags: toList(data.tags), categories: toList(data.categories), };}227 篇文章验证:带引号 abbrlink 0 残留,单行 categories 正确转数组,缺失字体引用从 55 个降到 0。
效果对比
| 指标 | 修复前 | 修复后 |
|---|---|---|
| stalux 演示站 HTML restored | 0 | 35 / 35(100%) |
| myblog HTML restored | 0 | 627 / 627(100%) |
| myblog 全部 restored | 627(仅 .md) | 1254(HTML + md 全覆盖) |
[post].astro 依赖图变化模块 |
37 | 0 |
| 缺失字体引用 | 55 | 0 |
| 带引号 CSS 文件 | 8 | 0 |
635 页的站点,第二次构建 1254 个路由直接 (restored),只剩静态页和 RSS/sitemap 重新生成,构建时间肉眼可见地缩短。
Commit 记录
| 仓库 | Commit | 版本 | 内容 |
|---|---|---|---|
| stalux | 5b95e3d1 |
1.15.0 | Astro 7.2.0 + 实验性增量构建 + cacheKey |
| stalux | d0bd6d8c |
1.16.0 | 背景随机移到客户端,保证构建确定性 |
| stalux | 44117035 |
1.17.0 | SVG 静态 URL + 静态字体 code.css,HTML 增量缓存生效 |
| stalux | 03faa2c5 |
1.18.0 | font-subset 改用 js-yaml,修复字体子集缺失 |
| myblog | 4787ad3d |
— | 升级 1.17.0,HTML 400 restored |
| myblog | 5b5fd791 |
— | 启用 incrementalBuild + gitignore 构建产物 |
| myblog | 12fba38a |
— | 移除 astro.config fonts 块,posts 227/227 |
| myblog | c62bdc54 |
— | 升级 1.18.0,字体子集缺失 55→0 |
注意事项
1. CI 必须缓存 node_modules/.astro/
官方文档明确说1:增量缓存放 cacheDir(默认 node_modules/.astro/),CI 里构建前必须恢复这个目录,否则每次全量渲染,增量白搭。构建产物输出目录每次构建会清空,跳过的页面靠从 cacheDir 恢复。
2. build.concurrency 会禁用增量
build.concurrency 大于 1 时增量缓存被禁用,Astro 会警告并全量渲染。静态站默认 1,别去动它。
3. 中间件改动不会失效缓存
中间件改动不会使缓存页面失效。如果你的中间件会改变预渲染页面的 HTML,编辑后要手动 astro build --force。
4. 静态页永远不缓存
只有 getStaticPaths() 返回并带 cacheKey 的页面可跳过。首页、about、404 这些普通静态页每次都渲染——这是我实测确认的,给静态页也加 getStaticPaths 返回单条目是不生效的,incremental-build.json 里只登记动态路由。
总结
这次升级的核心收获,一言以蔽之:增量构建的前提是构建确定性。Astro 的 dependencyHash 是对页面模块依赖图编译产物的 hash,任何“构建期随机”都会让它失效——哪怕最终产物文件是确定的。
| 随机源 | 本质 | 解法 |
|---|---|---|
import.meta.glob 的 SVG |
资源管线随机占位符 | 移 public/ 静态 URL |
<Font> 组件 |
prerender 随机端口 | 静态 @font-face |
Math.random() 注入 |
define:vars 随机内容 | 随机移到客户端 |
排查方法也值得记一笔:patch Astro 源码、把依赖图模块级 hash dump 出来对比,比猜快得多。出问题先看 node_modules/.astro/incremental-build.json 里对应路由的 dependencyHash 和 contentHashes 是否跨构建稳定,能少走很多弯路。
Astro 增量构建是实验性功能,文档里也标注了 Added in: astro@7.2.04,后续可能有 API 变化,但这个方向是对的。635 页的静态站,值得。
Footnotes
Astro 7.2.0 实验性增量构建:从 0 命中到 1254 个 restored 的踩坑实录
作者:xingwangzhe
本文链接:https://xingwangzhe.fun/posts/astro-7.2-incremental-build/
本文采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。
留言评论