skip to content
蕉太狼的博客
目录

Monorepo 工程实践

什么是 Monorepo

Monorepo(Monolithic Repository)是将多个项目/包放在同一个 Git 仓库中管理的工程方式,与之相对的是 Polyrepo(每个项目独立一个仓库)。

Polyrepo Monorepo
───────────────── ─────────────────────────
repo-web/ my-project/
repo-app/ → ├── apps/
repo-shared/ │ ├── web/
repo-utils/ │ └── app/
└── packages/
├── shared/
└── utils/

Monorepo vs Polyrepo

MonorepoPolyrepo
代码共享直接引用,无需发布需发布 npm 包,版本管理繁琐
依赖管理统一安装,避免版本冲突各自维护,同一依赖可能多个版本
原子提交跨包修改一次提交需多仓库协调,难以保证一致性
CI/CD需要智能识别变更范围各自独立,配置简单
仓库体积随项目增多而增大各自独立,体积可控
适用场景关联性强的多包项目完全独立的项目

工具选型

Monorepo 通常需要两层工具配合:

职责工具选项
包管理 / workspacepnpm、npm、yarn
构建编排 / 任务缓存Turborepo、Nx、Rush

目前最主流的组合是 pnpm workspace + Turborepo:pnpm 解决依赖安装和包引用,Turborepo 解决多包构建的任务编排和缓存加速。


pnpm Workspace

pnpm 原生支持 workspace,是 Monorepo 中使用最广泛的包管理器。

初始化项目

Terminal window
mkdir my-monorepo && cd my-monorepo
pnpm init

配置 workspace

在根目录创建 pnpm-workspace.yaml,声明哪些目录是 workspace 包:

pnpm-workspace.yaml
packages:
- 'apps/*'
- 'packages/*'

目录结构

my-monorepo/
├── pnpm-workspace.yaml
├── package.json
├── apps/
│ ├── web/ # 前端应用
│ │ └── package.json
│ └── server/ # 后端服务
│ └── package.json
└── packages/
├── ui/ # 共享组件库
│ └── package.json
├── utils/ # 共享工具函数
│ └── package.json
└── tsconfig/ # 共享 TypeScript 配置
└── package.json

根目录 package.json

package.json
{
"name": "my-monorepo",
"private": true,
"scripts": {
"dev": "turbo dev",
"build": "turbo build",
"lint": "turbo lint",
"typecheck": "turbo typecheck"
},
"devDependencies": {
"turbo": "^2.0.0"
}
}

依赖管理

安装依赖

Terminal window
# 在根目录安装(所有包可用的开发工具,如 eslint、typescript)
pnpm add -D typescript -w
# 在指定包中安装
pnpm add react --filter @my/web
# 在所有包中安装
pnpm add lodash-es --filter='./packages/*'

包之间互相引用

workspace 内的包可以通过 workspace:* 协议直接引用,无需发布到 npm:

apps/web/package.json
{
"name": "@my/web",
"dependencies": {
"@my/ui": "workspace:*",
"@my/utils": "workspace:*"
}
}

安装后,apps/web/node_modules/@my/ui 会是一个软链接,修改 packages/ui 的代码会立即生效,无需重新安装。

执行指定包的命令

Terminal window
# 在指定包中执行命令
pnpm --filter @my/web dev
pnpm --filter @my/ui build
# 在所有包中执行
pnpm -r build
# 在匹配的包中执行
pnpm --filter './packages/*' build

共享配置

共享 TypeScript 配置

将基础 tsconfig 提取成独立包,各项目按需继承:

packages/tsconfig/base.json
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"declaration": true
}
}
packages/tsconfig/package.json
{
"name": "@my/tsconfig",
"version": "0.0.0",
"private": true,
"exports": {
"./base": "./base.json",
"./vite": "./vite.json",
"./nextjs": "./nextjs.json"
}
}

各包继承:

apps/web/tsconfig.json
{
"extends": "@my/tsconfig/base",
"compilerOptions": {
"outDir": "dist"
},
"include": ["src"]
}

共享 ESLint 配置

packages/eslint-config/index.js
module.exports = {
extends: ['eslint:recommended'],
rules: {
'no-console': 'warn',
},
}
packages/eslint-config/package.json
{
"name": "@my/eslint-config",
"version": "0.0.0",
"private": true,
"main": "index.js"
}
apps/web/.eslintrc.json
{
"extends": ["@my/eslint-config"]
}

Turborepo

Turborepo 是专为 Monorepo 设计的高性能构建系统,核心优势是增量构建任务缓存

安装

Terminal window
pnpm add -D turbo -w

配置文件

turbo.json
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"outputs": []
},
"typecheck": {
"dependsOn": ["^build"],
"outputs": []
},
"test": {
"outputs": ["coverage/**"]
}
}
}

dependsOn 依赖关系

dependsOn 控制任务的执行顺序:

写法含义
"^build"先执行所有依赖包build,再执行当前包的 build
"build"先执行当前包自己的 build,再执行当前任务
[]无依赖,可并行执行

构建缓存

Turborepo 会对每个任务的输入(源文件、依赖、环境变量)计算哈希值,如果输入没变,直接复用上次的输出,跳过执行。

Terminal window
# 第一次构建(无缓存)
turbo build
# >>> Finished! (15.2s)
# 再次构建(命中缓存)
turbo build
# >>> FULL TURBO (0.1s)

