Monorepo 工程化实战:Turborepo 与 pnpm 工作空间深度应用

2026-06-02 00:13 Monorepo 工程化实战:Turborepo 与 pnpm 工作空间深度应用已关闭评论

Monorepo 工程化实战:Turborepo 与 pnpm 工作空间深度应用

结论先行:如果你团队有 3 个以上的前端/Node.js 项目共享代码,Monorepo + pnpm + Turborepo 是目前综合成本最低、收益最高的工程方案。我用这套组合把一个 6 个独立仓库的混乱体系重构为单仓库管理,CI 时间从 40 分钟降到 8 分钟。


一、我为什么跳进 Monorepo 这个坑

2023 年底,我接手了一个"中型前端项目群"——6 个独立仓库,共享一套 UI 组件库和工具函数库。每个库都发 npm 包,版本号飞涨,依赖散落各地。

典型的一天是这样过的:

  1. 在组件库里改了 Button 的一个 props,发版 v2.14.3
  2. 切到业务项目 A,升级依赖,跑测试,发现类型报错
  3. 修复,发版 v2.14.4
  4. 业务项目 B 的 CI 挂了,因为锁定了 v2.14.1

这就是"多仓库地狱"——代码是共享的,但工作流不是。我需要一个方案让所有项目活在同一个仓库里,共享同一套依赖树,改完代码立刻生效,完全不需要经历版本发布。

选型做了两周,最终敲定:pnpm workspace + Turborepo

为啥不是 Lerna? 维护状态过去两年一直暧昧不清,虽然 2023 年被 Nx 接手,但社区信任还没恢复。为啥不是 Nx? 太重了,它的插件生态和依赖图对我来说是过度抽象。Turborepo 够轻、够快、心智负担小。


二、目录结构设计:先想清楚怎么分

定了工具之后,第一个问题:代码往哪放?

我花了三天画目录结构。核心原则是按职责切分,不按项目切分。最终长这样:

packages/
├── apps/
│   ├── web-main/           # 主站应用(Next.js)
│   ├── admin-panel/        # 后台管理(Vite + React)
│   └── landing-page/       # 落地页(Astro)
├── packages/
│   ├── ui/                 # UI 组件库
│   ├── utils/              # 通用工具函数
│   ├── api-client/         # API 客户端封装
│   ├── eslint-config/      # ESLint 共享配置
│   └── tsconfig/           # TypeScript 共享配置
├── tooling/
│   └── scripts/            # 项目级脚本
├── pnpm-workspace.yaml     # pnpm 工作空间配置
├── turbo.json              # Turborepo 配置
└── package.json            # 根级配置

关键设计决策:

  • apps/ 放可部署的应用,每个应用是独立的可构建产物
  • packages/ 放可复用的内部包,按功能分,不按应用分
  • tooling/ 放工程化工具,跟业务代码做物理隔离

教训: 我曾经把 ui 包叫 @company/ui-lib,后来重构时才发现,lib 后缀是冗余信息。内部包命名就用 @scope/name,简洁才容易被团队记住。


三、pnpm 工作空间搭建:最关键的几个文件

3.1 pnpm-workspace.yaml

packages:
  - 'packages/apps/*'
  - 'packages/packages/*'
  - 'packages/tooling/*'

只有一行,但决定了一切。

3.2 .npmrc

shamefully-hoist=true
strict-peer-dependencies=false
auto-install-peers=true

shamefully-hoist 让我能从 monorepo 包里直接引用没声明为直接依赖的包——这有争议,但对迁移期间兼容旧代码很有用。新项目建议关掉它

3.3 根级 package.json

{
  "private": true,
  "scripts": {
    "dev": "turbo dev",
    "build": "turbo build",
    "lint": "turbo lint",
    "test": "turbo test",
    "format": "prettier --write .",
    "changeset": "changeset",
    "ci:version": "changeset version",
    "ci:publish": "changeset publish"
  },
  "devDependencies": {
    "turbo": "^2.0.0",
    "@changesets/cli": "^2.27.0",
    "prettier": "^3.2.0"
  },
  "packageManager": "pnpm@9.0.0"
}

四、Turborepo 配置:缓存才是灵魂

很多教程把 Turborepo 当"任务编排器"讲,但对我来说 Turborepo 最大的价值是缓存。同样的输入不会执行两遍。

4.1 turbo.json 核心配置

