---
title: Astro 7.2.0 实验性增量构建：从 0 命中到 1254 个 restored 的踩坑实录
date: "2026-08-07 01:09:00"
updated: "2026-08-07 01:09:00"
desc: Astro 7.2.0 带来了 experimental.incrementalBuild 实验性增量构建。我的 Astro 博客主题 Stalux 及其 635 页博客 myblog 升级后，HTML 页面却一个都没命中缓存，只有 .md 端点能复用。排查发现是两类"构建期随机源"污染了依赖图 hash：背景 SVG 的 ASTRO_ASSET_IMAGE 随机占位符、Font 组件在 prerender 阶段启动的随机端口。本文完整记录从 cacheKey 配置到构建确定性的修复历程，含 Astro 源码级排查方法与 commit 记录。
abbrlink: astro-7.2-incremental-build
tags:
    - astro
    - 增量构建
    - 构建性能
    - 踩坑记录
categories:
    - 技术
cc: CC-BY-NC-SA-4.0
---

> **AI 辅助声明**：本文的代码实现与排查过程使用了 AI 编程助手（ZCode）辅助进行源码分析、patch 调试与文档撰写。所有结论均经过构建实测验证。

2026 年 8 月，Astro 发布了 7.2.0，带来了一个我眼馋很久的实验性功能——**增量静态构建**（`experimental.incrementalBuild`）。我的 Astro 博客主题 Stalux 下的 myblog 博客有 635 个页面，每次构建全量渲染要 30-40 秒，文章多了之后实在肉疼。

满怀期待升级之后，我却发现了一个诡异的现象：**.md 端点能复用缓存，HTML 页面却一个都不命中**。这一查就是两天，最后把 Astro 的源码都 patch 了。

这篇文章完整记录这趟折腾之旅。说句实话，排查过程比结果精彩，也让我把 Astro 的构建管线摸了个透。

<!--more-->

## 前言

先交代一下背景。我的博客架构是这样的：

| 项目 | 角色 | 说明 |
| ---- | ---- | ---- |
| `stalux` | Astro 博客主题库（npm 包） | 我维护的 Astro 主题，被 myblog 以插件模式消费 |
| `myblog` | 真实博客（635 页） | `xingwangzhe.fun`，跑在 stalux 集成模式上 |

Astro 7.2.0 的增量构建，官方文档是这么说的[^1]：

> 这个实验性功能会复用上一次构建的输出，未变化的页面不再重新渲染。启用后，Astro 可以跳过由 `getStaticPaths()` 生成的静态页面——只要它的数据和它依赖的代码自上次构建以来都没变。

关键点在最后一句：**数据和代码都没变**。数据由 `cacheKey` 标记，代码由 Astro 对页面模块依赖图做 hash。两者都匹配上次构建，才会 `(restored)` 复用旧输出。

## 增量构建的正确姿势

先说配置，官方文档[^1]给得很清楚：

```js title="astro.config.mjs"
export default defineConfig({
  experimental: {
    incrementalBuild: true,
  },
});
```

`cacheKey` 是 `getStaticPaths()` 返回对象的一个**顶层属性**，与 `params`、`props` 平级：

```ts title="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 把前后邻居也拼进去了：

```ts title="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].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 一个都没有**：

```text title="第二次构建日志（修复前）"
├─ /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 页只是放大同一个问题。

```js title="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 截出来看差异：

```text title="两次构建的 SVG 模块 code 差异（占位符 hash 打码，避免被 Astro 误解析）"
构建1: meta.src: "__ASTRO_ASSET_IMAGE__P4DPfw***__"
构建2: meta.src: "__ASTRO_ASSET_IMAGE__8HSUfh***__"
```

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

```astro title="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 硬编码，客户端脚本直接引用：

```ts title="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 复制到用户项目：

```ts title="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`。

```text title="两次构建的字体 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 来服务字体文件：

```js title="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.mjs` 的 `fonts:` 块 | 不再触发 Astro 字体管线 |
| 删 `head.astro` 的 `<Font>` 组件 | 不再引入随机 resolver |
| `font-subset.ts` 生成静态 `code.css` + `GoogleSansCode.woff2` | 确定性 `@font-face`，文件名固定 |

```css title="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 全命中`。

```text title="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 解析器：

```ts title="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` 里对应路由的 `dependencyHash` 和 `contentHashes` 是否跨构建稳定，能少走很多弯路。

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

[^1]: [Astro 官方文档 - 实验性增量静态构建](https://docs.astro.build/zh-cn/reference/experimental-flags/incremental-build/)
[^2]: [Astro 源码 - plugin-incremental.js](https://github.com/withastro/astro/blob/main/packages/astro/src/core/build/plugins/plugin-incremental.ts)
[^3]: [Astro 源码 - content/loaders/file.ts（Astro 依赖 js-yaml 解析 YAML）](https://github.com/withastro/astro/blob/main/packages/astro/src/content/loaders/file.ts)
[^4]: [Astro 7.2.0 Release Notes](https://github.com/withastro/astro/releases/tag/astro%407.2.0)


---

**作者：**xingwangzhe

**本文链接：**[https://xingwangzhe.fun/posts/astro-7.2-incremental-build/](https://xingwangzhe.fun/posts/astro-7.2-incremental-build/)

本文采用[知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议](https://creativecommons.org/licenses/by-nc-sa/4.0/)进行许可。