Monorepo 管理

What — 什么是 Monorepo

Monorepo 是将多个相关项目(包、应用、库)放在同一个 Git 仓库中管理的策略,共享工具链、依赖和 CI 配置。

Monorepo vs Multirepo

维度MonorepoMultirepo
代码位置一个仓库多个仓库
包共享直接引用发布 npm
原子提交一个 PR 改多个包多个 PR 跨仓库
构建缓存跨包共享各自独立
CI/CD统一流水线每个仓库独立
权限控制统一精细

核心工具

工具定位特点
pnpm workspace包管理 + 工作区硬链接节省磁盘,workspace 协议
Turborepo构建编排增量构建 + 本地/远程缓存
Nx全功能平台依赖图分析 + 增量构建 + 插件
Lerna历史方案发布管理(已被 Nx 接管)
Changesets版本管理多包联动发版

Why — 为什么选择 Monorepo

1. 代码共享零延迟

包之间直接 import { Button } from '@my/ui',无需 npm publishnpm install,修改即生效。

2. 原子提交

一个 PR 同时修改组件库和消费应用,确保兼容性,不会出现”组件库更新了但应用还没适配”的问题。

3. 统一工具链

ESLint、TypeScript、Prettier、CI 配置全仓库共享,无需每个仓库重复配置。

4. 增量构建

Turborepo / Nx 自动分析依赖图,只构建变化的包及其下游,大型仓库构建时间从 30 分钟降到 2 分钟。


How — 怎么用

1. pnpm Workspace 搭建

# pnpm-workspace.yaml
packages:
  - 'apps/*'
  - 'packages/*'
// package.json(根目录)
{
  "name": "my-monorepo",
  "private": true,
  "scripts": {
    "dev": "turbo dev",
    "build": "turbo build",
    "test": "turbo test",
    "lint": "turbo lint"
  },
  "devDependencies": {
    "turbo": "^2.0.0",
    "typescript": "^5.5.0"
  }
}
my-monorepo/
├── apps/
│   ├── web/            — Web 应用
│   ├── admin/          — 后台管理
│   └── mobile/         — 移动端
├── packages/
│   ├── ui/             — 组件库
│   ├── utils/          — 工具库
│   ├── config/         — 共享配置(ESLint / TS)
│   └── api-client/     — API 客户端
├── pnpm-workspace.yaml
├── turbo.json
├── package.json
└── tsconfig.json

2. 包之间的引用

// apps/web/package.json
{
  "name": "@my/web",
  "dependencies": {
    "@my/ui": "workspace:*",
    "@my/utils": "workspace:*",
    "@my/api-client": "workspace:*"
  }
}
// packages/ui/package.json
{
  "name": "@my/ui",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "exports": {
    ".": { "import": "./dist/index.mjs", "require": "./dist/index.js", "types": "./dist/index.d.ts" },
    "./button": { "import": "./dist/button.mjs", "types": "./dist/button.d.ts" }
  },
  "scripts": {
    "build": "tsup src/index.ts --format esm,cjs --dts",
    "dev": "tsup src/index.ts --format esm,cjs --watch"
  },
  "peerDependencies": {
    "react": ">=18"
  }
}

workspace:* 在开发时链接到本地包,发布时自动替换为真实版本号。

3. Turborepo 配置

// turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "globalDependencies": ["**/.env.*local"],
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**", "!.next/cache/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "lint": {
      "dependsOn": ["^build"]
    },
    "test": {
      "dependsOn": ["build"],
      "outputs": ["coverage/**"]
    },
    "type-check": {
      "dependsOn": ["^build"]
    }
  }
}

任务依赖说明

配置含义
"dependsOn": ["^build"]先构建所有依赖包,再构建当前包
"dependsOn": ["build"]先执行当前包的 build,再执行当前任务
"outputs": ["dist/**"]缓存输出的文件
"persistent": true长驻进程(如 dev server)
"cache": false不缓存(如 dev)

Turborepo 缓存原理

输入哈希 = hash(源文件 + 依赖包输出 + 环境变量 + turbo.json)
→ 缓存命中?→ 直接复制缓存输出
→ 缓存未命中?→ 执行任务 → 存入缓存

4. 共享 TypeScript 配置

// packages/config/tsconfig.base.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  }
}
// packages/ui/tsconfig.json
{
  "extends": "@my/config/tsconfig.base.json",
  "compilerOptions": {
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src"]
}

5. 共享 ESLint 配置

// packages/config/eslint/index.js
module.exports = {
  extends: [
    'eslint:recommended',
    'plugin:@typescript-eslint/recommended',
    'plugin:react-hooks/recommended',
    'prettier',
  ],
  rules: {
    '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
    '@typescript-eslint/no-explicit-any': 'warn',
  },
}

6. Changesets — 多包发版

pnpm add -Dw @changesets/cli
npx changeset init
// .changeset/config.json
{
  "access": "restricted",
  "baseBranch": "main",
  "updateInternalDependencies": "patch"
}
# 1. 创建变更记录
npx changeset
# 交互式选择:哪些包有变更?是 patch/minor/major?变更说明?

# 2. 消费变更记录,更新版本号和 CHANGELOG
npx changeset version

# 3. 发布
pnpm changeset publish

CI 自动发版

# .github/workflows/release.yml
name: Release
on:
  push:
    branches: [main]
jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v2
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: 'pnpm' }
      - run: pnpm install --frozen-lockfile
      - run: pnpm build
      - run: pnpm test
      - name: Create Release Pull Request or Publish
        uses: changesets/action@v1
        with: { publish: pnpm changeset publish }
        env: { GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}, NPM_TOKEN: ${{ secrets.NPM_TOKEN }} }

