Chapter 05

一个仓库装下多个包

Web App + 移动端 + 共享组件库 + 后端 + 设计系统,想放一个仓库里一起开发——这就是 monorepo。pnpm workspaces 让多个 package.json 共享同一个 lockfile 和 node_modules store,包间 symlink 引用,一次 install 装齐。

Monorepo 的诉求

最小布局

my-mono/
├── pnpm-workspace.yaml          ← 声明哪些目录是包
├── package.json                 ← 根 package.json(私有)
├── pnpm-lock.yaml               ← 全仓唯一 lockfile
├── packages/
│   ├── ui/
│   │   ├── package.json         ← @myorg/ui
│   │   └── src/
│   ├── utils/
│   │   ├── package.json         ← @myorg/utils
│   │   └── src/
│   └── config/
│       ├── package.json         ← @myorg/config
│       └── eslint.config.js
└── apps/
    ├── web/
    │   ├── package.json         ← @myorg/web(Next.js)
    │   └── src/
    └── mobile/
        ├── package.json         ← @myorg/mobile(Expo)
        └── app/

pnpm-workspace.yaml

packages:
  - 'packages/*'
  - 'apps/*'
  - '!**/test/**'         # 排除测试目录

只支持 glob 模式,不支持 JS 配置——简单但够用。

根 package.json

{
  "name": "my-mono",
  "private": true,
  "packageManager": "pnpm@9.12.0",
  "scripts": {
    "dev": "pnpm -r --parallel dev",
    "build": "pnpm -r build",
    "test": "pnpm -r test",
    "lint": "pnpm -r lint"
  },
  "devDependencies": {
    "typescript": "^5.6.0",
    "prettier": "^3.3.3"
  }
}
private: true 很重要
根 package.json 是"伞",不应该发布到 npm。private: true 告诉 pnpm 和 npm 发不出去(pnpm publish 会拒绝)。子包视需要设 public/private。

子包 package.json

// packages/ui/package.json
{
  "name": "@myorg/ui",
  "version": "0.0.1",
  "main": "./src/index.ts",
  "types": "./src/index.ts",
  "dependencies": {
    "@myorg/utils": "workspace:*",
    "react": "^18.3.1"
  }
}

workspace 协议

workspace:*         ← 用 workspace 里的任意版本(最灵活)
workspace:^         ← 用 workspace 版本 + 发版时替换成 ^x.y.z
workspace:~         ← 类似,替换成 ~x.y.z
workspace:1.2.3     ← 必须精确版本

workspace: 协议强制告诉 pnpm「这个依赖必须从 workspace 里找」——如果写 "^0.0.1" 也能匹配到 workspace 里的包,但不够严格,容易意外从 npm 下载。

pnpm install 自动 link

pnpm install    # 在根目录运行
apps/web/node_modules/
├── react                        → .pnpm/react@18.3.1/...
└── @myorg/
    └── ui                       → ../../../packages/ui   (symlink 指向源码!)

跨 workspace 的引用,pnpm 直接 symlink 到源码目录——改代码实时生效,无需 build。

包间脚本协调

# 所有 workspace 运行 build
pnpm -r build

# 所有 workspace 并行跑 dev
pnpm -r --parallel dev

# 只跑某个 workspace
pnpm --filter @myorg/web dev

# 上面简写
pnpm -F @myorg/web dev

-r(recursive)

-r 对每个 workspace 执行一次命令,按依赖拓扑排序:被依赖的先跑。

pnpm -r build
# 1. @myorg/utils build
# 2. @myorg/config build
# 3. @myorg/ui build(依赖 utils、config,等它们完)
# 4. @myorg/web build(依赖 ui,等它完)

--parallel 忽略拓扑,全并发,适合 dev 启动多个进程(例如 Next dev + Storybook)。

在根目录给子包加依赖

pnpm add --filter @myorg/web axios
# 只在 apps/web 加 axios

pnpm add -Dw typescript
# -w 表示加到根 workspace(monorepo 共享 devDep)

root-workspace-package vs filter

场景加在哪
TS、Prettier、ESLint 配置包根 devDependencies(-Dw)
React(被多个 app 用)各自的 package.json,用 catalog 统一版本(第 7 章)
某个 app 私有的那个 app 的 package.json(-F app)

包间拓扑感知安装

# 只装 @myorg/web 和它依赖的所有 workspace
pnpm install --filter "@myorg/web..."

# 只装 @myorg/ui 和反向依赖它的 workspace
pnpm install --filter "...@myorg/ui"

共享 tsconfig

// packages/tsconfig/base.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "jsx": "react-jsx"
  }
}
// apps/web/tsconfig.json
{
  "extends": "@myorg/tsconfig/base.json",
  "include": ["src"],
  "compilerOptions": {
    "outDir": "dist"
  }
}

Prettier、ESLint 同理——每个 app 的 config 文件 require('@myorg/config/eslint')

TS 包引用(project references)

// packages/ui/tsconfig.json
{
  "references": [{ "path": "../utils" }],
  "compilerOptions": { "composite": true }
}

TS 能「增量编译」关联的包,不用每次全量 type-check。对大 monorepo 是性能关键。

发布:多包发一次

Changesets 是 pnpm monorepo 标配的版本/发布工具:

pnpm add -Dw @changesets/cli
pnpm changeset init
pnpm changeset          # 交互式记录本次改动
pnpm changeset version  # 按记录的 changeset 升版本
pnpm publish -r          # 发到 npm(只发 public 的)
pnpm publish 自动展开 workspace 协议
"dep": "workspace:^" 在发版那一刻被替换成真实版本号 "^1.2.3"——对外是标准 npm 包,对内保持 workspace 链接。这是 pnpm 的关键胶水。

Changesets 的工作流

  1. 写代码,pnpm changeset 描述改动 + 选版本升级类型(patch/minor/major)
  2. PR merge,CI 运行 changeset version 更新 package.json 版本 + 生成 CHANGELOG
  3. CI 下一步 pnpm publish -r 发包

小心:workspace:^ 不等于 workspace:*

workspace:*     发版时替换为 dep 的当前版本(精确)
workspace:^     替换为 ^x.y.z(允许兼容更新)
workspace:~     替换为 ~x.y.z(只允许 patch)

内部库推荐 workspace:^,对外用户拿到 ^x.y.z 灵活升级。如果是只在公司内部的私包,workspace:* 没问题。

真实案例:Vercel 的 Turborepo 官方模板

turborepo-starter/
├── pnpm-workspace.yaml
├── turbo.json                   ← 任务缓存配置
├── apps/
│   ├── web/         (Next.js)
│   └── docs/        (Next.js)
└── packages/
    ├── ui/          (React 组件)
    ├── eslint-config/
    └── typescript-config/

数万个 Vercel 用户在这个模板上跑,验证过 pnpm + Turborepo 的生产表现。下一章继续讲 filter 和依赖图。

本章小结