迁移之前,我们团队的日常是这样的:改一个公共组件,要在 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 进来。
#!/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""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,最后是三个应用。
为什么这个顺序很重要?因为每合进一个仓库,我们都会跑一次 pnpm install 和 tsc --build 来验证当前状态是否正常。如果先合应用层,它依赖的 shared-components 还没进来,类型检查和构建都会挂。从叶子节点开始,每一步合进来的仓库都能在当前 monorepo 里正常构建,出了问题也能立刻定位是哪个仓库的迁移引入的。
我们中间有一次没按顺序,把 app-admin 提前合了进来。结果 pnpm install 时它依赖的 @xxx/shared-components 在 workspace 里找不到,pnpm 直接去 npm registry 拉了线上旧版本,构建倒是过了,但类型对不上——线上版本还没有我们本地最新加的几个 props。排查了一个多小时才意识到是顺序的问题。
迁完 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:*:
// 替换前
{ "@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 强制统一了关键依赖:
// 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"
}
}
}
pnpm overrides 像一把大锤——不管子 package 声明的是什么版本,最终安装的都是 overrides 指定的。
这里踩了一个坑:app-h5 从 React 17 直接拉到 18.3.1 之后,用了 ReactDOM.render 的入口文件控制台疯狂报 warning。React 18 要求换成 createRoot,连带着一些依赖 ReactDOM.render 的第三方库(我们用的一个老版本富文本编辑器)也得升级。这部分额外花了两天,如果一开始就列出每个仓库的 React 大版本差异,可以提前评估工作量。
TypeScript 项目引用
代码和依赖都在一起了,但 TypeScript 还不知道怎么跨 package 做类型检查。需要给每个子包配 composite: true 和 references,在根目录的 tsconfig.json 里把所有子项目串起来。
// 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 里显式声明哪些环境变量影响构建:
{
"globalEnv": ["NODE_ENV"],
"tasks": {
"build": {
"env": ["VITE_API_BASE", "VITE_APP_VERSION"]
}
}
}
只有这些变量参与 hash 计算,CI 和本地之间 CI、GITHUB_SHA 之类的差异不再影响缓存。这一步改完命中率直接从 30% 跳到 50%。
元凶二:生成文件污染 inputs(50% → 65%)
我们的构建流程会从 OpenAPI spec 自动生成 src/generated/api-types.ts。这个文件在 src/** 的 glob 范围内,每次生成即使内容没变,文件时间戳也会更新,Turborepo 就认为 inputs 变了。
解法是把代码生成拆成独立的 Turborepo 任务:
{
"tasks": {
"codegen": {
"inputs": ["openapi.yaml"],
"outputs": ["src/generated/**"]
},
"build": {
"dependsOn": ["codegen", "^build"],
"inputs": ["src/**", "!src/generated/**", "tsconfig.json"],
"outputs": ["dist/**"]
}
}
}
codegen 和 build 各管各的缓存。openapi.yaml 没变就不重新生成,src 没变就不重新构建,两者互不干扰。
元凶三:锁文件变动的连锁反应(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 从 5 分钟膨胀到 25 分钟
8 个仓库合成一个之后,CI 从原来每个仓库 3-5 分钟,变成全量跑 25 分钟。原因是 CI 默认 pnpm install 装所有依赖,turbo build 构建所有包——哪怕这次 PR 只改了 app-admin 的一个按钮颜色。
解法是用 Turborepo 的 --filter 配合 Git diff,只跑受影响的包:
bash复制
turbo build test --filter='...[origin/main]'
这行命令的意思是:找出相对于 main 分支有文件变动的包,以及依赖这些包的下游包,只对它们跑 build 和 test。改了 app-admin 的按钮颜色,就只构建 app-admin,3 分钟搞定。改了 shared-components,会自动触发所有引用它的应用一起构建。
改完之后,80% 的 PR 的 CI 时间回到了 3-8 分钟,只有改公共包的 PR 才需要 15 分钟左右。
坑二:IDE 卡到怀疑人生
8 个仓库的代码放到一个 VS Code workspace 里,TypeScript Language Server 直接吃满 4GB 内存,输入一个字符要等 2-3 秒才有自动补全。
两个办法缓解:
第一,在 .vscode/settings.json 里做减法。关掉 typescript.preferences.includePackageJsonAutoImports(这个功能会扫描所有 node_modules 来生成 import 建议),把 node_modules、dist、.turbo、.next 目录加到 files.watcherExclude 和 search.exclude 里,减少文件系统监听的压力。
第二,靠 TypeScript 的 Project References。开了 composite: true 之后,TS Server 不会一次性加载全部子项目的源码,而是按需加载——打开 app-admin 的文件时只加载它直接依赖的 shared-components 和 shared-utils 的类型声明(.d.ts),不加载其他应用的代码。内存占用从 4GB 降到了 1.5GB 左右,自动补全延迟也回到了可接受的范围。
坑三:新人 onboarding 成本翻倍
仓库有 4000+ commit 历史,pnpm install 要装 2000+ 个包(8 个项目的依赖加起来),turbo build 第一次全量构建要跑 8-10 分钟。新人第一天 clone 下来,面对这个规模会有点懵——"我只负责 admin 后台,为什么要装移动端 H5 的依赖?"
我们最终沉淀了一套 onboarding 流程:
- 用
git clone --depth=1浅克隆,不拉 4000 条历史,clone 时间从 3 分钟降到 20 秒 - 根目录放了一个
setup.sh,一键完成pnpm install+turbo build全量构建,同时填充本地 Turborepo 缓存 - 之后日常开发只需要
pnpm turbo build --filter=@xxx/app-admin构建自己负责的应用,增量构建通常 10 秒以内
另外在根目录的 README.md 里画了一张包依赖关系图(用 mermaid 生成),新人看一眼就知道 app-admin 依赖了哪些内部包,改了 shared-utils 会影响哪些应用。
迁移三个月后的数据对比
| 指标 | 迁移前(8 个仓库) | 迁移后(Monorepo) |
|---|---|---|
| 跨仓改动耗时 | 半天(npm link + 发版 + 更新) | 10 分钟(改完直接引用) |
| CI 平均时长 | 3-5 分钟/仓库,但跨仓要手动触发多个 | 3-8 分钟(单包),15 分钟(公共包) |
| 版本冲突频率 | 每周 2-3 次 | 基本消失(workspace 协议 + overrides) |
| 依赖安装时间 | 每个仓库各装一遍,总计 15 分钟+ | 一次 pnpm install,3 分钟 |
| 新人上手时间 | 1 天(配 8 个仓库的开发环境) | 半天(一个仓库,一个 setup 脚本) |
回头看,最值得的不是构建速度的提升,而是跨仓改动的心理负担没了。以前改公共组件要发版、要通知下游、要确认版本号,现在就是正常提交代码,CI 自动帮你验证所有下游是否兼容。
最坑的部分是 React 17 → 18 的升级,和远程缓存命中率的调优。这两个加起来占了迁移总工作量的一半。如果你的团队也在考虑迁 Monorepo,建议先花一天时间梳理所有仓库的核心依赖版本差异,提前评估升级工作量——这个信息决定了你应该给迁移留多少 buffer。