迁移之前,我们团队的日常是这样的:改一个公共组件,要在 3 个仓库之间反复 npm link;改完之后走 npm publish 发版,再挨个去下游仓库 npm update;结果经常碰到版本范围匹配出错------^1.2.0 悄悄拉到了 1.3.0,类型对不上,排查半天才发现是另一个同事昨天发的 minor 版本搞的。
这是我们团队 8 个前端仓库并行开发两年之后的真实状况。每次跨仓改动,光是 npm link 和版本对齐就能吃掉半天。终于有一天,Tech Lead 在周会上拍板:"我们迁 Monorepo 吧。"
三个月,无数个坑,8 个仓库最终合进了一个 pnpm workspace + Turborepo 的 Monorepo。
Git 历史迁移:git filter-repo 才是正解
迁移 Monorepo 最纠结的一个决定:要不要保留 Git 历史?
直接把代码复制过来建新仓库最省事,但 git blame 就废了。对于一个有两年历史的项目来说,git blame 几乎是排查问题时的第一反应------"这行代码谁在什么场景下写的"。丢掉历史,等于未来排查问题时少了一个重要线索。
方案对比:subtree merge vs filter-repo
最开始我们试了 git subtree add --prefix=packages/shared-components,看起来很美,但踩了两个坑:历史记录是"拍扁"的,所有 commit 混在主仓库时间线里,git log --follow 对重命名的文件跟踪不了;如果子仓库有 merge commit,合进来之后历史图会变成一团乱麻。
最终选了 git filter-repo。这个工具能在保留完整历史的前提下,批量重写文件路径。
迁移脚本的核心流程
每个仓库的迁移分三步:克隆源仓库到临时目录,用 filter-repo 给所有文件路径加上目标前缀(比如 src/Button.tsx 变成 packages/shared-components/src/Button.tsx,commit 历史中的路径也会同步修改),然后在 monorepo 里把改写后的历史 merge 进来。
bash
#!/bin/bash
# migrate-repo.sh --- 单个仓库的历史迁移
REPO_URL=$1 # 源仓库地址
TARGET_DIR=$2 # 目标路径,如 packages/shared-components
BRANCH=${3:-main}
TEMP_DIR=$(mktemp -d)
git clone --single-branch --branch "$BRANCH" "$REPO_URL" "$TEMP_DIR"
cd "$TEMP_DIR"
# 重写所有 commit 中的文件路径,加上目标目录前缀
git filter-repo --to-subdirectory-filter "$TARGET_DIR" --force
cd /path/to/monorepo
git remote add temp-migrate "$TEMP_DIR"
git fetch temp-migrate
git merge temp-migrate/"$BRANCH" --allow-unrelated-histories \
-m "chore: migrate $TARGET_DIR with full git history"
git remote remove temp-migrate
rm -rf "$TEMP_DIR"
这里有个容易忽略的细节:--allow-unrelated-histories 是必须的。每个源仓库的 commit 树和 monorepo 完全独立,没有共同祖先,Git 默认会拒绝这种合并。
迁移顺序决定了过程的平稳度
我们按依赖拓扑排序,从叶子节点开始:design-tokens 和 eslint-config(零依赖)先进,然后是 shared-utils、shared-components,最后是三个应用。
为什么这个顺序很重要?
迁完 8 个仓库后,monorepo 的 commit 数量从 0 涨到了 4000+,用 git log --oneline | wc -l 验证总数,和各仓库之和对得上。随便挑几个文件跑 git blame,能看到原始仓库的 commit hash、作者和日期,说明历史完整保留了。
跨仓依赖收敛:从 npm 包到 workspace 协议
历史搬完了,代码都在一个仓库里了,但各个 package 的 package.json 还在引用 npm 上的包。要把这些改成 pnpm workspace 的内部引用。
workspace 结构和依赖替换
先在根目录建 pnpm-workspace.yaml,声明 packages/* 和 apps/* 两个目录。然后批量把所有内部包的版本号替换为 workspace:*:
json
// 替换前
{ "@xxx/shared-components": "^1.3.0", "@xxx/utils": "^2.1.0" }
// 替换后
{ "@xxx/shared-components": "workspace:*", "@xxx/utils": "workspace:*" }
workspace:* 告诉 pnpm:这个包就在本地 workspace 里,不要去 npm registry 找。开发时直接引用源码或构建产物,改了立刻生效,不需要发版。发布时 pnpm 会自动把 workspace:* 替换成实际版本号。
外部依赖版本不一致------最耗时的部分
8 个仓库各自装了两年依赖,同一个包的版本五花八门。比如 React:shared-components 用的 ^18.2.0,app-admin 是 ^18.0.0,app-h5 居然还停在 ^17.0.2,app-mini 则是 ^18.3.0。
pnpm workspace 对这种情况还算宽容------每个 package 可以有自己的依赖版本。但版本一致性直接影响 Turborepo 的缓存命中率(后面会展开讲),所以我们用 pnpm overrides 强制统一了关键依赖:
jsonc
// monorepo 根目录 package.json
{
"pnpm": {
"overrides": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"typescript": "~5.4.0",
"lodash": "npm:lodash-es@^4.17.21" // 顺便把 lodash 统一成 lodash-es
}
}
}
pnpm overrides 像一把大锤------不管子 package 声明的是什么版本,最终安装的都是 overrides 指定的。
TypeScript 项目引用
代码和依赖都在一起了,但 TypeScript 还不知道怎么跨 package 做类型检查。需要给每个子包配 composite: true 和 references,在根目录的 tsconfig.json 里把所有子项目串起来。
jsonc
// packages/shared-components/tsconfig.json
{
"compilerOptions": {
"composite": true, // 启用项目引用模式
"declaration": true,
"declarationMap": true // 类型跳转可以定位到源码
},
"references": [
{ "path": "../design-tokens" },
{ "path": "../shared-utils" }
]
}
配好之后,tsc --build 会按依赖顺序增量编译整个 monorepo,只重新编译有变更的包和它的下游。根目录的 tsconfig.json 自己不编译任何文件("files": []),纯粹用来声明子项目拓扑关系。
远程缓存命中率:从 30% 到 85% 的调优过程
Turborepo 的本地缓存在单人开发时够用,但团队协作时需要远程缓存------我在 A 分支构建过的包,你在 B 分支如果没改过,应该能直接复用。我们接入自建 HTTP 缓存服务器后,初始命中率只有 30%。70% 的构建任务在重复劳动,完全没发挥出缓存的价值。
元凶一:环境变量泄漏(30% -> 50%)
Turborepo 默认会把一些环境变量算进 hash。CI 环境有 CI=true、NODE_ENV=production,本地没有,hash 自然不一样,缓存永远命中不了。
解法是在 turbo.json 里显式声明哪些环境变量影响构建:
jsonc
{
"globalEnv": ["NODE_ENV"],
"tasks": {
"build": {
"env": ["VITE_API_BASE", "VITE_APP_VERSION"]
// 只有这些变量参与 hash 计算,其他环境差异不影响缓存
}
}
}
元凶二:生成文件污染 inputs(50% -> 65%)
我们的构建流程会从 OpenAPI spec 自动生成 src/generated/api-types.ts。这个文件在 src/** 的 glob 范围内,每次生成即使内容没变,文件时间戳也会更新,Turborepo 就认为 inputs 变了。
解法是把代码生成拆成独立的 Turborepo 任务:
jsonc
{
"tasks": {
"codegen": {
"inputs": ["openapi.yaml"], // 只有 API 定义变了才重新生成
"outputs": ["src/generated/**"]
},
"build": {
"dependsOn": ["codegen", "^build"],
"inputs": ["src/**", "!src/generated/**", "tsconfig.json"],
"outputs": ["dist/**"]
}
}
}
codegen 和 build 各管各的缓存。openapi.yaml 没变就不重新生成,src 没变就不重新构建,两者互不干扰。命中率到了 65%。
元凶三:锁文件变动的连锁反应(65% -> 80%)
pnpm-lock.yaml 是 Turborepo 默认的全局 input。
这个问题比较棘手。锁文件确实影响构建结果------间接依赖版本变了,构建产物可能不同。但大部分时候,改动只影响一两个包,不应该让整个 monorepo 的缓存全部作废。
我们的妥协方案是把 pnpm-lock.yaml 从 globalDependencies 里拿掉,只保留真正全局的配置(如 tsconfig.base.json)。代价是可能出现间接依赖变化导致的构建差异未被检测到,但我们用 CI 的集成测试兜底。
这是一个不完美的 trade-off。
最后 5%:缓存服务的存储策略(80% -> 85%)
剩下的 miss 大多来自缓存过期。自建缓存服务用的 S3 存储,默认 TTL 7 天。但像 design-tokens、eslint-config 这种几个月都不变的基础包,7 天一过缓存没了又得重新构建。
我们按包的变更频率设了不同的 TTL:design-tokens 和 eslint-config 30 天,shared-utils 14 天,shared-components 7 天(变得比较频繁),其他默认 3 天。再加上 LRU 淘汰策略,S3 bucket 限制在 50GB 以内,命中率稳定在 83%-87%。
迁移之后踩的坑
坑一:monorepo 的 CI 太慢
8 个仓库合成一个之后,CI 从原来每个仓库 3-5 分钟,变成全量跑 25 分钟。原因是 CI 默认安装所有依赖、构建所有包。
解法是用 Turborepo 的 --filter 配合 Git diff,只跑受影响的包:
bash
# 找出相对于 main 有变动的包及其下游,只跑它们的 build 和 test
turbo build test --filter='...[origin/main]'
坑二:IDE 卡顿
8 个仓库的代码放到一个 VS Code workspace 里,TypeScript Language Server 直接吃满内存。两个办法缓解:一是在 .vscode/settings.json 里关掉 includePackageJsonAutoImports,排除 node_modules、dist、.turbo 目录的搜索索引;二是靠 tsconfig.json 的 Project References------开了 composite: true 之后,TS Server 会按需加载子项目而不是一次性全部加载,内存占用好了不少。
坑三:新人 onboarding 成本
仓库有 4000+ commit,pnpm install 要装 2000+ 个包,新人第一天面对这个规模会有点懵。我们的做法是推荐 git clone --depth=1 浅克隆,配合一个 setup 脚本跑一次全量构建填充本地缓存,之后日常开发只需要 pnpm turbo build --filter=@xxx/app-admin 构建自己负责的应用。