缓存的输入默认包含:

  • 包内所有文件(可通过 inputs 配置精确指定)
  • 依赖的 package.json 中的版本号
  • 环境变量(需在 env 中声明才纳入)
turbo.json
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"],
"inputs": ["src/**", "package.json"],
"env": ["NODE_ENV", "VITE_API_URL"]
}
}
}

远程缓存

本地缓存只对本机有效,远程缓存可以让团队成员和 CI 共享构建结果:

Terminal window
# 登录 Vercel(官方远程缓存服务,免费)
npx turbo login
# 链接到远程缓存
npx turbo link

也可以自托管远程缓存服务(开源方案:ducktors/turborepo-remote-cache):

turbo.json
{
"remoteCache": {
"apiUrl": "https://your-cache-server.com"
}
}

配置后,CI 第一次跑完的缓存,本地开发者可以直接复用,大幅缩短等待时间。

过滤执行范围

Terminal window
# 只构建指定包及其依赖
turbo build --filter=@my/web
# 只执行受变更影响的包(基于 git diff)
turbo build --filter='[HEAD^1]'
# 组合:只执行 apps 下发生变更的包
turbo build --filter='./apps/...[HEAD^1]'

发布工作流

changesets — 版本管理与发布

Changesets 是 Monorepo 中最常用的版本管理工具:

Terminal window
pnpm add -D @changesets/cli -w
pnpm changeset init

工作流:

Terminal window
# 1. 开发完成后,声明变更
pnpm changeset
# 交互式选择影响的包和版本类型(patch/minor/major),填写变更描述
# 2. 合并到主分支后,统一升版本
pnpm changeset version
# 自动根据 .changeset/ 目录下的文件更新各包版本号和 CHANGELOG
# 3. 发布到 npm
pnpm changeset publish

实践示例:共享组件库

packages/ui 为例,演示一个完整的共享包设置:

packages/ui/package.json
{
"name": "@my/ui",
"version": "0.0.0",
"private": true,
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "vite build",
"dev": "vite build --watch"
},
"devDependencies": {
"@my/tsconfig": "workspace:*",
"vite": "^5.0.0",
"vite-plugin-dts": "^3.0.0"
},
"peerDependencies": {
"react": "^18.0.0"
}
}
packages/ui/src/index.ts
export { Button } from './Button'
export { Input } from './Input'
export type { ButtonProps, InputProps } from './types'

应用中直接引用:

apps/web/src/App.tsx
import { Button } from '@my/ui'

由于 @my/ui 是软链接,开发时修改组件代码后(配合 vite build --watch),应用无需重启即可生效。


常见问题

某个包的依赖提升到了根目录

pnpm 默认会将相同版本的依赖提升到根目录的 node_modules,减少磁盘占用。如果某个包需要独立安装某个依赖版本,在该包的 package.json 中显式声明即可。

循环依赖

packages/a 依赖 packages/bpackages/b 又依赖 packages/a,会导致构建死锁。可以用 turbo run build --graph 可视化依赖图来排查:

Terminal window
turbo run build --graph
# 输出 Graphviz DOT 格式的依赖图,可粘贴到 https://dreampuf.github.io/GraphvizOnline/ 可视化

TypeScript 找不到 workspace 包的类型

确保被引用的包已构建(生成了 dist/.d.ts),或者在 tsconfig.json 中配置 paths 直接指向源码:

apps/web/tsconfig.json
{
"compilerOptions": {
"paths": {
"@my/ui": ["../../packages/ui/src/index.ts"]
}
}
}

其他方案:Git Submodule 与 Git Subtree

在 pnpm workspace 流行之前,Git 原生提供了两种多仓库管理方案。现在基本已被 Monorepo 工具链替代,但了解它们有助于理解问题的演进。

Git Submodule

Submodule 在主仓库中嵌入另一个独立仓库的引用,各自保留独立的 git 历史。

Terminal window
# 添加子模块
git submodule add https://github.com/org/shared-ui.git packages/ui
# 克隆含子模块的仓库(需额外初始化)
git clone https://github.com/org/my-project.git
git submodule update --init --recursive
# 更新子模块到最新提交
git submodule update --remote

主仓库只记录子模块的某一次 commit hash,子模块本身的代码变更需要进入子模块目录单独提交再推送。

Git Subtree

Subtree 将外部仓库的内容直接合并进主仓库,没有额外的引用文件。

Terminal window
# 添加子树
git subtree add --prefix=packages/ui https://github.com/org/shared-ui.git main --squash
# 拉取子树更新
git subtree pull --prefix=packages/ui https://github.com/org/shared-ui.git main --squash
# 将修改推回子树远端
git subtree push --prefix=packages/ui https://github.com/org/shared-ui.git main

相比 Submodule,Subtree 对普通使用者更透明(clone 后直接可用,不需要额外初始化),但推回改动时命令比较繁琐。

三种方案对比

pnpm WorkspaceGit SubmoduleGit Subtree
代码位置同一仓库独立仓库,主仓库存引用代码合并进主仓库
clone 体验直接可用需要 --recursive直接可用
原子提交
独立版本历史部分保留
工具链支持丰富(Turborepo 等)原生 git原生 git
适用场景关联性强的内部多包项目引用不常变动的第三方库源码需要偶尔同步的外部仓库