{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["src/**", "tsconfig.json"],
      "outputs": ["dist/**", ".next/**"]
    },
    "test": {
      "dependsOn": ["build"],
      "inputs": ["src/**", "vitest.config.ts"],
      "outputs": []
    },
    "lint": {
      "dependsOn": ["^build"],
      "inputs": ["src/**", ".eslintrc.js"],
      "outputs": []
    },
    "dev": {
      "cache": false,
      "persistent": true
    }
  }
}

说几个我踩过的坑:

坑 1:dependsOn 里的 ^ 前缀

  • "^build" 表示"所有依赖的 build 执行完后才执行我的 build"
  • "build" 表示"等我自己的 build 运行一遍"
  • 不加 ^ 前缀,Turborepo 会并行执行所有包的 build,依赖还没构建好就执行,直接报错

坑 2:inputs 要精确

  • 一开始我没写 inputs,随便改个 README 就让整个缓存失效
  • 明确只跟踪 src/**tsconfig.json,其他变化不影响缓存命中

坑 3:远程缓存需要付费

  • Turborepo 的远程缓存(Vercel Remote Cache)是付费功能
  • 小团队可以用 turbo-server 自建,但我选择只用本地缓存
  • CI 环境中可以用 TURBO_CACHE_KEY 让缓存跨分支共享(下面会讲)

4.2 包之间的依赖声明

在每个包的 package.json 中:

// packages/packages/ui/package.json
{
  "name": "@company/ui",
  "dependencies": {
    "@company/utils": "workspace:*",
    "react": "^18.0.0"
  }
}

workspace:* 是 pnpm 的关键语法。它告诉 pnpm:"这个依赖就在本仓库里,不要从 npm registry 下载。" 发布时 pnpm 会自动把它替换为实际版本号。

注意: 别写 workspace:^1.0.0 或者硬编码版本号,就用 workspace:*。pnpm 会在 pnpm publish 时自动处理版本替换。


五、TypeScript 配置:三份 tsconfig 的哲学

Monorepo 里 TypeScript 的路径映射是核心痛点。我的方案是三层继承:

根级 tsconfig.json(基础配置)

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "jsx": "react-jsx",
    "esModuleInterop": true,
    "skipLibCheck": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  }
}

包级 tsconfig.json(继承根级 + 路径映射)

// packages/packages/ui/tsconfig.json
{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src"]
}

应用级 tsconfig.json(继承根级 + 引用)

// packages/apps/web-main/tsconfig.json
{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "paths": {
      "@company/ui": ["../../packages/ui/src"],
      "@company/utils": ["../../packages/utils/src"]
    }
  },
  "references": [
    { "path": "../../packages/ui" },
    { "path": "../../packages/utils" }
  ]
}

为什么不用 TypeScript Project References 的自动模式? 说实话,references 的体验比我想象中差。VS Code 对 Project References 的支持不够稳定,经常需要手动 tsc --build。大多数时候,paths 配合 moduleResolution: Bundler 就够了。


六、Dev 工作流:本地开发的真实体验

6.1 启动开发环境

# 根目录安装所有依赖(仅一次)
pnpm install

# 启动所有应用的 dev server
pnpm dev

Turborepo 会找到所有包里定义了 dev 脚本的,按依赖拓扑顺序启动。

6.2 在应用 A 中调试组件库代码

这是 monorepo 最大的优势——不需要 npm link,不需要发版

# 我在 web-main 里开发,同时改了 @company/ui 的代码
# 因为 workspace:* 链接,改动立即生效
# TypeScript 类型也能实时检查

但这里有个坑:HMR 热更新在跨包场景下不够稳定

Next.js 对 Turborepo 的 HMR 支持最好,但 Vite 项目引用 workspace 包时,热更新经常失效。我的解决方案:

# 在 ui 包里添加 watch 模式
# packages/packages/ui/package.json
"scripts": {
  "dev": "tsc --watch --preserveWatchOutput",
  "build": "tsc"
}

然后在 turbo.jsondev 任务里保证 ui 包先跑 watch:

"dev": {
  "cache": false,
  "persistent": true,
  "dependsOn": ["^dev"]
}

这样 @company/ui 每次 src 变化就重新编译到 dist,引用它的应用通过文件变化触发 HMR。

6.3 只构建部分包

# 只构建 web-main 及其依赖
turbo build --filter=@company/web-main

# 排除某个包
turbo build --filter=!@company/landing-page

# 运行某个包及其下游依赖的测试
turbo test --filter=@company/ui...

--filter 语法

  • --filter=@company/web-main —— 只这个包
  • --filter=@company/web-main... —— 这个包及其所有依赖(向上)
  • --filter=...@company/web-main —— 所有依赖这个包的项目(向下)
  • --filter=...[HEAD] —— 相对于 git HEAD 有变化的包

七、CI 配置:从 40 分钟到 8 分钟的核心优化

7.1 Turborepo 与 CI 缓存

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  ci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 2  # 重要:Turborepo 需要 git 历史

      - uses: pnpm/action-setup@v3
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'

      - run: pnpm install --frozen-lockfile

      - uses: actions/cache@v4
        with:
          path: |
            node_modules/.cache/turbo
          key: turbo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}

      - run: pnpm lint
      - run: pnpm test
      - run: pnpm build

关键优化点:

  1. fetch-depth: 2:Turborepo 需要上一个 commit 的 hash 来判断哪些文件变了。深度为 1 会导致缓存全部失效
  2. pnpm install --frozen-lockfile:确保 lockfile 一致,CI 中绝对不会意外更新依赖
  3. actions/cache 缓存 Turbo 的本地缓存目录:同类任务复用缓存,即使跨分支也能部分命中

7.2 增量 CI(按需构建)

# 只对发生变化的包及其依赖执行完整 CI
turbo lint test build \
  --filter=...[HEAD] \
  --filter=...${{ github.event.pull_request.base.sha }}

这行命令让 CI 只跑有变更的包。以我的经验,大约 60% 的 PR 只改了 1-2 个包,平均 CI 时间从全量构建的 40 分钟降到 8 分钟。

诚实地说: 这个优化效果取决于你仓库的拆分粒度。如果你把所有代码放一个包,--filter 优化等于没效果。拆得越细,CI 优化收益越大。


八、版本管理与发布:Changesets 出场

Monorepo 的版本管理是第二大痛点。我选 @changesets/cli

8.1 初始化

pnpm add -w -D @changesets/cli @changesets/changelog-github
pnpm changeset init

生成 .changeset/config.json

{
  "$schema": "node_modules/@changesets/config/schema.json",
  "changelog": "@changesets/changelog-github",
  "commit": false,
  "fixed": [],
  "linked": [],
  "access": "public",
  "baseBranch": "main",
  "updateInternalDependencies": "patch",
  "ignore": []
}

8.2 日常工作流

# 1. 开发者创建 changeset
pnpm changeset

# 2. 选择受影响的包,填写 changelog 摘要
#    这会生成一个 .md 文件在 .changeset/ 目录

# 3. 发布时,合并所有 changeset 并更新版本
pnpm ci:version
pnpm ci:publish

核心规则:开发者只需要在第 1 步输入一行文字,后面全是自动化的。

8.3 GitHub Action 自动发布

# .github/workflows/release.yml
name: Release

on:
  push:
    branches: [main]

jobs:
  release:
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pull-requests: write
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v3
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'

      - run: pnpm install --frozen-lockfile
      - run: pnpm build

      - name: Create Release Pull Request
        uses: changesets/action@v1
        with:
          publish: pnpm ci:publish
          version: pnpm ci:version
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

这个 Action 做的事情:

  1. 如果 .changeset/ 目录有未消费的 changeset,自动创建一个 PR("Version Packages")
  2. 合并该 PR 后(即 push 到 main),自动执行 changeset publish 发布

九、踩坑实录:我花了两周填的坑

坑 1:幽灵依赖

# 在 @company/ui 里用到了 lodash
// packages/packages/ui/src/button.tsx
import { debounce } from 'lodash'  # 编译没问题!

因为在 workspace 的根 node_modules 里有 lodash,所有子包都能直接引用——但这是个假象@company/uipackage.json 没有声明 lodash 依赖。

后果: 发布后用户安装 @company/uilodash 不会被安装,运行时直接崩溃。

解决方案:

# 使用 pnpm 的 eslint-plugin 检测
pnpm add -w -D @types/eslint-plugin-import

# .eslintrc.js
module.exports = {
  rules: {
    'import/no-extraneous-dependencies': [
      'error',
      {
        packageDir: [__dirname, `${__dirname}/../../`],
      },
    ],
  },
};

但这个规则配置起来很麻烦。更简单的是加一个 CI 检查:

# 检查所有包的 package.json 是否声明了所有用到的依赖
pnpm exec syncpack list-mismatches

坑 2:Docker 镜像构建

Monorepo 的 Docker 构建有一个经典问题:只复制当前包不够,还得复制它的 workspace 依赖。

错误做法是只复制当前目录:

# ❌ 这样会漏掉 @company/ui 和 @company/utils
COPY packages/apps/web-main ./apps/web-main

正确的多阶段构建:

FROM node:20-alpine AS builder
WORKDIR /app

# 先复制依赖文件(利用 Docker 缓存)
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY packages/packages/ui/package.json ./packages/ui/
COPY packages/packages/utils/package.json ./packages/utils/
COPY packages/apps/web-main/package.json ./apps/web-main/

# 安装依赖
RUN pnpm install --frozen-lockfile

# 复制源码
COPY . .

# 构建
RUN turbo build --filter=@company/web-main

# 生产镜像
FROM node:20-alpine AS runner
COPY --from=builder /app/apps/web-main/.next ./.next
COPY --from=builder /app/apps/web-main/public ./public
COPY --from=builder /app/apps/web-main/package.json ./package.json

CMD ["node", "server.js"]

核心思路:pnpm install 之前,把每个 workspace 包的 package.json 复制进去。这样 Docker 的 layer 缓存能命中——只要 pnpm-lock.yaml 不变,即使改了业务代码也不需要重装依赖。

坑 3:node_modules 膨胀

pnpm 声称"节省磁盘空间",但在 monorepo 场景下,node_modules 的规模仍然大到离谱——我见过一个项目装完超过 5GB。

# 分析 node_modules 大小
du -sh node_modules packages/*/node_modules

