pnpm Workspace + Turborepo:现代前端 Monorepo 实战

2504 字
13 分钟
pnpm Workspace + Turborepo:现代前端 Monorepo 实战

pnpm Workspace + Turborepo:现代前端 Monorepo 实战#

当你的团队同时维护多个 npm 包、多个应用,且它们之间有大量共享代码时,Monorepo 就是你的答案。本文将从零开始搭建一个基于 pnpm Workspace + Turborepo 的现代 Monorepo,覆盖从项目初始化到 CI/CD 的完整流程。

一、为什么选择 pnpm + Turborepo#

1.1 Monorepo 工具链对比#

特性npm workspacesyarn workspacespnpm workspacesNxTurborepo
包管理
任务编排
增量构建
远程缓存
依赖隔离--

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_modules
node_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 骨架#

Terminal window
mkdir my-monorepo && cd my-monorepo
# 初始化 pnpm
pnpm 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-config

2.2 目录结构规划#

my-monorepo/
├── apps/
│ ├── web/ # 主应用
│ └── docs/ # 文档站
├── packages/
│ ├── ui/ # 共享 UI 组件库
│ ├── utils/ # 共享工具函数
│ └── tsconfig/ # 共享 TypeScript 配置
├── tools/
│ └── eslint-config/ # 共享 ESLint 配置
├── pnpm-workspace.yaml
├── turbo.json
├── package.json
└── .npmrc

2.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 配置#

.npmrc
# 提升 peer dependencies 到根目录(某些工具需要)
shamefully-hoist=false
# 严格的 peer dependency 检查
strict-peer-dependencies=false
# 自动安装 peer dependencies
auto-install-peers=true

三、配置共享包#

3.1 共享 TypeScript 配置#

packages/tsconfig/package.json
{
"name": "@monorepo/tsconfig",
"version": "0.0.0",
"private": true,
"files": ["*.json"]
}
packages/tsconfig/base.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"]
}
packages/tsconfig/vue.json
{
"extends": "./base.json",
"compilerOptions": {
"jsx": "preserve",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"]
}
}
packages/tsconfig/node.json
{
"extends": "./base.json",
"compilerOptions": {
"module": "ESNext",
"lib": ["ES2022"],
"types": ["node"]
}
}

3.2 共享工具库#

packages/utils/package.json
{
"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"
}
}
packages/utils/src/index.ts
export { formatDate, timeAgo } from './date';
export { debounce, throttle } from './timing';
export { deepClone, deepMerge } from './object';
export { isEmail, isURL, isPhone } from './validators';
packages/utils/src/timing.ts
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);
}
};
}
packages/utils/src/date.ts
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/utils
apps/web → packages/utils
apps/docs → packages/ui

执行 turbo build 时,Turborepo 会自动:

  1. 先构建 packages/utils(无上游依赖)
  2. 再构建 packages/ui(依赖 utils)
  3. 最后并行构建 apps/webapps/docs(依赖 ui)
Terminal window
# 查看任务执行图
turbo build --graph
# 生成可视化图
turbo build --graph=graph.html

4.3 缓存机制#

Turborepo 的杀手锏是增量构建缓存

Terminal window
$ turbo build
Tasks: 4 successful, 4 total
Cached: 3 cached, 4 total
Time: 1.2s >>> FULL TURBO # 🚀 只有一个包需要重新构建

缓存基于以下因素计算 hash:

  • 源文件内容
  • 环境变量(通过 env 配置)
  • 上游依赖的构建产物
  • turbo.json 配置
Terminal window
# 清除缓存
turbo clean
# 查看缓存状态
turbo build --summarize

4.4 远程缓存#

团队协作时,可以使用远程缓存共享构建结果:

Terminal window
# 使用 Vercel 远程缓存
npx turbo login
npx turbo link
# 或自托管
# turbo.json
{
"remoteCache": {
"signature": true
}
}
Terminal window
# 环境变量配置
TURBO_TOKEN=your_token
TURBO_TEAM=your_team
TURBO_API=https://your-cache-server.com

