从多仓到 Monorepo 的渐进式迁移:Git 历史保留、依赖收敛与缓存调优

迁移之前,我们团队的日常是这样的:改一个公共组件,要在 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-tokenseslint-config(零依赖)先进,然后是 shared-utilsshared-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.0app-admin^18.0.0app-h5 居然还停在 ^17.0.2app-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: truereferences,在根目录的 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=trueNODE_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/**"]
    }
  }
}

codegenbuild 各管各的缓存。openapi.yaml 没变就不重新生成,src 没变就不重新构建,两者互不干扰。命中率到了 65%。

元凶三:锁文件变动的连锁反应(65% -> 80%)

pnpm-lock.yaml 是 Turborepo 默认的全局 input。

这个问题比较棘手。锁文件确实影响构建结果------间接依赖版本变了,构建产物可能不同。但大部分时候,改动只影响一两个包,不应该让整个 monorepo 的缓存全部作废。

我们的妥协方案是把 pnpm-lock.yamlglobalDependencies 里拿掉,只保留真正全局的配置(如 tsconfig.base.json)。代价是可能出现间接依赖变化导致的构建差异未被检测到,但我们用 CI 的集成测试兜底。

这是一个不完美的 trade-off。

最后 5%:缓存服务的存储策略(80% -> 85%)

剩下的 miss 大多来自缓存过期。自建缓存服务用的 S3 存储,默认 TTL 7 天。但像 design-tokenseslint-config 这种几个月都不变的基础包,7 天一过缓存没了又得重新构建。

我们按包的变更频率设了不同的 TTL:design-tokenseslint-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_modulesdist.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 构建自己负责的应用。

相关推荐
SuperEugene2 小时前
TypeScript+Vue 实战:告别 any 滥用,统一接口 / Props / 表单类型,实现类型安全|编码语法规范篇
开发语言·前端·javascript·vue.js·安全·typescript
gis开发3 小时前
cesium 中添加鹰眼效果
前端·javascript
bluceli4 小时前
JavaScript动态导入与代码分割:优化应用加载性能的终极方案
javascript
kyriewen4 小时前
原型与原型链:JavaScript 的“家族关系”大揭秘
前端·javascript·ecmascript 6
滴滴答答哒4 小时前
layui表格头部按钮 加入下拉选项
前端·javascript·layui
乌索普-4 小时前
基于vue2的简易购物车
开发语言·前端·javascript
走粥4 小时前
使用indexOf查找对象结合Pinia持久化引发的问题
开发语言·前端·javascript
不甜情歌5 小时前
搞懂 Promise:告别回调嵌套,再也不怕异步代码乱成麻
前端·javascript
spencer_tseng5 小时前
Vue node_modules
javascript·vue.js