实际迁移过程的复盘,AI 参与了复盘整理。
最近把 astro-koharu 从 Astro 5.16.6 升到了 6.4.8。
一开始以为还是熟悉的三件套:升依赖、改 API、修 build。真正动手后才发现,Astro 本身不算最麻烦,项目背着的历史数据才是:
旧文章里的 slug 和 link、多语言 fallback、搜索索引,以及可能跨了几个版本的备份。
所以这次的目标很朴素:
- 页面别少;
- URL 别变;
- 旧文章不用手改;
- 旧备份恢复后还能直接构建。
另一个目标就没那么保守了:给 v5.0.0 战未来。
v5.0.0 准备用 Astro 6 打底,一个很重要的原因就是 Live Content Collections。我不想把 astro-koharu 直接改成必须连后端的 CMS, 而是想保留现在开箱即用的静态构建,再留一条可选的动态路径:不配后台时照常生成静态站;需要时再接一个轻后台,拿到动态内容和发布能力。
理想状态是静态和动态各取所长。再往后,我甚至可以直接把文章发给爱酱,让她帮我整理、写入并发布博客。这个想法现在还没落成, 但如果底层一直停在旧 Content Collections 上,后面每走一步都要先还技术债。
最后不只把模板升到了 Astro 6,也做了一套可 dry-run、自动备份、重复执行不出事的内容迁移。顺手拿 376 篇真实旧文章跑了一遍, 确认不是只对测试有效。
先别急着改版本号
Astro 6 升级指南要求 Node.js 22.12+,同时带来了 Vite 7、Zod 4、
Shiki 4 和 Content Layer 的变化。迁移时 astro@latest 已经进了 7.x,所以这里直接锁定目标版本:
{
"packageManager": "pnpm@10.28.2",
"engines": {
"node": ">=22.12.0"
},
"dependencies": {
"astro": "6.4.8"
}
}动代码前先记下 Astro 5 的基线:
| 指标 | Astro 5.16.6 |
|---|---|
astro check | 347 个文件,0 error / warning / hint |
| Astro 静态页面 | 130 |
| Pagefind 索引页面 | 134 |
dist/ 路由产物 | 141 |
| Astro 构建耗时 | 约 21.62 秒 |
这里最重要的不是构建时间,而是公开行为:
- 默认语言继续不带前缀,英文和日文仍然使用
/en/、/ja/; - frontmatter 的
link继续控制公开 URL; - 未翻译文章的 fallback 和 canonical 不变;
- GFM、Shiki、KaTeX、Mermaid、Pagefind、RSS、sitemap、robots 都要正常生成。
这轮先不把 Fonts API、CSP 和轻后台一起塞进迁移。Live Content Collections 不是“不适合”,恰恰是升级 Astro 6 的理由之一; 只是要先把内容身份、历史数据和迁移路径收稳,再往上接动态能力,不然出了问题很难判断是框架迁移还是新功能。
依赖不用全部追最新
核心依赖最后落在这些版本:
| 依赖 | 迁移前 | 迁移后 |
|---|---|---|
astro | 5.16.6 | 6.4.8 |
@astrojs/react | 4.4.2 | 5.0.7 |
@astrojs/rss | 4.0.14 | 4.0.19 |
@astrojs/sitemap | 3.7.0 | 3.7.3 |
shiki | 3.22.0 | 4.3.1 |
astro-mermaid | 1.2.0 | 2.1.0 |
astro-pagefind | 1.8.5 | 1.8.6 |
这里忍住了把 astro-pagefind 升到 2.x 的冲动。2.x 支持 Astro 6,但搜索组件也换成了 Web Component API,继续升就得连搜索样式、
键盘操作和 i18n 一起重写。
1.8.6 已经补了 Astro 6 的 peer 支持,又保留旧 UI。对这次迁移来说,它比“最新版本”更合适。
真正的改动:内容 ID 不再等于公开 slug
旧项目使用 src/content/config.ts、post.slug 和 post.render()。Astro 6 改成了显式 loader,配置移到
src/content.config.ts,Zod 也从 astro/zod 导入:
import { defineCollection } from 'astro:content';
import { glob } from 'astro/loaders';
import { z } from 'astro/zod';
const blogCollection = defineCollection({
loader: glob({
pattern: '**/[^_]*.{md,mdx}',
base: './src/content/blog',
}),
schema: z.object({
title: z.string(),
// 其他 frontmatter...
}),
});API 不难改,容易改错的是内容身份。
在这个项目里,几个名字很像,实际不是一回事:
entry.id:Markdown 相对路径,给内部查找用;- 公开 slug:经过 locale 处理和字符转写后,出现在 URL 里;
frontmatter.link:用户显式指定的永久链接,只覆盖公开 slug。
因此只替换真正属于 Content Collection 的身份读取:
export function getPostLocale(post: BlogPost): string {
return getSlugLocaleInfo(post.id).locale;
}
export function getPostSlug(post: BlogPost): string {
return post.data.link ?? transliterateSlug(getSlugLocaleInfo(post.id).localeFreeSlug);
}
路由里传内部 ID,再使用新的顶层 render():
import { render } from 'astro:content';
return posts.map((post) => ({
params: { slug: getPostSlug(post) },
props: { postId: post.id },
}));
const post = await getPostById(postId, locale);
if (!post) throw new Error(`Post not found: ${postId}`);
const { Content } = await render(post);Astro 6 里 body 的类型允许 undefined,摘要提取也要顺手兜一下:
extractTextFromMarkdown(post.body ?? '', maxLength);
不能全仓把 slug 替换成 id。搜索结果、相似文章和系列导航里也有 slug,它们表达的是公开路径。机械替换能过类型检查,
却可能悄悄改掉线上 URL,这种 bug 比直接 build 失败更烦。
376 篇文章,不可能让用户手改
旧文章里最棘手的是顶层 slug。它以前承担过永久链接语义,Astro 6 的 loader 又开始用文件路径生成 entry ID。
直接删会丢 URL,原样保留则继续混淆两套身份。
所以 Koharu CLI 多了一条正式迁移命令:
# 先看计划,不写文件
pnpm koharu migrate --dry-run
# 自动备份后再执行
pnpm koharu migrate
迁移规则尽量少动文章:
| 文章现状 | 处理方式 |
|---|---|
只有 link | 不改 |
同时有 link 和 slug | 保留 link,删除冗余 slug |
只有 slug | 原位改名为 link |
| 两者都没有 | 使用路径;译文优先跟随默认语言的稳定链接 |
| 出现重复 URL 或多个候选链接 | 整批停止,交给用户决定 |
实现时给它加了几条底线:
- 扫描所有
.md和.mdx,不假设目录结构永远整齐; - 只编辑目标字段,不重新序列化整段 YAML;
- 解析错误、空链接、重复 URL 和符号链接都会在写入前阻止整批迁移;
- 写入前再次检查文件和配置有没有变化,避免拿过期计划覆盖用户刚改的文章;
- 单文件使用临时文件加原子 rename,后续失败则逆序回滚;
- 正式执行前自动备份,再跑一次 dry-run 必须是 0 项变更。
多语言文章也不能只看文件名。默认语言文章如果把 note/old-name 改成了 my-stable-url,英文译文就应该跟着这个公开链接,
而不是重新从自己的路径算一个。反过来,默认语言没有显式 link 时,路径就是已经存在的 URL,也不能被译文反向覆盖。
pnpm dev 和 pnpm build 现在都会先跑 pnpm koharu migrate --check。它只检查,不会擅自改文章;发现旧字段或冲突时,
直接在 Astro 启动前给出迁移提示。
还有一个进程边界很容易忽略:旧版的 pnpm koharu update 运行时,内存里还是旧 CLI。必须等它完全退出,再启动新命令:
pnpm koharu update
pnpm koharu migrate --dry-run
pnpm koharu migrate
不要指望正在运行的旧进程突然学会更新后才出现的 migrate 子命令。
旧备份也不能继续 cp -r
内容迁完后又发现一个老问题:旧恢复逻辑会把备份目录直接合并进新目录。
假设新版模板多了一篇示例文章,而旧备份里没有它。普通复制不会删除这篇文章,恢复完成后,用户站点就会凭空多出模板内容。 文件系统觉得自己成功了,用户不会。
所以恢复要区分快照和配置:
| 恢复对象 | 恢复方式 |
|---|---|
src/content/blog | 清空目标后恢复,必须与备份快照一致 |
public/img | 同样按快照替换,避免已删除图片残留 |
src/pages/*.md | 替换独立 Markdown 页面,保留新版 .astro 路由 |
config/ | 合并恢复,既还原用户配置,也接住新版配置项 |
| summaries、similarities、LQIP | 只跟随完整备份恢复 |
新备份 manifest 使用 schemaVersion: 2,没有版本字段的旧归档仍按 v1 读取。恢复前会检查 manifest、归档内容、文件类型和符号链接;
解压后也不会立刻覆盖,而是先准备候选快照、跑内容迁移,全部通过再切换。中途失败就回滚。
基础备份不包含摘要、相似文章和 LQIP,恢复后需要重新生成:
pnpm koharu generate all
完整备份才会带上这些与文章快照配套的派生数据。
Build 里依次炸出的四个坑
1. Markdown 配置换入口了
旧配置把 gfm、remarkPlugins 和 rehypePlugins 直接放在 markdown 下,Astro 6.4 会给弃用提示。
这个项目的 Markdown 管线又很长,直接删肯定不行,最后改成显式 processor:
import { unified } from '@astrojs/markdown-remark';
markdown: {
processor: unified({
gfm: true,
remarkPlugins,
rehypePlugins,
}),
syntaxHighlight: {
type: 'shiki',
excludeLangs: ['mermaid'],
},
}Shoka 语法、链接嵌入、Mermaid、KaTeX 和 Shiki 都继续走原来的处理顺序。
2. react-tweet 的 CSS 没进 Vite
第一次生产构建直接报:
Unknown file extension ".css"
Hint: add the package to vite.resolve.noExternal
旧的 vite.ssr.noExternal 没覆盖 Astro 6 的预渲染 environment。按错误提示挪到这里:
vite: {
resolve: {
noExternal: ['react-tweet'],
},
}
3. RSS 撞上 Zod 4
@astrojs/rss 4.0.14 会报:
z.function(...).returns is not a function
不是 feed 数据坏了,而是旧版本还在调用 Zod 4 已移除的 API。升级到 4.0.19 后,默认、英文和日文三份 RSS 都恢复了。
4. i18n 配置保留了一个不存在的行为
原配置同时使用 prefixDefaultLocale: false 和 redirectToDefaultLocale: true,Astro 6 不再接受。
先回头查 Astro 5 基线,发现 /zh/ 和 /zh/post/* 本来就是 404,根本没有重定向需要兼容。于是删掉无效配置,
继续保留 /rss.xml、/en/rss.xml 和 /ja/rss.xml 这三条正式 feed URL。
这也是为什么迁移前要留基线:有些“兼容配置”只是看起来像在工作。
怎么确认站点没被悄悄改坏
先比较加入本文之前的模板产物:
| 指标 | Astro 5 | Astro 6 |
|---|---|---|
| 静态页面 | 130 | 130 |
| Pagefind 索引页面 | 134 | 134 |
dist/ 路由产物 | 141 | 141 |
| Astro 构建耗时 | 21.62s | 21.76s |
数字对上只是第一层,还检查了:
/、/en/、/ja/和代表性文章返回 200,/zh/继续 404;- fallback 页面仍显示默认语言正文,canonical 仍指向默认语言 URL;
- 生成 HTML 里能找到 Pagefind body、Shiki、KaTeX 和 Mermaid 标记;
- Pagefind 实际查询可用,RSS、sitemap 和 robots 正常生成;
- pnpm 10.28.2 可以用 frozen lockfile 干净安装并完成生产构建。
迁移脚本另外有 42 个测试,专门覆盖重复链接、危险 frontmatter、符号链接、并发编辑、恶意归档、写入失败回滚,以及把旧归档 恢复到带有新版模板内容的目录。测的是数据边界,不是 CLI 文案。
最后再拿一份长期使用的真实仓库做恢复:
| 检查项 | 结果 |
|---|---|
| 历史 Markdown / MDX | 376 篇 |
已有稳定 link | 375 篇 |
| 需要自动补齐 | 1 篇 |
| 同语言 URL 冲突 | 0 |
| 恢复后的内容数量 | 376 篇 |
astro check | 0 error / warning / hint |
| Astro 6 静态页面 | 797 |
| Pagefind 索引页面 | 801 |
这个结果比 fixture 更有说服力:新版模板示例没有残留,375 篇文章不用重写,只有 1 篇需要补 link。旧文章里原有的 KaTeX、
Shiki 语言别名和外链抓取 warning 仍然存在,但没有阻止类型检查和 797 个页面的生产构建。
先守住静态,再接轻后台
当前版本仍然默认走 static + nginx,但这是默认部署方式,不是未来架构的上限。
我想要的轻后台必须是选配:
- 不启用后台时,博客继续完整静态构建,部署方式和现在一样;
- 启用后,再通过 Live Content Collections、按需渲染或缓存能力接动态内容;
- 发布入口也不必绑死在传统 CMS,后面可以接 Bot、AI 助手或其他自动化流程。
v5.0.0 本身会是一轮破坏性升级,但“有破坏性变更”不等于“把迁移成本丢给用户”。内容结构和内部 API 可以重做,已有文章、 永久链接和备份必须尽量自动过桥。迁移脚本也不是写完几组 fixture 就算结束,至少要先拿自己的 376 篇历史文章完整跑通, 确认恢复、迁移和生产构建都没问题,才敢发正式版本。
其他新能力还是拆开处理:
- Fonts API 值得单独研究,但现有博客已经有约 20 MB 的中日文字体分片和按 locale 加载;
- CSP 会碰到 ClientRouter、Shiki、inline script 和第三方资源,应该单独做;
- Sonda 继续通过
ANALYZE=true pnpm build按需开启。
Rust compiler 和 queued rendering 看起来只要一行配置,还是同机各跑了一次:
| 模式 | real time | Astro time | 最大 RSS |
|---|---|---|---|
| 默认 | 25.29s | 22.89s | 2.30 GB |
| queued rendering | 26.60s | 24.48s | 2.36 GB |
| Rust compiler 0.3.1 | 26.24s | 23.66s | 2.47 GB |
单次结果不算严谨 benchmark,但已经足够回答“要不要留在主干”:这套项目上没看到收益,于是配置和依赖都删了。
最后整理出的迁移顺序
如果项目也有历史内容和多语言路由,我会按这个顺序再做一次:
- 记下 Node、check、build、路由集合和代表性 HTML 基线;
- 锁定目标 major 和包管理器版本,不直接追
latest; - 升 Astro 与官方 integrations,先看第一批真实错误;
- 单独处理 content config、
entry.id、公开 slug 和render(entry); - 对历史文章先 dry-run,有冲突就整批停;
- 正式迁移放在自动备份后,再跑一次 dry-run 验证幂等;
- 分清备份里的快照目录与合并配置,恢复后跑同一套迁移;
- 最后比较路由、HTML、Pagefind、RSS,并用一份真实旧备份做完整构建。
astro-koharu 用户实际只需要记住这几行:
pnpm koharu update
pnpm koharu migrate --dry-run
pnpm koharu migrate
pnpm check
pnpm build
先等 update 完全退出,再运行后面的新版 CLI。已有 link 会原样保留,旧 slug 会转成 link,忘了迁移时 dev/build 的只读检查
也会先拦下来,不会静默改文章。
最后
这次升级真正理清的是三件事:entry.id 管内部查找,公开 slug 管 URL,link 只在用户明确指定时覆盖后者。文章和图片是用户快照,
配置则需要在恢复用户值的同时接住新版能力。
版本号从 5 变成 6 不难,pnpm build 变绿也不算结束。等一份有 376 篇历史文章的旧备份能无损恢复、URL 不漂、生产构建完成,
这次迁移才算真的跑通。
Astro 6 也不是终点。它只是先把静态博客的地基换稳,再给 Live Content Collections、可选轻后台和爱酱代发博客留一扇门: 平时继续安静地生成静态页面,想折腾时也不会被纯静态架构锁死。
If you enjoyed this, leave a comment~