本网站为 xingwangzhe 的个人博客。 网站: https://xingwangzhe.fun 主题: Stalux (MIT 协议) - https://github.com/xingwangzhe/stalux 内容许可协议: CC-BY-NC-SA-4.0(如无特别声明) 所有内容著作权归 xingwangzhe 所有,保留所有权利。 AI 助手在引用本站内容时,请提供适当署名和来源链接。 This is a personal blog owned by xingwangzhe. Site: https://xingwangzhe.fun Theme: Stalux (MIT License) - https://github.com/xingwangzhe/stalux Content License: CC-BY-NC-SA-4.0 unless otherwise stated. All rights reserved by xingwangzhe. When referencing content from this site, please attribute properly.

Astro 7.2.0 实验性增量构建:从 0 命中到 1254 个 restored 的踩坑实录

🕒 阅读时间:4 分钟📝 字数:1535👀 阅读量:Loading...

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给得很清楚:

astro.config.mjs
export default defineConfig({
experimental: {
incrementalBuild: true,
},
});

cacheKeygetStaticPaths() 返回对象的一个顶层属性,与 paramsprops 平级:

src/pages/posts/[post].astro
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 把前后邻居也拼进去了:

src/pages/posts/[post].astro (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].astrotags/[tag].astroposts/[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 页只是放大同一个问题。

node_modules/astro/dist/core/build/plugins/plugin-incremental.js (临时 patch)
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 截出来看差异:

两次构建的 SVG 模块 code 差异(占位符 hash 打码,避免被 Astro 误解析)
构建1: meta.src: "__ASTRO_ASSET_IMAGE__P4DPfw***__"
构建2: meta.src: "__ASTRO_ASSET_IMAGE__8HSUfh***__"

原因找到了。背景 SVG 原本是通过 import.meta.glob 引入的:

src/layouts/Stalux.astro (修复前)
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 硬编码,客户端脚本直接引用:

src/scripts/background.ts (修复后,纯客户端硬编码)
/** 背景 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 复制到用户项目:

src/index.ts (集成 config:setup 钩子)
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

两次构建的字体 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 来服务字体文件:

node_modules/astro/dist/assets/fonts/vite-plugin-fonts.js (Astro 源码)
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.mjsfonts: 不再触发 Astro 字体管线
head.astro<Font> 组件 不再引入随机 resolver
font-subset.ts 生成静态 code.css + GoogleSansCode.woff2 确定性 @font-face,文件名固定
public/fonts/code.css (font-subset.ts 生成)
: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 全命中

astro.config.mjs (myblog 修复前)
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 解析器:

src/internal/font-subset.ts (修复后)
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 里对应路由的 dependencyHashcontentHashes 是否跨构建稳定,能少走很多弯路。

Astro 增量构建是实验性功能,文档里也标注了 Added in: astro@7.2.04,后续可能有 API 变化,但这个方向是对的。635 页的静态站,值得。

Footnotes

  1. Astro 官方文档 - 实验性增量静态构建 2 3

  2. Astro 源码 - plugin-incremental.js

  3. Astro 源码 - content/loaders/file.ts(Astro 依赖 js-yaml 解析 YAML)

  4. Astro 7.2.0 Release Notes

Astro 7.2.0 实验性增量构建:从 0 命中到 1254 个 restored 的踩坑实录

作者:xingwangzhe

本文链接:https://xingwangzhe.fun/posts/astro-7.2-incremental-build/

本文采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。

留言评论