pnpm Workspace + Turborepo:现代前端 Monorepo 实战
pnpm Workspace + Turborepo:现代前端 Monorepo 实战
当你的团队同时维护多个 npm 包、多个应用,且它们之间有大量共享代码时,Monorepo 就是你的答案。本文将从零开始搭建一个基于 pnpm Workspace + Turborepo 的现代 Monorepo,覆盖从项目初始化到 CI/CD 的完整流程。
一、为什么选择 pnpm + Turborepo
1.1 Monorepo 工具链对比
| 特性 | npm workspaces | yarn workspaces | pnpm workspaces | Nx | Turborepo |
|---|---|---|---|---|---|
| 包管理 | ✅ | ✅ | ✅ | ✅ | ❌ |
| 任务编排 | ❌ | ❌ | ❌ | ✅ | ✅ |
| 增量构建 | ❌ | ❌ | ❌ | ✅ | ✅ |
| 远程缓存 | ❌ | ❌ | ❌ | ✅ | ✅ |
| 依赖隔离 | ❌ | ❌ | ✅ | - | - |
pnpm 负责包管理(依赖安装、workspace 协议),Turborepo 负责任务编排(构建顺序、缓存、并行执行)。两者组合是目前最流行的 Monorepo 方案。
1.2 pnpm 的独特优势
pnpm 使用内容寻址存储(Content-Addressable Storage)和硬链接,解决了 npm/yarn 的两大痛点:
- 磁盘空间:相同的包只存储一份,所有项目通过硬链接引用
- 幽灵依赖:严格的 node_modules 结构,只有显式声明的依赖才能访问
# npm 的扁平 node_modules(幽灵依赖)node_modules/ ├── express/ ├── body-parser/ ← 你没安装,但能 require ├── cookie/ ← 幽灵依赖 └── ...
# pnpm 的严格 node_modulesnode_modules/ ├── .pnpm/ ← 实际安装位置(硬链接到全局存储) │ ├── express@4.18.2/ │ │ └── node_modules/ │ │ ├── express/ │ │ ├── body-parser/ ← 只有 express 能访问 │ │ └── cookie/ │ └── ... └── express -> .pnpm/express@4.18.2/node_modules/express二、项目初始化
2.1 创建 Monorepo 骨架
mkdir my-monorepo && cd my-monorepo
# 初始化 pnpmpnpm init
# 创建 workspace 配置cat > pnpm-workspace.yaml << 'EOF'packages: - 'apps/*' - 'packages/*' - 'tools/*'EOF
# 创建目录结构mkdir -p apps/web apps/docs packages/ui packages/utils packages/tsconfig tools/eslint-config2.2 目录结构规划
my-monorepo/├── apps/│ ├── web/ # 主应用│ └── docs/ # 文档站├── packages/│ ├── ui/ # 共享 UI 组件库│ ├── utils/ # 共享工具函数│ └── tsconfig/ # 共享 TypeScript 配置├── tools/│ └── eslint-config/ # 共享 ESLint 配置├── pnpm-workspace.yaml├── turbo.json├── package.json└── .npmrc2.3 根目录 package.json
{ "name": "my-monorepo", "private": true, "scripts": { "dev": "turbo dev", "build": "turbo build", "lint": "turbo lint", "test": "turbo test", "clean": "turbo clean", "format": "prettier --write \"**/*.{ts,tsx,md,json}\"" }, "devDependencies": { "turbo": "^2.0.0", "prettier": "^3.0.0" }, "packageManager": "pnpm@9.0.0"}2.4 .npmrc 配置
# 提升 peer dependencies 到根目录(某些工具需要)shamefully-hoist=false
# 严格的 peer dependency 检查strict-peer-dependencies=false
# 自动安装 peer dependenciesauto-install-peers=true三、配置共享包
3.1 共享 TypeScript 配置
{ "name": "@monorepo/tsconfig", "version": "0.0.0", "private": true, "files": ["*.json"]}{ "$schema": "https://json.schemastore.org/tsconfig", "compilerOptions": { "strict": true, "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "isolatedModules": true, "declaration": true, "declarationMap": true, "sourceMap": true }, "exclude": ["node_modules", "dist"]}{ "extends": "./base.json", "compilerOptions": { "jsx": "preserve", "lib": ["ES2022", "DOM", "DOM.Iterable"], "types": ["vite/client"] }}{ "extends": "./base.json", "compilerOptions": { "module": "ESNext", "lib": ["ES2022"], "types": ["node"] }}3.2 共享工具库
{ "name": "@monorepo/utils", "version": "1.0.0", "private": true, "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" } }, "scripts": { "build": "tsup src/index.ts --format esm --dts", "dev": "tsup src/index.ts --format esm --dts --watch", "clean": "rm -rf dist", "lint": "eslint src/", "test": "vitest run" }, "devDependencies": { "@monorepo/tsconfig": "workspace:*", "tsup": "^8.0.0", "typescript": "^5.0.0", "vitest": "^1.0.0" }}export { formatDate, timeAgo } from './date';export { debounce, throttle } from './timing';export { deepClone, deepMerge } from './object';export { isEmail, isURL, isPhone } from './validators';export function debounce<T extends (...args: any[]) => any>( fn: T, delay: number): (...args: Parameters<T>) => void { let timer: ReturnType<typeof setTimeout>; return function (this: any, ...args: Parameters<T>) { clearTimeout(timer); timer = setTimeout(() => fn.apply(this, args), delay); };}
export function throttle<T extends (...args: any[]) => any>( fn: T, interval: number): (...args: Parameters<T>) => void { let lastTime = 0; return function (this: any, ...args: Parameters<T>) { const now = Date.now(); if (now - lastTime >= interval) { lastTime = now; fn.apply(this, args); } };}export function formatDate( date: Date | string | number, format = 'YYYY-MM-DD HH:mm:ss'): string { const d = new Date(date); const tokens: Record<string, string> = { YYYY: String(d.getFullYear()), MM: String(d.getMonth() + 1).padStart(2, '0'), DD: String(d.getDate()).padStart(2, '0'), HH: String(d.getHours()).padStart(2, '0'), mm: String(d.getMinutes()).padStart(2, '0'), ss: String(d.getSeconds()).padStart(2, '0'), };
return Object.entries(tokens).reduce( (result, [token, value]) => result.replace(token, value), format );}
export function timeAgo(date: Date | string | number): string { const seconds = Math.floor((Date.now() - new Date(date).getTime()) / 1000);
const intervals: [number, string][] = [ [31536000, '年'], [2592000, '个月'], [86400, '天'], [3600, '小时'], [60, '分钟'], [1, '秒'], ];
for (const [secondsInUnit, unit] of intervals) { const count = Math.floor(seconds / secondsInUnit); if (count >= 1) return `${count} ${unit}前`; }
return '刚刚';}3.3 workspace 协议
pnpm 使用 workspace: 协议来引用 monorepo 内的包:
{ "dependencies": { "@monorepo/utils": "workspace:*", // 任意版本 "@monorepo/ui": "workspace:^1.0.0", // 兼容版本 "@monorepo/tsconfig": "workspace:*" }}发布时,pnpm 会自动将 workspace:* 替换为实际版本号。
四、Turborepo 任务编排
4.1 turbo.json 配置
{ "$schema": "https://turbo.build/schema.json", "globalDependencies": [ "**/.env.*local" ], "tasks": { "build": { "dependsOn": ["^build"], "outputs": ["dist/**", ".next/**", ".output/**"], "env": ["NODE_ENV"] }, "dev": { "dependsOn": ["^build"], "cache": false, "persistent": true }, "lint": { "dependsOn": ["^build"] }, "test": { "dependsOn": ["build"], "env": ["CI"] }, "clean": { "cache": false }, "typecheck": { "dependsOn": ["^build"] } }}4.2 理解依赖拓扑
"dependsOn": ["^build"] 中的 ^ 表示上游依赖。假设依赖关系如下:
apps/web → packages/ui → packages/utilsapps/web → packages/utilsapps/docs → packages/ui执行 turbo build 时,Turborepo 会自动:
- 先构建
packages/utils(无上游依赖) - 再构建
packages/ui(依赖 utils) - 最后并行构建
apps/web和apps/docs(依赖 ui)
# 查看任务执行图turbo build --graph
# 生成可视化图turbo build --graph=graph.html4.3 缓存机制
Turborepo 的杀手锏是增量构建缓存:
$ turbo build
Tasks: 4 successful, 4 total Cached: 3 cached, 4 total Time: 1.2s >>> FULL TURBO # 🚀 只有一个包需要重新构建缓存基于以下因素计算 hash:
- 源文件内容
- 环境变量(通过
env配置) - 上游依赖的构建产物
- turbo.json 配置
# 清除缓存turbo clean
# 查看缓存状态turbo build --summarize4.4 远程缓存
团队协作时,可以使用远程缓存共享构建结果:
# 使用 Vercel 远程缓存npx turbo loginnpx turbo link
# 或自托管# turbo.json{ "remoteCache": { "signature": true }}# 环境变量配置TURBO_TOKEN=your_tokenTURBO_TEAM=your_teamTURBO_API=https://your-cache-server.com五、应用层配置
5.1 Web 应用 (Vue + Vite)
{ "name": "@monorepo/web", "version": "1.0.0", "private": true, "type": "module", "scripts": { "dev": "vite", "build": "vue-tsc --noEmit && vite build", "preview": "vite preview", "lint": "eslint src/", "typecheck": "vue-tsc --noEmit" }, "dependencies": { "@monorepo/ui": "workspace:*", "@monorepo/utils": "workspace:*", "vue": "^3.4.0", "vue-router": "^4.3.0" }, "devDependencies": { "@monorepo/tsconfig": "workspace:*", "@vitejs/plugin-vue": "^5.0.0", "typescript": "^5.0.0", "vite": "^5.0.0", "vue-tsc": "^2.0.0" }}import { defineConfig } from 'vite';import vue from '@vitejs/plugin-vue';import { resolve } from 'path';
export default defineConfig({ plugins: [vue()], resolve: { alias: { '@': resolve(__dirname, 'src'), }, }, // 开发时直接引用源码,不需要先构建 optimizeDeps: { exclude: ['@monorepo/ui'], },});5.2 在应用中使用共享包
import { createApp } from 'vue';import { formatDate, debounce } from '@monorepo/utils';import App from './App.vue';
console.log(formatDate(new Date())); // "2026-04-05 17:48:00"
const app = createApp(App);app.mount('#app');六、开发工作流
6.1 常用命令
# 安装所有依赖pnpm install
# 全部开发模式pnpm dev
# 只开发特定包turbo dev --filter=@monorepo/web
# 构建所有包pnpm build
# 只构建某个包及其依赖turbo build --filter=@monorepo/web...
# 在特定包中添加依赖pnpm add lodash --filter=@monorepo/utils
# 在根目录添加开发依赖pnpm add -Dw prettier
# 在所有包中运行脚本pnpm -r run lint
# 只在变更的包中运行turbo build --filter=...[HEAD^1]6.2 pnpm filter 语法
# 按包名过滤pnpm --filter @monorepo/web dev
# 按目录过滤pnpm --filter ./apps/web dev
# 包含依赖(三个点)pnpm --filter @monorepo/web... build # web 及其所有依赖
# 只依赖(不含自身)pnpm --filter @monorepo/web^... build
# Git 变更过滤pnpm --filter "...[origin/main]" build # 自 main 分支以来变更的包6.3 内部包的开发体验优化
为了开发时不需要每次修改都重新构建内部包,有几种策略:
策略一:使用 exports 指向源码
{ "exports": { ".": { "development": "./src/index.ts", "import": "./dist/index.js" } }}策略二:tsup watch 模式
{ "tasks": { "dev": { "dependsOn": ["^dev"], "cache": false, "persistent": true } }}每个包的 dev 脚本都是 watch 模式,Turborepo 会按拓扑顺序启动。
策略三:Vite 直接解析 TypeScript
import { defineConfig } from 'vite';
export default defineConfig({ resolve: { conditions: ['development'], },});七、版本管理与发布
7.1 使用 Changesets
# 安装pnpm add -Dw @changesets/cli @changesets/changelog-github
# 初始化pnpm changeset init{ "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", "changelog": [ "@changesets/changelog-github", { "repo": "your-org/your-repo" } ], "commit": false, "fixed": [], "linked": [["@monorepo/ui", "@monorepo/utils"]], "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", "ignore": ["@monorepo/web", "@monorepo/docs"]}7.2 发布工作流
# 1. 开发完成后,添加 changesetpnpm changeset
# 交互式选择:# - 哪些包有变更# - 变更级别(patch/minor/major)# - 变更描述
# 2. 版本升级(通常在 CI 中执行)pnpm changeset version
# 3. 发布到 npmpnpm changeset publish7.3 根目录脚本
{ "scripts": { "changeset": "changeset", "version-packages": "changeset version", "release": "turbo build && changeset publish" }}八、CI/CD 配置
8.1 GitHub Actions
name: CI
on: push: branches: [main] pull_request: branches: [main]
jobs: build: runs-on: ubuntu-latest
steps: - uses: actions/checkout@v4 with: fetch-depth: 2 # 用于变更检测
- uses: pnpm/action-setup@v3 with: version: 9
- uses: actions/setup-node@v4 with: node-version: 20 cache: 'pnpm'
- name: Install dependencies run: pnpm install --frozen-lockfile
# Turborepo 缓存 - name: Cache turbo uses: actions/cache@v4 with: path: .turbo key: ${{ runner.os }}-turbo-${{ github.sha }} restore-keys: | ${{ runner.os }}-turbo-
- name: Build run: pnpm build
- name: Lint run: pnpm lint
- name: Test run: pnpm test
- name: Typecheck run: turbo typecheck
release: needs: build if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest
steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v3 with: version: 9 - uses: actions/setup-node@v4 with: node-version: 20 cache: 'pnpm'
- run: pnpm install --frozen-lockfile - run: pnpm build
- name: Create Release PR or Publish uses: changesets/action@v1 with: publish: pnpm release version: pnpm version-packages env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }}8.2 利用 Turborepo 加速 CI
# 只构建受影响的包turbo build --filter=...[origin/main]
# 开启远程缓存(CI 间共享)turbo build --token=$TURBO_TOKEN --team=$TURBO_TEAM九、常见问题与最佳实践
9.1 处理 TypeScript 项目引用
如果你使用 TypeScript Project References:
// tsconfig.json (根目录){ "references": [ { "path": "./packages/utils" }, { "path": "./packages/ui" }, { "path": "./apps/web" } ], "files": []}{ "extends": "@monorepo/tsconfig/vue.json", "compilerOptions": { "composite": true, "outDir": "dist", "rootDir": "src" }, "references": [ { "path": "../utils" } ], "include": ["src"]}9.2 共享 ESLint 配置
import tseslint from 'typescript-eslint';import pluginVue from 'eslint-plugin-vue';
export const base = tseslint.config( ...tseslint.configs.recommended, { rules: { '@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', }], }, });
export const vue = tseslint.config( ...base, ...pluginVue.configs['flat/recommended'],);{ "name": "@monorepo/eslint-config", "version": "0.0.0", "private": true, "type": "module", "main": "./index.js", "dependencies": { "typescript-eslint": "^8.0.0", "eslint-plugin-vue": "^9.0.0" }}9.3 Monorepo 注意事项
- 不要在根目录装应用依赖:根目录只放工具依赖(turbo、prettier 等)
- 保持包职责单一:一个包做一件事
- 版本一致性:共享的依赖(如 TypeScript)尽量在所有包中保持相同版本
- 避免循环依赖:A 依赖 B,B 不能再依赖 A
# 检查循环依赖pnpm ls --depth=Infinity --filter @monorepo/ui | grep @monorepo十、总结
pnpm Workspace + Turborepo 的组合提供了:
- pnpm:高效的依赖管理、严格的隔离、workspace 协议
- Turborepo:智能的任务编排、增量缓存、远程缓存
关键要点:
- 用
pnpm-workspace.yaml定义包范围 - 用
workspace:*引用内部包 - 用
turbo.json定义任务依赖和缓存策略 ^前缀表示先构建上游依赖- 用 Changesets 管理版本和发布
- CI 中利用 Turborepo 缓存加速
Monorepo 不是银弹,但当你的项目规模到了一定程度,它带来的代码共享、一致性保证和开发效率提升是非常显著的。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!