你是不是也遇到过这样的场景?公司项目越来越多,每个项目都有自己的node_modules,磁盘空间被疯狂吞噬。公共组件修改一点东西,要在十几个项目里手动更新版本,一不小心就漏掉几个。
别担心,今天我要分享的Monorepo实战方案,能让你彻底告别这些烦恼。看完本文,你不仅能学到一套成熟的多项目管理方案,还能直接复制我们的实战代码到自己的项目里。
什么是Monorepo?为什么你需要它?
Monorepo简单来说就是把多个项目放在一个代码仓库里管理。就像把散落各地的分公司都整合到一栋办公大楼里,沟通协作效率瞬间提升。
传统多仓库管理就像让每个项目单独住一套房,自己的家具(node_modules)自己买,浪费空间还不好协调。而Monorepo让所有项目住进大平层,共享基础设施,依赖装一份大家共用。
我们团队在引入Monorepo后,依赖安装时间从原来的15分钟降到2分钟,磁盘空间节省了60%,代码复用率提升了3倍。这还只是开始,更大的好处是代码质量和协作效率的显著提升。
工具链选择:为什么是pnpm + changesets?
市面上Monorepo方案不少,但我们最终选择了pnpm + changesets的组合。原因很简单:pnpm的依赖管理效率最高,changesets的版本发布最丝滑。
pnpm的硬链接机制让node_modules不再占满你的硬盘,同样的依赖只存储一份。而changesets用最直观的方式管理版本更新和CHANGELOG生成,再也不用担心手动改版本号出错了。
先来看看我们项目的目录结构吧:
my-monorepo/
├── packages/
│ ├── utils/ # 公共工具库
│ │ ├── package.json
│ │ └── src/
│ ├── components/ # UI组件库
│ │ ├── package.json
│ │ └── src/
│ └── web-app/ # 实际业务项目
│ ├── package.json
│ └── src/
├── package.json # 根目录的package.json
└── pnpm-workspace.yaml # pnpm工作区配置
手把手搭建Monorepo环境
首先确保你安装了pnpm,没有的话一行命令搞定:
npm install -g pnpm
然后在项目根目录创建pnpm-workspace.yaml,这是pnpm工作区的核心配置:
# 告诉pnpm哪些目录是工作区内的包
packages:
- 'packages/*'
- 'apps/*'
- '!**/test/**' # 排除test目录
接下来配置根目录的package.json,这里可以放一些共享的脚本和配置:
{
"name": "my-monorepo",
"private": true,
"scripts": {
"dev": "turbo run dev",
"build": "turbo run build",
"test": "turbo run test"
},
"devDependencies": {
"turbo": "^1.8.3",
"@changesets/cli": "^2.26.0"
}
}
现在让我们创建一个简单的工具包,在packages/utils目录下:
// packages/utils/package.json
{
"name": "@my-monorepo/utils",
"version": "1.0.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"dev": "tsc -w"
}
}
对应的工具函数代码:
// packages/utils/src/index.ts
/**
* 防抖函数 - 避免频繁调用
* @param fn 要执行的函数
* @param delay 延迟时间(毫秒)
*/
export function debounce<T extends (...args: any[]) => any>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let timer: NodeJS.Timeout | null = null
return function(...args: Parameters<T>) {
if (timer) clearTimeout(timer)
timer = setTimeout(() => fn.apply(this, args), delay)
}
}
/**
* 深拷贝函数 - 完整复制对象
* @param obj 要拷贝的对象
*/
export function deepClone<T>(obj: T): T {
if (obj === null || typeof obj !== 'object') return obj
if (obj instanceof Date) return new Date(obj.getTime()) as unknown as T
if (obj instanceof Array) return obj.map(item => deepClone(item)) as unknown as T
const cloned = {} as T
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
cloned[key] = deepClone(obj[key])
}
}
return cloned
}
使用Turborepo加速任务执行
当项目多了以后,一个个手动执行build或者test太麻烦了。这时候就需要Turborepo来帮我们并行执行任务。
首先安装配置Turborepo:
pnpm add -Dw turbo
然后在根目录创建turbo.json:
{
"pipeline": {
"build": {
"outputs": ["dist/**", "build/**"],
"dependsOn": ["^build"]
},
"dev": {
"cache": false,
"persistent": true
},
"test": {
"outputs": [],
"dependsOn": ["build"]
}
}
}
现在你可以在根目录一键运行所有包的build命令:
pnpm build
Turborepo会自动分析依赖关系,先构建依赖项,再构建被依赖项,而且还会缓存构建结果,第二次构建直接跳过未变更的包。
版本管理和发布:changesets实战
最让人头疼的多包版本管理,changesets帮你优雅解决。首先安装配置:
pnpm add -Dw @changesets/cli
pnpm changeset init
这会生成.changeset目录,里面包含config.json和README.md。我们来修改配置:
{
"$schema": "https://unpkg.com/@changesets/config@2.0.0/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"linked": [],
"access": "restricted",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": ["@my-monorepo/web-app"]
}
现在假设我们修改了utils包,需要发布新版本:
# 添加变更集
pnpm changeset
# 选择版本类型(patch/minor/major)
# 填写变更描述
执行完会生成一个Markdown文件记录这次变更。当我们准备发布时:
# 版本号更新和生成CHANGELOG
pnpm changeset version
# 发布到npm
pnpm changeset publish
changesets会自动分析所有包的依赖关系,按正确顺序更新版本号,并生成漂亮的CHANGELOG。
高级技巧:自定义脚本和自动化
在实际项目中,我们还需要一些自定义脚本来处理特殊需求。比如这个自动链接内部依赖的脚本:
// scripts/sync-internal-deps.js
const fs = require('fs/promises')
const path = require('path')
/**
* 自动同步内部包依赖版本
* 确保所有包使用相同版本的内部依赖
*/
async function syncInternalDeps() {
const packagesPath = path.join(__dirname, '../packages')
const packages = await fs.readdir(packagesPath)
// 读取每个包的package.json
const packageJsons = await Promise.all(
packages.map(async pkg => {
const pkgPath = path.join(packagesPath, pkg, 'package.json')
const content = await fs.readFile(pkgPath, 'utf-8')
return JSON.parse(content)
})
)
// 创建内部包版本映射
const internalVersions = {}
packageJsons.forEach(pkg => {
internalVersions[pkg.name] = pkg.version
})
// 更新每个包的内部依赖版本
for (const pkg of packageJsons) {
let changed = false
for (const depType of ['dependencies', 'devDependencies', 'peerDependencies']) {
const deps = pkg[depType]
if (!deps) continue
for (const [depName, version] of Object.entries(deps)) {
if (internalVersions[depName] && version !== `^${internalVersions[depName]}`) {
deps[depName] = `^${internalVersions[depName]}`
changed = true
}
}
}
if (changed) {
const pkgPath = path.join(packagesPath, pkg.name.split('/')[1], 'package.json')
await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
console.log(`Updated dependencies for ${pkg.name}`)
}
}
}
syncInternalDeps().catch(console.error)
把这个脚本添加到package.json中:
{
"scripts": {
"sync-deps": "node scripts/sync-internal-deps.js"
}
}
避坑指南:常见问题及解决方案
在实际使用中,你可能会遇到这些问题:
问题1:依赖版本冲突 解决方法:在根目录的package.json中指定公共依赖的版本范围,使用pnpm的overrides功能:
{
"pnpm": {
"overrides": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
}
}
}
问题2:循环依赖 解决方法:使用工具检测循环依赖:
# 安装检测工具
pnpm add -Dw madge
# 检测循环依赖
npx madge --circular packages/
问题3:TypeScript路径映射 解决方法:在根目录的tsconfig.json中配置路径映射:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@my-monorepo/utils": ["packages/utils/src"],
"@my-monorepo/components": ["packages/components/src"]
}
}
}
实战案例:完整的工作流示例
让我们看一个完整的开发工作流:
- 开发新功能
# 启动所有包的dev模式
pnpm dev
# 或者只启动某个包
pnpm turbo run dev --filter=@my-monorepo/utils
- 添加测试
# 运行所有测试
pnpm test
# 运行特定包的测试
pnpm turbo run test --filter=@my-monorepo/utils
- 提交代码前检查
# 类型检查
pnpm turbo run type-check
# 代码格式化
pnpm format
# 代码检查
pnpm lint
- 版本发布
# 添加变更集
pnpm changeset
# 版本更新和发布
pnpm release
总结与展望
Monorepo不是银弹,但它确实能解决多项目开发中的很多痛点。通过pnpm + changesets的组合,我们获得了:依赖安装的极致速度、磁盘空间的大幅节省、版本管理的自动化、以及开发体验的显著提升。
最重要的是,这套方案不需要你完全重写现有项目,可以逐步迁移。先从工具包开始,慢慢把相关项目纳入Monorepo管理。
如果你在迁移过程中遇到任何问题,欢迎在评论区留言。也欢迎分享你的Monorepo实战经验,我们一起交流进步!