在现代前端开发中,随着业务复杂度的上升,我们经常面临这样的困境:多个项目之间存在大量的重复代码。在传统的单体仓库或多仓(Multi-repo)模式下,跨项目复用代码通常需要复杂的协调和同步机制。本文将带你深入使用 pnpm + Turborepo 重构项目的完整历程,从问题诊断到解决方案,从技术选型到具体实施,全方位展示一个真实项目的架构演进过程。

一、背景:为什么我们需要放弃传统单体架构?

1.1 混乱的依赖管理

在重构之前,项目的 package.json 就像一个大杂烩:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"dependencies": {
"vue": "^3.3.0",
"electron": "^25.0.0",
"node-printer": "^0.6.0",
"websocket": "^1.0.34"
// ... 其他40+个依赖混在一起
},
"devDependencies": {
"vite": "^4.4.0",
"typescript": "^5.2.0",
"eslint": "^8.48.0"
// ... 其他30+个开发依赖
}
}

核心问题:

  • 依赖污染:Web 端能访问 Electron 的 Node.js API,开发时正常,构建后白屏
  • 版本冲突:多版本库共存,调试困难
  • 重复安装:相同的依赖在不同项目中重复下载,磁盘空间浪费严重

1.2 代码复用的困境

项目中存在大量共享代码:

  • 打印机通信协议
  • 标签数据模型和验证逻辑
  • WebSocket 连接管理
  • 工具函数库

共享方式:

  • import { Label } from '../../../../shared/types'(路径混乱)
  • 复制粘贴(同步噩梦)
  • 或直接引用 node_modules 中的未声明依赖(幽灵依赖)

二、架构选型:为什么选择 Monorepo?

2.1 Monorepo vs Multi-repo 的抉择

方案优点缺点适用场景
Multi-repo项目独立,权限清晰代码复用困难,版本同步复杂项目间耦合度低,独立演进
Monorepo代码复用简单,统一工作流权限管理复杂,仓库体积大项目强耦合,频繁协同开发

适用场景判断:

采用 Web 主体 + 桌面微服务 的伴生架构:

  • Web 端:承载 100% 的编辑器核心业务与 UI 交互
  • Electron 端 (Agent):作为轻量级本地服务,专注于提供 Web 无法直接访问的系统能力(如静默打印、驱动调用)
  • 通信机制:两者通过标准化的 WebSocket 协议通信,共享接口定义 (TypeScript Interface)通信契约

这符合 Monorepo 的适用场景。

2.2 技术栈对比:为什么是 pnpm + Turborepo?

pnpm 的三大杀手锏

1. 严格的依赖隔离

1
2
3
4
5
6
7
8
9
10
# 传统 npm/yarn 的扁平化 node_modules
node_modules/
├── vue/ # 直接可用
├── lodash/ # 直接可用(即使没声明!)
└── ...

# pnpm 的隔离式 node_modules
node_modules/
├── .pnpm/ # 所有依赖的实际存储
└── vue -> .pnpm/vue@3.3.0/node_modules/vue # 符号链接

效果:从物理层面根除幽灵依赖。

2. 硬链接的磁盘节省

1
2
3
4
5
6
7
8
9
# 所有项目共享同一个全局 store
~/.pnpm-store/
├── v3/files/
│ ├── 00/... # vue 3.3.0
│ ├── 01/... # typescript 5.2.0
│ └── ...

# 每个项目通过硬链接引用
projects/app/web/node_modules/.pnpm/vue@3.3.0 -> 硬链接到全局 store

效果:磁盘占用减少 40%,安装速度提升 50%。

3. 原生 Workspace 支持

1
2
3
4
# pnpm-workspace.yaml
packages:
- 'apps/*'
- 'packages/*'

效果:零配置启动多包管理。

Turborepo:构建流水线优化师

Turborepo 解决了 Monorepo 最大的痛点——构建效率。

