从分散到统一:六个 Astro 站点合并为一个 Monorepo 的迁移实践
背景
rikolab 最早由一个开源主页及若干独立的工具站点组成。起初每个站点采用独立 Git 仓库、独立依赖与独立部署流程。随着站点数量增加至 7 个,多仓库架构的维护瓶颈逐渐显现:每次修改共用的 SEO 规则、国际化字典或站点元数据,都需要在所有仓库中重复修改并逐个提交发布,重复配置与同步成本居高不下。
为了彻底解决这一问题,本次工程改造将分散的独立仓库整合为一个统一的 Monorepo(单体代码仓库):
- 根目录统一管理代码,各子站点收纳至
apps/目录; - 提取公共逻辑与全局配置至
shared/目录; - 包管理统一收敛至
pnpm workspace; - 部署平台迁移至 Cloudflare Pages,配合 GitHub Actions 脚本根据每次提交的改动路径实现受影响站点的按需差异化构建。
本文将系统梳理本次架构迁移前后的对比、技术选型权衡、实施细节,以及迁移过程中遇到的典型构建问题与解决方案。
一、迁移前后的架构对比
1.1 仓库组织与依赖管理
| 维度 | 迁移前 (Multi-repo) | 迁移后 (Monorepo) |
|---|---|---|
| 仓库数量 | 7 个独立的 Git 仓库 | 1 个统一的 Monorepo |
| 工作区结构 | 顶层目录无版本控制,子目录各带 .git | 顶层根目录作为统一 Git 仓库 |
| 依赖管理 | 每个站点各自安装,依赖版本分散容易漂移 | 根目录 pnpm workspace 统一锁定与去重 |
| 公共配置 | 跨站点复制粘贴代码 | 统一抽取至 shared/ 集中维护 |
1.2 部署链路与发布体验
| 维度 | 迁移前 | 迁移后 |
|---|---|---|
| 部署托管 | 多平台分散或手工脚本上传 | Cloudflare Pages 统一托管 |
| 触发机制 | 本地手动构建或单独触发 | 推送到 main 分支全自动触发 |
| 构建范围 | 人工判断需要发布的站点 | CI 脚本根据改动路径自动计算受影响的 App |
| 流水线结构 | 各站点割裂的配置 | GitHub Actions 按差异动态生成 Job Matrix |
| 域名解析 | 各自离散配置 | 统一从中央元数据派生子域名映射 |
1.3 整合后的目录结构
rikolab/
├── apps/
│ ├── main/ # 个人主页与产品聚合站
│ ├── lumino/ # 独立工具站 1
│ ├── wakeupscreen/ # 独立工具站 2
│ ├── macnewfile/ # 独立工具站 3
│ ├── vibewebhelper/ # 独立工具站 4
│ └── xpathtools/ # 独立工具站 5
├── shared/
│ ├── astro/ # 共享的 Astro 布局与组件
│ ├── i18n/ # 国际化语言检测与字典
│ ├── seo/ # 统一的 SEO Meta、Canonical 规则
│ └── site-metadata/ # 统一站点定义与域名映射元数据
├── scripts/
│ └── ci/ # 差异化构建与矩阵生成脚本
├── .github/
│ └── workflows/ # 统一部署流水线
├── pnpm-workspace.yaml
└── package.json
其中一个体量极小的独立法务页面直接并入主站的 public/ 静态目录,整体收敛为 1 个主站与 5 个独立工具站点,不再维护分散的项目孤岛。
二、技术选型与架构考量
2.1 为什么选择 Monorepo
将多个独立站点合并至单一仓库,核心目标是解决复用与原子化更新问题:
- 共享代码无需发包:原本跨站复用工具函数只有“复制代码”或“发布私有 npm 包”两种路径。在 Monorepo 下,
shared/中的模块可通过相对路径或 workspace 别名直接引用,无版本发布周期与升级磨合成本。 - 原子化变更:当需要调整一个公共配置字段(例如全站更新统计 ID 或调整 canonical 链接格式)时,单个 Commit 即可覆盖所有受影响的站点,避免跨仓库修改时由于遗漏或时序不一致引发线上问题。
- 维护基线统一:Node 运行时、包管理器版本、构建流水线以及全局依赖(如 TypeScript、ESLint)均可在根目录统一锁定。
对于维护者集中且站点体量适中的个人与小型团队,Monorepo 带来的维护效率提升明显高于仓库体积增加带来的轻微开销。
2.2 Cloudflare Pages 的接入路径选择
Cloudflare Pages 对多站点的支持主要有两种实现路径:
- 路径 A:基于 Pages 原生 Git 集成
每个 Pages 项目直接绑定 GitHub 仓库,指定Root Directory对应各自的子目录。该方式虽然配置直观,但每个项目固定监听某一子目录,无法根据代码改动差异灵活组合构建,且受限于免费版单一账户下的 Git 绑定配额。 - 路径 B:基于外部 CI 构建并由 Wrangler 推送产物(选用方案)
将构建与分发逻辑完全移交至 GitHub Actions,编译完成后通过 Cloudflare 官方的wrangler pages deployCLI 直接将静态产物推送到对应的 Pages 项目。
选用路径 B 具备更高的工程灵活性:触发规则、路径差异分析以及并发构建矩阵完全由我们掌控,同时能够规避平台侧的集成约束。
三、实施核心:差异化构建与共享层设计
3.1 统一技术栈基线
| 层次 | 选型 | 作用与定位 |
|---|---|---|
| 运行时 | Node.js 24 | 本地与 CI 统一的 JavaScript 运行时 |
| 包管理 | pnpm 10 (workspace) | 多项目依赖共享与精确版本锁定 |
| 站点框架 | Astro 5 | 静态 HTML 内容生成与图片优化 |
| 自动化集成 | GitHub Actions | 路径检测、矩阵编译与发布触发 |
| 静态托管 | Cloudflare Pages + Wrangler | 边缘 CDN 分发与产物部署 |
3.2 基于改动路径的按需构建
在 Monorepo 中,如果每次提交代码都对所有子项目全量构建,CI 的运行时间将随着站点数量增加而线性膨胀。因此必须引入精细化的改动识别机制。
在流水线中通过自定义脚本 scripts/ci/changed-apps.mjs 读取本次 Commit 的文件差异列表(changed files),按如下规则判定构建范围:
| 改动文件路径 | 构建行为 |
|---|---|
仅改动 apps/<name>/ 目录内部文件 | 仅构建 该特定的子站点 <name> |
改动 shared/、根目录 package.json 或 CI 流水线本身 | 全量构建 所有 6 个子站点 |
仅改动 docs/、README.md 等非构建文件 | 跳过构建,不触发任何部署任务 |
确定受影响的 App 列表后,通过 deploy-matrix.mjs 将其转换为 GitHub Actions 的动态 Job Matrix,各个站点的编译与部署任务即可实现并行执行。
3.3 共享配置层的抽取
将散落的配置提炼至 shared/ 目录,涵盖四个核心模块:
site-metadata:定义各站点的基础信息、独立域名与 CDN 缓存规则,作为各站构建与 CI 脚本的单一真实数据源(Single Source of Truth);seo:统一 Open Graph、Twitter Card 及规范链接(Canonical URL)的元标签生成函数;i18n:统一浏览器语言嗅探、本地存储与国际化字典加载逻辑;astro:共享通用头部、尾部组件与公共 CSS 样式。
四、迁移过程中的典型问题与修复
在将 7 个历史工程合并入统一 Workspace 的过程中,遇到并解决了如下典型工程问题:
4.1 pnpm 版本声明冲突
- 现象:GitHub Actions 报错
Multiple versions of pnpm specified。 - 原因:根目录
package.json中已通过 Corepack 声明了packageManager: "pnpm@10.0.0",而在 GitHub Actions 的pnpm/action-setup步骤中又显式指定了version: 10,导致版本解析冲突。 - 修复:移除 Action 配置文件中的
version参数,统一由package.json保持唯一的版本声明。
4.2 缺失 pnpm-lock.yaml 导致 CI 缓存中断
- 现象:CI 提示
Dependencies lock file is not found并中断执行。 - 原因:Workflow 中配置了
cache: pnpm,但初次提交代码时根目录未同步提交由 Workspace 生成的pnpm-lock.yaml。 - 修复:在根目录下执行
pnpm install生成统一的 lockfile 并提交至仓库。
4.3 @astrojs/sitemap 依赖版本不兼容
- 现象:多个应用在执行
astro build时崩溃,报错Cannot read properties of undefined (reading 'reduce')。 - 原因:
@astrojs/sitemap@3.7.2在当前配置组合下存在解析缺陷,所有启用 Sitemap 插件的站点均受到波及。 - 修复:在各 App 中将
@astrojs/sitemap版本锁定回稳定的3.2.1。得益于 Monorepo 结构,一次性提交即可修复全站的构建异常。
4.4 pnpm 10 对原生扩展构建脚本的拦截
- 现象:主站构建过程中,Astro 图片优化模块报错
Could not find Sharp。 - 原因:pnpm 10 引入了更严格的安全策略,默认拦截非白名单依赖的原生构建脚本,导致
sharp与esbuild的编译产物未正确链接。 - 修复:在根目录
package.json中配置onlyBuiltDependencies放行构建白名单:"pnpm": { "onlyBuiltDependencies": [ "esbuild", "sharp" ] }
4.5 间接依赖解析不稳定
- 现象:放行编译脚本后,主站依然无法稳定加载 Sharp 模块。
- 原因:主站对
sharp存在强依赖,但仅通过 Astro 间接引入。在 pnpm 的严格依赖隔离机制下,深层传递依赖的解析可能受环境影响。 - 修复:在主站
apps/main/package.json中显式声明sharp为直接依赖,消除解析不确定性。
4.6 共享函数更名导致的运行时残留
- 现象:本地开发与构建无异常,但在浏览器打开页面时控制台报错
ReferenceError: normalizeBrowserLang is not defined。 - 原因:重构国际化模块时,将底层方法收敛为
detectPreferredLang,但个别页面的客户端内嵌脚本中残留了对旧函数名的直接调用。 - 修复:全局检索旧函数名,统一更新为新接口。在抽取共享模块时,应对代码库进行彻底的符号检索,规避此类未被构建期捕获的运行时隐患。
五、Cloudflare Pages 部署实践与配额规避
5.1 绕过项目绑定数量限制
Cloudflare Pages 在传统的 Git 自动集成模式下,免费账户对绑定的站点数量存在一定限制。针对 6 个独立站点的发布需求,我们采用了更优的 Token 部署方案:
- 在 Cloudflare 控制台分别为 6 个站点创建对应的 Pages 项目(如
rikolab-main、rikolab-lumino等); - 在 GitHub 仓库 Secrets 中注入
CLOUDFLARE_ACCOUNT_ID与CLOUDFLARE_API_TOKEN; - CI 编译出产物后,通过 Wrangler 指令定向发布:
npx wrangler pages deploy apps/<name>/dist --project-name rikolab-<name>
该方案将代码存储与部署托管彻底解耦,不仅解除了项目绑定的数量约束,也使得后续新增站点时只需简单声明元数据并由 CI 自动完成推送,无须侵入现有的部署流水线。
六、总结
将多个独立站点合并至单一 Monorepo 并统一接入 Cloudflare Pages,是对前端资产的一次系统性收敛。回顾本次迁移,其核心收益在于:
- 维护成本显著降低:消除跨项目重复样板代码,共享逻辑一处修改、全局生效;
- 发布链路全面可控:基于改动路径的按需构建机制,既保障了构建效率,又免除了人工介入判断发布范围的不确定性;
- 基础设施高性价比:利用 Cloudflare Pages 与 GitHub Actions 的免费资源额度,搭建起高可用、全球加速且维护成本极低的现代化静态站矩阵。
对于维护若干独立工具站或内容产品的团队与个人而言,当项目数量达到一定规模时,及早将分散的孤岛仓库整合为结构清晰的 Monorepo,是一项值得投入且长期受益的基础工程改造。