优化手段:

  • devDependencies 里非工程化工具移到根级 devDependencies,不要在每个包里重复
  • 定期运行 pnpm dedupe 来合并版本冲突的依赖

十、效果总结:数据和感受

用这套方案 6 个月后,团队的实际数据:

指标 改造前 改造后
仓库数量 6 1
包数量 6 个独立 npm 包 14 个内部包
全量 CI 时间 ~40 min ~12 min
增量 CI 时间 无此概念 ~3-8 min
跨项目修改代码 发版+更新依赖,2 天 1 次 commit,5 分钟
开发者 PR 前置等待 ~1.5 小时/天 ~15 分钟/天
类型错误发现时机 运行时 编译时

最让我意外的反而是团队协作模式的变化——因为所有代码在一个仓库里,前端组的人开始主动去看其他项目的代码,跨项目共享组件和工具的增长速度是过去的 3 倍。工具链的变化改变了团队的文化,这是我没想到的。


十一、说点真话:Monorepo 不是银弹

说了这么多好话,我必须诚实地指出该放弃 monorepo 的场景:

  1. 团队规模 < 3 人,项目 < 2 个 —— 不值得。多仓库 + npm link 就够
  2. 项目之间没有共享代码 —— 那是多项目,不是 monorepo,强行拼在一起只会增加复杂度
  3. 有多个语言/技术栈且没有重叠 —— JS + Rust + Python 在同一个仓库里,工具链互不兼容,CI 配置异常复杂
  4. Org 级的权限隔离要求严格 —— Git 级别的访问控制没有好方案(CODEOWNERS 是事后控制,不是访问控制)
  5. 历史包袱过于沉重的老旧项目 —— 迁移成本可能大于收益

我的建议: monorepo 是"术",不是"道"。当项目间的耦合关系让你感到痛苦时,monorepo 是解药。但如果你还在"加一个组件要不要起一个新仓库"这个阶段纠结,别碰 monorepo,那对你来说是过度工程化。

留个问题给你: 你团队当前的协作瓶颈到底在"代码管理"层面,还是在"沟通流程"层面?如果是后者,换工具解决不了问题。

你可能感兴趣的文章

来源:每日教程每日一例,深入学习实用技术教程,关注公众号TeachCourse
转载请注明出处: https://teachcourse.cn/4200.html ,谢谢支持!

资源分享

分类:Android 标签:
Claude Code添加超级能力Skill Claude Code添加超级能力Skil
008-SQL Server实战经验总结:如何将一对多的两个表中的某些列合并成一行显示? 008-SQL Server实战经验总结
Open Claw 配置 OpenRouter 模型操作手册 Open Claw 配置 OpenRouter
Ubuntu系统ERROR 2002 (HY000) Can not connect to local MySQL server through socket varrunmysqldmysqld Ubuntu系统ERROR 2002 (H

评论已关闭!