特性解决的问题实际效果
任务依赖图自动分析包之间的依赖关系智能构建顺序,无需手动指定
增量构建基于内容哈希的缓存未修改的包秒级完成
并行执行充分利用多核 CPU构建时间缩短 60-80%
远程缓存团队共享构建产物CI/CD 时间从 10分钟降至 1分钟
1
2
3
4
5
6
7
8
9
10
11
12
13
// turbo.json
{
"pipeline": {
"build": {
"dependsOn": ["^build"], // 自动依赖分析
"outputs": ["dist/**"]
},
"test": {
"dependsOn": ["build"], // 测试依赖构建
"outputs": []
}
}
}

2.3 为什么不选择其他方案?

方案优点缺点排除原因
npm/yarn workspace生态成熟依赖扁平化,仍有幽灵依赖依赖隔离不彻底
Lerna历史悠久,功能全面配置复杂,性能一般过于重量级
Nx功能强大,插件丰富学习曲线陡峭对小型团队过度设计
pnpm + Turborepo简单高效,性能优异相对较新最佳匹配我们的需求

三、实战:从零搭建 Monorepo 架构

3.1 项目结构设计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
babel-editor/
├── apps/ # 应用层
│ ├── web/ # Web 前端
│ │ ├── src/
│ │ ├── package.json # 只声明 Web 相关依赖
│ │ └── vite.config.ts
│ └── agent/ # Electron 桌面端
│ ├── src/
│ ├── package.json # 只声明 Electron 相关依赖
│ └── electron-builder.yml
├── packages/ # 共享层
│ └── shared/ # TypeScript 共享库
│ ├── src/
│ │ ├── types/ # 共享类型定义
│ │ ├── utils/ # 共享工具函数
│ │ └── constants/ # 共享常量
│ └── package.json # 无运行时依赖
├── package.json # 根包,仅包含脚本和 devDependencies
├── pnpm-workspace.yaml # 工作空间定义
├── turbo.json # 构建流水线配置
└── tsconfig.base.json # 基础 TypeScript 配置

设计原则:

  • 物理隔离:不同端的代码无法相互引用
  • 依赖最小化:每个包只声明自己必需的依赖
  • 类型驱动:所有共享代码都通过 TypeScript 接口定义

3.2 配置详解

3.2.1 根目录 package.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"name": "babel-editor-monorepo",
"private": true,
"scripts": {
"dev": "turbo run dev --parallel",
"build": "turbo run build",
"lint": "turbo run lint",
"type-check": "turbo run type-check",
"agent:dev": "turbo run dev --filter=@babel/printer-agent",
"web:dev": "turbo run dev --filter=@babel/editor",
"agent:build": "turbo run build --filter=@babel/printer-agent",
"web:build": "turbo run build --filter=@babel/editor"
},
"devDependencies": {
"turbo": "^2.8.1"
},
"packageManager": "pnpm@10.4.1"
}

3.2.2 pnpm-workspace.yaml

1
2
3
packages:
- 'apps/*'
- 'packages/*'

3.2.3 turbo.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": [
"**/.env.*local"
],
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {},
"type-check": {}
}
}

3.3 跨包依赖配置

3.3.1 共享包 package.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"name": "@babel/shared",
"version": "0.1.0",
"type": "module",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"type-check": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.4.0"
}
}

3.3.2 Web 应用引用共享包

package.json:

1
2
3
4
5
6
7
{
"name": "@babel/web",
"dependencies": {
"@babel/shared": "workspace:*",
"vue": "^3.3.0"
}
}

TypeScript 代码使用:

1
2
3
4
5
6
7
import type { Label } from '@babel/shared/types';
import { formatDate } from '@babel/shared/utils';

const label: Label = {
id: '123',
name: '示例标签',
};

3.4 Windows 特殊问题解决

3.4.1 Electron 构建硬链接问题

问题现象:

1
2
> pnpm agent:build
ERR_ELECTRON_BUILDER_CANNOT_EXECUTE spawn app-builder.exe ENOENT

解决方案:

1
2
3
4
5
6
7
{
"scripts": {
"build:win": "set ELECTRON_BUILDER_USE_SYSTEM_APP_BUILDER=true && electron-builder --win --x64",
"build:mac": "electron-builder --mac --x64",
"build:linux": "electron-builder --linux --x64"
}
}

