用了兩年 Lerna + Yarn workspace 的組合後,今年開始嘗試 pnpm workspace。pNpm 的硬連結機制天然適合 Monorepo——依賴不會重複安裝,磁碟佔用大幅減少。對比下來,pnpm workspace 可能是目前最優雅的 Monorepo 方案。
為什麼選 pnpm
pnpm 的核心優勢在 Monorepo 場景下特別明顯:
- 硬連結儲存:全域性 store + 硬連結,10 個子專案共享同一份依賴,不會像 Yarn v1 那樣每個專案都裝一份
- 嚴格的依賴管理:幽靈依賴問題被徹底解決,package.json 裡沒宣告的依賴用不了
- 原生 workspace 支援:不需要 Lerna 這樣的上層工具,pnpm 自己就能處理
bash
# 安裝速度對比(同一個 Monorepo,30 個子專案)
# npm: ~120s
# yarn v1: ~85s
# pnpm: ~15s
# 磁碟佔用對比
# npm: ~2.1GB
# yarn v1: ~1.8GB
# pnpm: ~600MB(硬連結去重)
專案結構搭建
bash
# 初始化專案
mkdir my-monorepo && cd my-monorepo
pnpm init
# 建立目錄結構
mkdir -p packages/{shared,components,utils}
mkdir -p apps/{admin,portal}
my-monorepo/
├── package.json
├── pnpm-workspace.yaml
├── pnpm-lock.yaml
├── packages/
│ ├── shared/ # 共享業務邏輯
│ │ ├── package.json
│ │ └── src/
│ ├── components/ # 元件庫
│ │ ├── package.json
│ │ └── src/
│ └── utils/ # 工具函式
│ ├── package.json
│ └── src/
├── apps/
│ ├── admin/ # 後臺管理
│ │ ├── package.json
│ │ └── src/
│ └── portal/ # 入口網站
│ ├── package.json
│ └── src/
└── tools/
└── eslint-config/ # 共享 ESLint 配置
核心配置
pnpm-workspace.yaml:
yaml
packages:
- 'packages/*'
- 'apps/*'
- 'tools/*'
根 package.json:
json
{
"name": "my-monorepo",
"private": true,
"scripts": {
"dev": "pnpm --filter admin dev",
"dev:portal": "pnpm --filter portal dev",
"build": "pnpm -r --filter './packages/*' build",
"build:all": "pnpm -r build",
"test": "pnpm -r test",
"lint": "pnpm -r lint"
},
"devDependencies": {
"typescript": "^4.3.0",
"vite": "^2.5.0",
"@vitejs/plugin-vue": "^1.6.0"
}
}
子包 package.json(以 utils 為例):
json
{
"name": "@my-monorepo/utils",
"version": "0.1.0",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "vite build",
"dev": "vite build --watch"
},
"dependencies": {
"dayjs": "^1.10.0"
}
}
子包互相引用
在 Monorepo 中,子包之間互相引用用 workspace: 協議:
json
{
"name": "@my-monorepo/components",
"dependencies": {
"@my-monorepo/utils": "workspace:*",
"@my-monorepo/shared": "workspace:*"
}
}
釋出時 pnpm 會自動將 workspace:* 替換為實際版本號。
typescript
// packages/components/src/Button.vue
<script setup>
import { formatCurrency } from '@my-monorepo/utils'
import { useUserStore } from '@my-monorepo/shared'
const props = defineProps<{ amount: number }>()
const formatted = computed(() => formatCurrency(props.amount))
</script>
pnpm --filter 命令
--filter 是 pnpm workspace 最強大的功能,可以精確控制命令作用範圍:
bash
# 只在 admin 應用中安裝 lodash
pnpm --filter admin add lodash
# 只在 admin 中安裝,但要先構建它依賴的包
pnpm --filter admin... build
# 在 packages/ 下的所有包中執行 test
pnpm --filter './packages/*' test
# 只構建 utils 和依賴 utils 的包
pnpm --filter '@my-monorepo/utils...' build
# 在 admin 中執行 dev,同時 watch 它依賴的本地包
pnpm --filter admin dev
# 執行所有 packages 的 build(按拓撲排序)
pnpm -r --filter './packages/*' build
Vite 構建配置
子包的 Vite 配置,輸出庫模式:
typescript
// packages/utils/vite.config.ts
import { defineConfig } from 'vite'
import { resolve } from 'path'
import dts from 'vite-plugin-dts'
export default defineConfig({
plugins: [
dts({
insertTypesEntry: true
})
],
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'MyUtils',
formats: ['es', 'cjs'],
fileName: (format) => `index.${format === 'es' ? 'mjs' : 'cjs'}`
},
rollupOptions: {
external: ['dayjs'] // 不打包依賴
}
}
})
常見問題
幽靈依賴問題(Phantom Dependencies):
typescript
// ❌ 在 npm/yarn 的 node_modules 扁平結構中,
// 依賴的依賴可以直接 import(幽靈依賴)
import something from 'transitive-dependency'
// ✅ pnpm 的嚴格結構不允許這樣做
// 必須在 package.json 中顯式宣告
// 報錯:Module not found
這是 pnpm 的設計決策,強制正確的依賴宣告。
.npmrc 配置:
ini
# 如果確實需要訪問未宣告的依賴(不推薦)
shamefully-hoist=true
# 也可以只對特定包豁免
public-hoist-pattern[]=*eslint*
和 Lerna 的對比
| 維度 | Lerna + Yarn v1 | pnpm workspace | | ------|----------------|----------------| | 依賴管理 | 扁平化,有幽靈依賴 | 嚴格隔離 | | 磁碟佔用 | 高(重複安裝) | 低(硬連結) | | 安裝速度 | 慢 | 快 | | 需要額外工具 | 需要 Lerna | 不需要 | | 版本釋出 | Lerna publish | changesets | | 學習曲線 | 中等 | 低 |
如果你的團隊還在用 Lerna,遷移成本不高,收益明顯。
小結
- pnpm workspace 是目前最輕量的 Monorepo 方案,不需要 Lerna
workspace:*協議處理子包間依賴,--filter精確控制命令範圍- 硬連結儲存 + 嚴格依賴管理是 pnpm 的核心優勢
- 搭配 Vite 構建子包,開發體驗很流暢
- 版本管理推薦用 changesets 替代 Lerna publish