Notes

Monorepo 与依赖治理:pnpm 和 Turborepo

一句话结论:采用 pnpm workspaces 管理的 Monorepo,并通过 Turborepo 实现智能、高效的任务编排,是现代大型前端项目解决代码复用、依赖管理和工程效率三大挑战的最佳实践。


1. 为什么要使用 Monorepo?

Monorepo 是一个包含多个独立项目或包(packages)的单一代码仓库。与传统的 Polyrepo(每个项目一个仓库)相比,它具有显著优势:

  • 代码复用与共享:跨项目共享通用组件(如 ui)、工具函数(如 utils)或配置(如 eslint-config-custom)变得极其简单,无需发布到 npm。
  • 原子化提交 (Atomic Commits):一次提交可以跨越多个包,轻松完成需要同时修改前端、后端和共享库的复杂功能,保证了代码的一致性。
  • 集中化依赖管理:所有项目共享一个 lockfile,避免了版本冲突,简化了依赖升级。
  • 统一的开发体验:所有项目遵循相同的构建、测试和发布流程,降低了新成员的上手成本。

2. 依赖管理:pnpm Workspaces

pnpm 是一个快速、磁盘空间高效的包管理器。它的 workspaces 功能是实现 Monorepo 的基石。

A. 核心原理

pnpm 使用一种创新的内容寻址存储来管理 node_modules。所有依赖项都被存储在一个全局的、集中的 store 中,然后在每个项目中通过硬链接 (hard links)符号链接 (symlinks) 组合出 node_modules 目录。

  • 磁盘高效:同一个包的同一个版本在磁盘上只存储一份。
  • 安装速度快:如果依赖已存在于 store 中,安装过程几乎是瞬时的。
  • 非扁平化 node_modules:解决了“幻影依赖”问题。你的代码只能访问到在 package.json 中明确声明的依赖。

B. 如何设置

  1. 项目结构
    /
    ├── apps/
    │   ├── web/ (一个 Next.js 应用)
    │   └── docs/ (一个文档网站)
    ├── packages/
    │   ├── ui/ (共享 React 组件库)
    │   ├── utils/ (共享工具函数)
    │   └── eslint-config-custom/
    ├── package.json
    └── pnpm-workspace.yaml
    
  2. pnpm-workspace.yaml:在仓库根目录创建此文件,定义工作区的范围。
    packages:
      - 'apps/*'
      - 'packages/*'
    
  3. package.json
    {
      "name": "my-monorepo",
      "private": true,
      "scripts": {
        "dev": "pnpm --filter web dev", // 仅运行 web 应用的 dev 脚本
        "build": "pnpm -r build" // 在所有包中运行 build 脚本
      },
      "devDependencies": {
        "typescript": "latest",
        // ... 其他共享的开发依赖
      }
    }
    
  4. 本地包链接:现在,apps/web 可以像使用 npm 包一样直接引用 packages/ui
    // apps/web/package.json
    {
      "name": "web",
      "dependencies": {
        "ui": "workspace:*" // "workspace:*" 会自动链接到本地的 ui 包
      }
    }
    

    当你运行 pnpm install 时,pnpm 会在 apps/web/node_modules 中创建一个指向 packages/ui 的符号链接。你在 ui 包中的任何修改都会立即反映在 web 应用中,无需重新发布或链接。

3. 高效任务编排:Turborepo

如果你的 Monorepo 很大,每次都重新构建和测试所有的包会非常耗时。Turborepo 就是解决这个问题的利器。

A. 核心特性

  1. 远程缓存 (Remote Caching):Turborepo 会缓存你的任务(如 build, test)的输出。当你和你的团队成员运行任务时,如果输入(代码、依赖、环境变量)没有改变,Turborepo 会直接从缓存中拉取结果,而不是在本地重新执行。这使得 CI 和本地构建速度快得惊人。
  2. 任务流水线 (Task Pipelines):在 turbo.json 中定义任务之间的依赖关系。例如,deploy 依赖于 buildtest,而 build 又依赖于其内部依赖包的 build。Turborepo 会以最高效的并行度来执行这个任务图。
  3. 智能感知 (Scoped Tasks):Turborepo 知道哪些包的代码发生了变化,只会重新运行受影响的包及其依赖项的任务。

B. 如何设置

  1. 安装 Turborepo
    pnpm add turbo --save-dev -w # -w 表示安装在根工作区
    
  2. 创建 turbo.json
    // turbo.json
    {
      "$schema": "https://turbo.build/schema.json",
      "baseBranch": "origin/main",
      "pipeline": {
        "build": {
          // "build" 任务依赖于所有内部依赖项的 "build" 任务
          "dependsOn": ["^build"],
          // "build" 任务的输出是 .next, dist 和 build 目录
          "outputs": [".next/**", "dist/**", "build/**"]
        },
        "lint": {
          "outputs": [] // lint 不产生持久化文件
        },
        "test": {
          "dependsOn": ["build"],
          "outputs": []
        },
        "dev": {
          "cache": false // 开发服务器不应该被缓存
        }
      }
    }
    
    • ^build: ^ 符号表示“拓扑依赖”,即“我所依赖的所有包的 build 任务”。
  3. 更新 package.json 脚本:使用 turbo 来运行你的脚本。
    // package.json
    {
      "scripts": {
        "dev": "turbo run dev",
        "build": "turbo run build",
        "test": "turbo run test",
        "lint": "turbo run lint"
      }
    }
    

现在,当你运行 pnpm build 时,turbo 会接管一切:它会分析你的依赖图,找出哪些包需要构建,并行地执行它们,缓存结果,并跳过所有未发生变化的工作。


4. 依赖治理

Monorepo 也带来了依赖治理的挑战。

  • 版本统一:尽可能在根 package.json 中定义核心依赖(如 react, typescript)的版本,所有子包通过 peerDependencies 继承。
  • 依赖更新:使用 pnpm up -r --latest 来递归地更新所有包的依赖项。
  • 循环依赖:Monorepo 中很容易出现循环依赖(A 依赖 B,B 又依赖 A)。使用 madge -cnx graph 等工具来检测和可视化依赖关系,及时发现并解决循环依赖问题。

5. 关联阅读

cd ..