原理: pnpm 使用硬链接,Electron Builder 有时无法正确解析。环境变量强制使用系统级调用,绕过内部路径处理。


四、迁移策略:从单体到 Monorepo 的平稳过渡

4.1 分支策略

1
2
3
4
5
6
git
├── master (旧架构主分支,只修复紧急bug)
├── monorepo-migration (新架构主分支,所有新功能)
└── feature/*
├── feature/new-ui (基于新架构)
└── feature/legacy-fix (基于旧架构)

4.2 渐进式迁移步骤

第一阶段:准备期

  • 依赖分析:使用 npm lsdepcheck 分析现有依赖关系
  • 代码审计:识别共享代码,规划包拆分
  • 团队培训:pnpm、Turborepo 基础培训

第二阶段:并行开发期

  • 搭建新架构:完成基础 Monorepo 结构
  • 核心模块迁移:先迁移最稳定的共享代码
  • 双轨运行:新架构与旧架构并存
1
2
3
4
5
# 开发工作流对比
旧:npm run dev # 启动整个应用(混乱)
新:pnpm dev # 启动所有子项目(并行)
├── web dev # 启动 Web 端 Vue 应用 (默认端口:5173)
└── agent dev # 启动 C 端助手 (WS Server, 默认端口:19090)

第三阶段:全面切换

  • 功能验证:确保所有功能在新架构下正常运行
  • 性能测试:构建速度、运行时性能对比
  • 正式切换:旧分支归档,全员切换到新架构

五、成果与收益

5.1 量化指标对比

指标重构前重构后提升
首次安装时间32s12s62.5%
增量构建时间53s23s56.6%

5.2 开发体验提升

新成员上手时间

  • 之前:克隆 -> npm install (失败) -> debug -> 1.5天
  • 之后:克隆 -> pnpm install (成功) -> pnpm dev -> 20分钟

日常开发流程

Web 和 Agent 支持自动热更新修改的共享代码(通过 workspace 软链)。

5.3 架构质量改善

类型安全增强

之前(运行时报错):

1
2
const label = await getLabelFromAPI();
console.log(label.name); // 可能 undefined

之后(编译时检查):

1
2
const label: Label = await getLabelFromAPI();
// TypeScript 确保返回类型匹配 Label 接口

依赖关系可视化

1
2
3
4
5
6
7
8
9
10
11
12
$ pnpm ls -r --depth 3
babel-editor-monorepo
├── apps
│ ├── web
│ │ ├── vue@3.3.0
│ │ └── @babel/shared@workspace
│ └── agent
│ ├── electron@25.0.0
│ └── @babel/shared@workspace
└── packages
└── shared
└── typescript@5.2.0

六、经验总结与最佳实践

6.1 成功的关键因素

  1. 充分的预研:对比了多种 Monorepo 方案,选择了最适合的
  2. 渐进式迁移:双分支策略确保业务连续性
  3. 团队协作:全员参与设计,共同制定规范
  4. 持续优化:基于实际使用反馈不断调整配置

6.2 遇到的挑战与解决方案

挑战解决方案效果
Windows 构建失败环境变量 + 脚本清理成功率 100%
幽灵依赖难以发现pnpm 严格模式彻底根除
构建缓存失效Turborepo 远程缓存CI/CD 加速 80%
包引用循环依赖图分析工具提前预警

6.3 给其他团队的建议

适合使用 Monorepo 的场景

  • ✅ 项目间共享大量代码(>30%)
  • ✅ 频繁跨项目修改
  • ✅ 团队规模 5-50 人
  • ✅ 需要统一技术栈

  • 不建议:项目完全独立、需要精细权限控制、仓库体积极其巨大 (>10GB)


七、未来规划

  1. 短期优化:微前端集成,将 Web 应用细化拆分
  2. 长期演进:插件化架构,支持第三方插件开发
  3. 云原生部署:完善容器化部署方案

结语

从混乱的单体架构到清晰的 Monorepo,我们不仅重构了代码,更重构了团队的开发理念和工作流程。这次重构告诉我们:

架构改造的最大挑战往往不是技术,而是人和流程。做好准备,小步快跑,你一定也能找到最适合自己团队的解决方案。