从混乱到秩序:用pnpm+changesets搞定Monorepo,效率提升300%

206 阅读5分钟

你是不是也遇到过这样的场景?公司项目越来越多,每个项目都有自己的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"]
    }
  }
}

实战案例:完整的工作流示例

让我们看一个完整的开发工作流:

  1. 开发新功能
# 启动所有包的dev模式
pnpm dev

# 或者只启动某个包
pnpm turbo run dev --filter=@my-monorepo/utils
  1. 添加测试
# 运行所有测试
pnpm test

# 运行特定包的测试
pnpm turbo run test --filter=@my-monorepo/utils
  1. 提交代码前检查
# 类型检查
pnpm turbo run type-check

# 代码格式化
pnpm format

# 代码检查
pnpm lint
  1. 版本发布
# 添加变更集
pnpm changeset

# 版本更新和发布
pnpm release

总结与展望

Monorepo不是银弹,但它确实能解决多项目开发中的很多痛点。通过pnpm + changesets的组合,我们获得了:依赖安装的极致速度、磁盘空间的大幅节省、版本管理的自动化、以及开发体验的显著提升。

最重要的是,这套方案不需要你完全重写现有项目,可以逐步迁移。先从工具包开始,慢慢把相关项目纳入Monorepo管理。

如果你在迁移过程中遇到任何问题,欢迎在评论区留言。也欢迎分享你的Monorepo实战经验,我们一起交流进步!