7. Turborepo 远程缓存

# 启用 Vercel 远程缓存(免费)
npx turbo login
npx turbo link

# 现在 CI 的构建结果会缓存到 Vercel
# 本地开发也能命中 CI 的缓存

常见问题与踩坑

问题原因解决方案
循环依赖包 A 依赖包 B,包 B 也依赖包 A重构提取共享部分到包 C
构建顺序错没有正确配置 dependsOn确保 ^build 声明依赖
缓存失效源文件没变但输出不同检查 globalDependencies,是否遗漏环境变量
发布版本不一致workspace 协议未正确解析用 Changesets 管理版本
IDE 跳转不对TypeScript 项目引用未配置配置 tsconfig.jsonreferences

最佳实践

  1. workspace 协议:包间依赖用 workspace:*,确保本地链接。
  2. 构建顺序交给 Turborepo:不要手动编排构建顺序,用 dependsOn
  3. Changesets 管理发版:多包联动发版,避免手动改版本号。
  4. 共享配置包:TypeScript、ESLint、Prettier 配置提取到 packages/config
  5. 远程缓存:开启 Turborepo 远程缓存,CI 和本地共享构建结果。

面试题

workspace:* 是 pnpm workspace 的协议,在开发时将依赖链接到本地工作区包,发布时自动替换为真实版本号。与 npm link 的区别:(1) 自动化——workspace:* 声明在 package.json 中,安装即链接,无需手动执行命令;(2) 发布安全——发布时 pnpm 自动将 workspace:* 替换为实际版本号(如 1.2.3),而 npm link 创建的是符号链接,发布时可能导致包引用了一个本地路径;(3) 一致性——所有开发者 clone 后 pnpm install 即获得正确的链接,无需额外操作。


2. Turborepo 的增量构建是怎么实现的?

:基于输入哈希的缓存机制:(1) 为每个 task 计算输入哈希——源文件内容(git hash)、依赖包的 task 输出(通过 dependsOn: ["^build"] 递归追踪)、环境变量、turbo.json 配置;(2) 哈希相同 → 缓存命中 → 直接复制 outputs 目录中的文件,跳过执行;(3) 哈希不同 → 执行任务 → 将 outputs 存入缓存。远程缓存(Vercel)让 CI 的构建结果可以共享给本地开发者。效果:修改 @my/ui 只会重建 @my/ui + 依赖它的 @my/web,其他包直接用缓存。


3. Monorepo 中如何避免循环依赖?

:循环依赖(A 依赖 B,B 依赖 A)会导致构建失败和不可预测的行为。避免方法:(1) 架构分层——明确定义依赖方向(apps → features → entities → shared),不允许反向依赖;(2) 提取共享模块——如果 A 和 B 都需要对方的某个功能,将共享部分提取到 C 包;(3) 工具检测——使用 madgedependency-cruiser 自动检测循环依赖,集成到 CI;(4) Nx 的模块边界规则——配置 enforce-module-boundaries ESLint 规则,在开发时阻止非法依赖。


4. Changesets 和 lerna version 有什么区别?

:Changesets 和 lerna version 都管理 Monorepo 的多包发版,但机制不同:(1) 记录方式——Changesets 在 .changeset/ 目录中创建独立的 Markdown 文件记录变更,lerna version 通过 git commit 信息推断版本变化;(2) 灵活性——Changesets 允许每次变更独立选择包和版本级别(patch/minor/major),lerna version 通常统一处理;(3) CI 集成——Changesets 有官方 GitHub Action,自动创建 Release PR,审核后自动发布;lerna 需要 --conventional-commits 配合;(4) 维护状态——Changesets 活跃维护,lerna 已停止维护(被 Nx 接管)。新项目推荐 Changesets。


5. pnpm 的硬链接机制为什么能节省磁盘空间?

:pnpm 将所有包安装在全局存储(~/.pnpm-store/),项目中的 node_modules 通过硬链接指向全局存储。同一个包(如 React 18.2.0)在全局只存一份,10 个项目使用 React 也只占一份空间。而 npm/yarn 每个项目独立安装,10 个项目 = 10 份 React。硬链接是文件系统级别的——多个目录条目指向同一个磁盘数据块,不占用额外空间。pnpm 还使用符号链接创建非扁平的 node_modules 结构,解决幽灵依赖问题(只能 import 声明过的依赖)。


相关链接