五、应用层配置#

5.1 Web 应用 (Vue + Vite)#

apps/web/package.json
{
"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"
}
}
apps/web/vite.config.ts
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 在应用中使用共享包#

apps/web/src/main.ts
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 常用命令#

Terminal window
# 安装所有依赖
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 语法#

Terminal window
# 按包名过滤
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 指向源码

packages/utils/package.json
{
"exports": {
".": {
"development": "./src/index.ts",
"import": "./dist/index.js"
}
}
}

策略二:tsup watch 模式

turbo.json
{
"tasks": {
"dev": {
"dependsOn": ["^dev"],
"cache": false,
"persistent": true
}
}
}

每个包的 dev 脚本都是 watch 模式,Turborepo 会按拓扑顺序启动。

策略三:Vite 直接解析 TypeScript

apps/web/vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
resolve: {
conditions: ['development'],
},
});

七、版本管理与发布#

7.1 使用 Changesets#

Terminal window
# 安装
pnpm add -Dw @changesets/cli @changesets/changelog-github
# 初始化
pnpm changeset init
.changeset/config.json
{
"$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 发布工作流#

Terminal window
# 1. 开发完成后,添加 changeset
pnpm changeset
# 交互式选择:
# - 哪些包有变更
# - 变更级别(patch/minor/major)
# - 变更描述
# 2. 版本升级(通常在 CI 中执行)
pnpm changeset version
# 3. 发布到 npm
pnpm changeset publish

7.3 根目录脚本#

{
"scripts": {
"changeset": "changeset",
"version-packages": "changeset version",
"release": "turbo build && changeset publish"
}
}

八、CI/CD 配置#

8.1 GitHub Actions#

.github/workflows/ci.yml
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#

Terminal window
# 只构建受影响的包
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": []
}
packages/ui/tsconfig.json
{
"extends": "@monorepo/tsconfig/vue.json",
"compilerOptions": {
"composite": true,
"outDir": "dist",
"rootDir": "src"
},
"references": [
{ "path": "../utils" }
],
"include": ["src"]
}

9.2 共享 ESLint 配置#

tools/eslint-config/index.js
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'],
);
tools/eslint-config/package.json
{
"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 注意事项#

  1. 不要在根目录装应用依赖:根目录只放工具依赖(turbo、prettier 等)
  2. 保持包职责单一:一个包做一件事
  3. 版本一致性:共享的依赖(如 TypeScript)尽量在所有包中保持相同版本
  4. 避免循环依赖:A 依赖 B,B 不能再依赖 A
Terminal window
# 检查循环依赖
pnpm ls --depth=Infinity --filter @monorepo/ui | grep @monorepo

十、总结#

pnpm Workspace + Turborepo 的组合提供了:

  • pnpm:高效的依赖管理、严格的隔离、workspace 协议
  • Turborepo:智能的任务编排、增量缓存、远程缓存

关键要点:

  1. pnpm-workspace.yaml 定义包范围
  2. workspace:* 引用内部包
  3. turbo.json 定义任务依赖和缓存策略
  4. ^ 前缀表示先构建上游依赖
  5. 用 Changesets 管理版本和发布
  6. CI 中利用 Turborepo 缓存加速

Monorepo 不是银弹,但当你的项目规模到了一定程度,它带来的代码共享、一致性保证和开发效率提升是非常显著的。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

pnpm Workspace + Turborepo:现代前端 Monorepo 实战
https://boke.hackerdream.xyz/posts/monorepo-pnpm-workspace/
作者
晴天
发布于
2026-03-01
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
晴天
Hello, I'm 晴天.
公告
欢迎来到我的博客!这是一则示例公告。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
125
分类
17
标签
287
总字数
257,955
运行时长
0
最后活动
0 天前

目录