这篇文章整理了一套可复用的 Monorepo 工程化落地方法:如何用 pnpm workspace 管理多包多应用、如何用 Turbo 做任务编排与增量构建、如何约束依赖分层、以及团队协作中最容易踩坑的点与应对策略。
文中示例以两个业务应用 A项目 / B项目 为例;如需集成到上层宿主,统一称为 宿主项目。
1. 背景:为什么从多仓库走向 Monorepo
当团队从单应用走向多应用/多模块时,常见问题会集中爆发:
- 重复建设:脚手架、规范、基础能力在多个仓库里反复搭一遍
- 升级困难:公共能力升级需要跨仓库同步,长期会出现版本碎片
- 联调低效:跨包改动要先发包/拷贝产物,调试链路长
Monorepo 的目标不是“把代码放一起”,而是让 依赖关系清晰、让 构建与开发可被编排、让 跨包改动像改单仓库一样顺滑。
目标与约束
-
目标
- 用一个仓库承载多应用(
apps/*)与可复用共享包(packages/*) - 让共享包能被源码级引用,实现“改完即生效”的开发体验
- 让构建能被 DAG 编排,支持增量构建、并行构建、可缓存
- 用工具强制依赖分层,避免项目增长后结构失控
- 用一个仓库承载多应用(
-
约束
- 单向依赖:共享包不反向依赖应用;同层互不依赖(避免“意大利面”)
- 协议统一:内部包依赖统一走
workspace:*,避免重复实例与版本漂移
2.2 Monorepo 目录与分层架构
项目采用标准的 Monorepo 结构,遵循严格的分层依赖原则,将代码组织为应用层 (apps) 和共享包层 (packages)。整体架构分为三个层级,每一层都有明确的职责边界和依赖方向。
目录结构
repo/
├── apps/
│ ├── a-project/
│ └── b-project/
├── packages/
│ ├── base-*/ # 基础层:零内部依赖
│ ├── service-*/ # 服务层:只依赖 base
│ └── component-*/ # 组件层:只依赖 base
├── package.json
├── pnpm-workspace.yaml
├── turbo.json
└── scripts/
├── check-dependencies.js
└── check-workspace-protocol.js
2.2.1 架构分层图
flowchart TB
subgraph BASE["Base 层 - 零依赖"]
direction TB
B1["base-utils<br/>工具函数"]
B2["base-types<br/>类型定义"]
B3["base-ui<br/>UI组件"]
end
subgraph MIDDLE["Service 层 Component 层"]
direction TB
S1["service-<br/>xx服务"]
S2["service-xx<br/>xx服务"]
S3["service-i18n<br/>国际化"]
S4["service-xx<br/>xx服务"]
S5["service-router<br/>路由拦截器"]
S6["service-user<br/>用户服务"]
S7["service-xx<br/>服务"]
C1["component-editor<br/>编辑器"]
end
subgraph APPS["Apps 层 - 应用层"]
direction TB
A1["A项目"]
A2["B项目"]
end
BASE --> MIDDLE
MIDDLE --> APPS
classDef baseBox fill:#e8f5e9,stroke:#388e3c,stroke-width:2px
classDef middleBox fill:#fff3e0,stroke:#f57c00,stroke-width:2px
classDef appsBox fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
classDef baseItem fill:#66bb6a,stroke:#388e3c,stroke-width:2px,color:#fff
classDef middleItem fill:#ffa726,stroke:#f57c00,stroke-width:2px,color:#fff
classDef appsItem fill:#42a5f5,stroke:#1976d2,stroke-width:2px,color:#fff
class BASE baseBox
class MIDDLE middleBox
class APPS appsBox
class B1,B2,B3 baseItem
class S1,S2,S3,S4,S5,S6,S7,C1 middleItem
class A1,A2 appsItem
2.2.2 分层详解
base 层 - 基础层 (Zero Dependencies)
基础层的包不依赖任何内部包,是整个架构的地基。这些包的设计原则是高内聚、低耦合、无状态。
| 包名 | 包路径 | 职责 | 核心能力 |
|---|---|---|---|
| types | @org/base-types | TypeScript 类型定义 | 用户类型、消息类型、任务类型、认证类型、扩展接口等全局类型 |
| ui | @org/base-ui | 基础 UI 组件 | Toast 提示、通用弹窗等无依赖的纯 UI 组件 |
核心设计原则:
- 必须是纯函数或无状态类
- 不得引入任何内部
@org/*包 - 对外依赖(如 axios、lodash)应通过
peerDependencies声明 - 所有导出必须通过
src/index.ts统一暴露
service 层 - 服务层 (With Dependencies on base)
服务层的包依赖 base 层的基础能力,通过组合和封装提供更高层次的业务服务。
| 包名 | 包路径 | 职责 | 依赖关系 | 核心能力 |
|---|---|---|---|---|
| i18n | @org/service-i18n | 国际化服务 | vue, vue-i18n (零内部依赖) | VueI18n 封装、多语言管理、语言切换、 |
| router | @org/service-router | 路由拦截器和工具 | vue, vue-router | 路由拦截器工厂 |
| user | @org/service-user | 用户服务和状态管理 | vue, vuex, base-types | 用户信息管理 |
依赖管理策略:
- 使用
peerDependencies声明对 base 层包的依赖 - 应用层通过
workspace:*安装时,pnpm 自动解析为同一实例 - 避免在 service 层包之间产生依赖(保持扁平化)
component 层 - 组件层 (With Dependencies on base)
组件层提供可复用的业务组件,依赖 base 层的基础能力。
| 包名 | 包路径 | 职责 | 依赖关系 | 核心能力 |
|---|---|---|---|---|
| editor | @org/component-editor | 编辑器组件 | base-utils (工具函数) | Tiptap 编辑器核心、自定义扩展(列表、键盘事件)、样式封装 |
设计特点:
- 专注于 UI 组件的封装和复用
- 独立于 service 层,可单独使用
- 未来可扩展:markdown、table 等组件
apps 层 - 应用层
应用层是面向用户的完整产品,可以自由组合使用 packages 中的所有共享能力。
| 应用 | 状态 | 说明 |
|---|---|---|
| a-project | 已上线 | A项目应用 |
| b-project | 开发中 | B项目应用 |
应用特点:
- 直接依赖所需的
packages,通过dependencies声明 - 拥有独立的路由、状态管理、业务逻辑
- 可以独立构建为 SPA,也可以构建为微前端模块
- 共享相同的构建工具链和开发规范
2.2.3 依赖解析机制
项目通过三个核心机制实现高效的依赖管理和源码级引用:
核心价值:
- 即时生效:修改 packages 源码,应用无需重装立即更新
- 类型安全:TypeScript 直接从源码推断,无需
.d.ts文件 - 避免冲突:peerDependencies 确保全局单例
- 源码调试:浏览器中可直接调试 packages 源码
机制 1:pnpm workspace 符号链接
pnpm 通过符号链接(symlink)实现工作区内包的快速引用,无需发布到 npm 仓库。
工作流程:
核心优势:
- 即时生效:修改 package 源码,应用立即感知,无需重新安装
- 双向同步:在应用中调试时修改的代码,直接保存到 package 源码
- 节省空间:多个应用共享同一份源码,不会重复复制
机制 2:peerDependencies 单例保证
通过 peerDependencies 声明依赖,确保整个依赖树中只有一个包实例,避免多版本冲突。
依赖树解析对比:
为什么需要单例?
假设 base-utils 中有一个全局状态管理器:
// packages/base/utils/src/storage.ts
class StorageManager {
private cache = new Map();
set(key: string, value: any) {
this.cache.set(key, value);
}
get(key: string) {
return this.cache.get(key);
}
}
export const storage = new StorageManager(); // 全局单例
- ** 单例保证**:使用
peerDependencies后,所有包共享同一个storage实例,状态统一
机制 3:Webpack Alias 源码解析
通过 resolve.alias 配置,Webpack 在打包时将包名直接解析为源码路径,跳过编译产物。
解析流程对比:
代码示例:
核心优势:
| 特性 | 传统方式 (dist) | 源码引用 (src) |
|---|---|---|
| 类型提示 | 需要 .d.ts 文件 | 直接从源码推断 |
| 代码跳转 | 跳转到声明文件 | 跳转到源码实现 |
| 断点调试 | 只能调试编译后的代码 | 直接调试 TypeScript 源码 |
| Tree Shaking | 取决于编译配置 | Webpack 统一处理,效果更好 |
| 构建速度 | 需要先构建 package | 与应用代码一起编译 |
| HMR | 需要重新构建 package | 修改即生效 |
** 三机制协同工作示例**
下图展示了修改 base/utils 的代码时,三个机制如何协同保证应用即时更新:
实际工作流程:
- 机制 1 生效 pnpm workspace 的符号链接使修改对所有依赖方可见
- 机制 2 保证 peerDependencies 确保只有一个 base/utils 实例被更新
- 机制 3 处理 Webpack alias 将源码编译并通过 HMR 推送到浏览器
- 浏览器更新 应用自动刷新,显示最新代码效果
2.2.4 架构优势
零循环依赖:严格的单向依赖流(apps service/component base),通过自动化工具检测
高复用性:通用能力下沉,避免重复开发,新应用可快速接入
类型安全:TypeScript 直接从源码推断类型,无需维护 .d.ts 声明文件
开发体验:HMR 即时生效,修改 packages 代码应用立即更新
独立演进:每个包和应用都有独立的版本和生命周期
渐进式接入:新应用只需依赖所需的 packages,不必全量引入
2.2.5 架构约束与校验
为确保分层架构的严格执行,项目通过 Git 提交钩子(pre-commit) 自动检查依赖规则,任何违反架构约束的代码都无法提交。
依赖规则:
1. 分层依赖规则
| 层级 | 允许依赖 | 禁止依赖 | 说明 |
|---|---|---|---|
| base | 无 | 任何 @org/* 包 | 基础层必须零依赖,保持纯净 |
| service | base 层 | service 层、component 层、apps 层 | 服务层包之间不能互相依赖 |
| component | base 层 | service 层、component 层、apps 层 | 组件层包之间不能互相依赖 |
| apps | base、service、component | apps 层 | 应用之间不能互相依赖 |
规则示例:
2. 包命名规范
所有 packages/ 下的包必须以 分层前缀 开头:
| 前缀 | 示例 | 说明 |
|---|---|---|
base- | base-utils, base-types | 基础层包 |
component- | component-editor, component-table | 组件层包 |
3. 依赖协议规范(workspace:*)
所有 @org/* 依赖必须使用 workspace:* 协议:
为什么必须使用 workspace:*?
- 确保引用本地包:pnpm 会将其解析为符号链接,而非从 npm 下载
- 版本一致性:避免版本号不一致导致的依赖问题
- 开发体验:修改 package 源码立即生效,无需重新安装
- 构建优化:Turbo 能准确追踪依赖关系
4. Git 提交校验
每次 git commit 时,会自动运行以下检查:
# .husky/pre-commit
pnpm run check:deps # 运行 scripts/check-dependencies.js
检查流程:
sequenceDiagram
autonumber
participant Dev as 开发者
participant Git as Git
participant Husky as Husky Hook
participant Check1 as 包命名检查
participant Check2 as 依赖规则检查
participant Check3 as workspace 协议检查
Dev->>Git: git commit -m "feat: ..."
Git->>Husky: 触发 pre-commit 钩子
rect rgb(232, 245, 233)
Note right of Check1: 检查 1:包命名规范
Husky->>Check1: 检查 packages/ 下的包名
Check1->>Check1: 验证前缀: base-/service-/component-
Check1-->>Husky: 返回结果
end
rect rgb(227, 242, 253)
Note right of Check2: 检查 2:依赖规则
Husky->>Check2: 分析所有 package.json
Check2->>Check2: 验证依赖是否符合分层规则
Check2->>Check2: 检测同层互相依赖
Check2-->>Husky: 返回结果
end
rect rgb(255, 243, 224)
Note right of Check3: 检查 3:workspace 协议
Husky->>Check3: 检查 @org/* 依赖
Check3->>Check3: 验证是否使用 workspace:*
Check3-->>Husky: 返回结果
end
alt 所有检查通过
Husky-->>Git: 允许提交
Git-->>Dev: 提交成功
else 发现违规
Husky-->>Git: 阻止提交
Git-->>Dev: 提交失败<br/>输出错误信息
Dev->>Dev: 修复问题后重新提交
end
手动运行检查:
# 检查依赖规则
node scripts/check-dependencies.js
# 检查 workspace 协议
node scripts/check-workspace-protocol.js
绕过检查(不推荐):
# 仅在紧急情况下使用
git commit --no-verify -m "chore: emergency fix"
核心价值:
- 强制约束:通过工具而非文档约束,确保架构规则被严格执行
- 即时反馈:提交前就发现问题,避免问题代码进入代码库
- 团队协作:统一的规则检查,所有成员遵循相同标准
- 持续演进:随着项目发展,可以轻松调整规则并立即生效
3. 核心技术方案详解
3.1 Monorepo 工程化 (pnpm + Turbo)
1. pnpm workspace
pnpm 通过符号链接机制实现工作区内包的快速引用,确保开发时的即时同步。
核心优势:
- 即时同步:修改
packages/base/utils源码,a-project无需重装立即生效 - 单一实例:peerDependencies 确保整个依赖树只有一个
base-utils - 节省空间:符号链接而非复制,多个应用共享同一份源码
2. Turbo
Turbo 通过任务编排和智能缓存机制,实现增量构建和并行执行,大幅提升 Monorepo 的构建效率。
任务编排与依赖拓扑图:
sequenceDiagram
autonumber
participant CMD as pnpm turbo build
participant Turbo as Turbo 分析器
participant Base as base 层
participant Service as service 层
participant Component as component 层
participant Apps as apps 层
Note over CMD,Apps: Turbo 构建流程
CMD->>Turbo: 执行构建命令
Turbo->>Turbo: 读取 turbo.json<br/>分析依赖图
rect rgb(232, 245, 233)
Note right of Base: 第一波 (并行执行 3 个包)<br/>utils, types, ui
Turbo->>Base: 开始构建 base 层
Base->>Base: 并行编译
end
rect rgb(227, 242, 253)
Note right of Service: 第二波 (并行执行 7 个包)<br/>
Base->>Service: base 层完成,触发 service 层
Service->>Service: 并行编译
end
rect rgb(235, 228, 248)
Note right of Component: 第三波 (并行执行 1 个包)<br/>editor
Base->>Component: base 层完成,触发 component 层
Component->>Component: 并行编译
end
rect rgb(255, 243, 224)
Note right of Apps: 第四波 (应用层)<br/>
Service->>Apps: service 层完成,触发应用构建
Component->>Apps: component 层完成,触发应用构建
Apps->>Apps: 编译打包
end
Apps-->>CMD: 构建完成
DAG (有向无环图) 精准编排机制:
Turbo 通过分析 package.json 中的依赖关系和 turbo.json 中的任务配置,自动构建依赖图并确保正确的构建顺序。
核心原理:
graph TB
subgraph Step1["1 依赖分析"]
A1["读取所有 package.json"]
A2["提取 dependencies<br/>和 peerDependencies"]
A3["构建包依赖图"]
A1 --> A2 --> A3
end
subgraph Step2["2 拓扑排序"]
B1["检测循环依赖"]
B2["计算构建层级<br/>base, service/component, apps"]
B3["生成执行顺序"]
B1 --> B2 --> B3
end
subgraph Step3["3 并行执行"]
C1["同层级包并行构建"]
C2["等待当前层完成"]
C3["触发下一层构建"]
C1 --> C2 --> C3
end
Step1 --> Step2 --> Step3
style Step1 fill:#e8f5e9,stroke:#2e7d32
style Step2 fill:#e3f2fd,stroke:#1976d2
style Step3 fill:#fff3e0,stroke:#f57c00
实际示例:
循环依赖检测:
graph LR
subgraph Check["循环依赖检测机制"]
D1["深度优先遍历<br/>依赖图"]
D2{检测到<br/>回路?}
D3[" 报错并终止<br/>提示循环路径"]
D4[" 继续构建"]
D1 --> D2
D2 -->|是| D3
D2 -->|否| D4
end
style D3 fill:#ffebee,stroke:#c62828
style D4 fill:#e8f5e9,stroke:#2e7d32
循环依赖示例(错误示范):
检测循环依赖的工具:
# Turbo 自动检测
pnpm build # Turbo 会在构建前自动检测循环依赖
# 输出示例:
No circular dependencies found
Package graph is valid
本项目依赖验证:
根据项目当前结构分析:
- base 层 (3个包): 无内部依赖,可并行构建
- service 层 (7个包): 仅依赖 base 层,无循环
- component 层 (1个包): 仅依赖 base 层,无循环
- apps 层 (应用层): 依赖所有共享包,无循环
- 结论: 项目依赖结构健康,严格遵循单向依赖原则
配置示例:
// turbo.json
{
"tasks": {
"build": {
"dependsOn": ["^build"], // ^ 表示先构建依赖项
"outputs": ["dist/**"], // 缓存的产物目录
"inputs": ["src/**", "package.json"] // 参与哈希计算的输入
},
"dev": {
"cache": false, // dev 不缓存
"persistent": true
}
}
}
核心优势:
- 并行执行:自动识别依赖关系,同层级包并行构建
- 精准编排:通过 DAG 拓扑排序确保构建顺序正确
- 循环检测:构建前自动检测循环依赖,避免死锁
- 可视化:
turbo run build --graph生成依赖关系图
3.2 微前端架构 (Module Federation)
1. 双模式构建
通过 BUILD_TARGET 环境变量,项目可以输出两种不同模式的产物:
- 标准构建 (SPA):
BUILD_TARGET未定义时,构建一个标准的单页应用,输出到dist/。 - 远程模块构建:
BUILD_TARGET=remote时,构建一个微前端模块,输出到dist-mf/。
Webpack 配置 (extension.config.js) 会根据此变量动态调整,例如在远程模式下关闭代码分割 (splitChunks: false) 以保证单一入口文件。
2. 模块暴露与共享
mf.config.js 是 Module Federation 的核心配置:
name: 定义远程模块的全局唯一名称,如app_alpha_extension。filename: 定义远程模块的入口文件名,如entry.js。exposes: 列出需要暴露给宿主应用的模块及其对应的内部路径。这是实现组件级共享的关键。
exposes: {
// 格式: './<暴露的模块名>': './<内部源码路径>'
'./entry': './src/mf/main.ts',
'./需要暴露出去的内容': './src/<路径>',
}
shared: 定义与宿主共享的依赖库,以避免重复加载和多实例问题。可以精细控制每个库的版本要求、是否为单例 (singleton) 以及加载策略 (eager)。
3.3 共享包与应用交互机制
1. 源码级引用
此架构的核心优势在于源码级引用。应用在开发时直接引用 packages 的 TypeScript 源码,而非编译后的 dist 文件。
-
实现方式: 依赖
pnpm的符号链接和 Webpack 的resolve.alias配置。alias将@org/base-utils这样的导入路径直接映射到packages/base/utils/src。 -
优势:
-
即时热更新 (HMR): 修改
package代码,应用页面会立即热更新。 -
完整的类型支持: TypeScript 可以直接从源码推断类型,无需依赖
.d.ts文件。 -
简化调试: 在浏览器中可以直接调试
package的源码。
4. 开发与实践指南
4.1 开发流程
本节介绍从环境搭建到生产构建的完整开发流程。下图展示了开发者、pnpm、Turbo 和应用之间的完整交互流程,涵盖依赖安装、开发调试和生产构建三个核心阶段:
流程说明:
开发阶段:
- pnpm install:解析
pnpm-workspace.yaml,创建符号链接到node_modules - 安装完成:开发者修改
apps/a-project代码 - HMR 热更新:通过 symlink 直接引用源码,Webpack 检测文件变化触发热更新,源码变更即时生效
构建阶段:
- pnpm build:触发 Turbo 构建编排
- 读取 turbo.json:分析依赖关系,生成构建拓扑图
- 分层并行构建:
- base 层(基础层):3个包并行构建 产物输出到
dist/ - service/component 层(服务/组件层):8个包并行构建 产物输出到
dist/ - apps 层(应用层):a-project 构建完成 产物输出到
dist/ - 所有构建完成:返回开发者
核心优势:
- 开发效率:源码级引用 + HMR 热更新,修改立即生效,无需手动重启
- 构建速度:Turbo 自动按依赖关系编排,同层级包并行构建,充分利用多核性能
- 增量构建:Turbo 缓存机制确保只重新构建变更的包,大幅缩短构建时间
1. 启动开发环境
项目支持多应用并行开发,Turbo 提供灵活的启动方式:
cd apps/a-project
pnpm serve
端口配置说明:
| 应用 | 默认端口 | 配置位置 | 说明 |
|---|---|---|---|
| a-project | 8099 | vue.config.js | 当前已开发 |
| b-project | 8100 | vue.config.js | 规划中 |
// apps/a-project/vue.config.js
module.exports = {
devServer: {
port: 8099, // 应用独立端口,避免冲突
open: false,
https: true
}
};
最佳实践:
- 日常开发:使用
--filter只启动需要的应用,节省资源 - 依赖调试:修改 packages 时,应用会通过 HMR 自动更新,无需重启
- CI/CD:使用
pnpm dev启动所有应用进行集成测试 - 端口冲突:确保每个应用配置不同的端口号
2. 添加依赖
# 为应用添加依赖
pnpm --filter @org/a-project add vue-router
# 为共享包添加依赖
pnpm --filter @org/base-utils add dayjs
# 为所有包添加开发依赖
pnpm add -Dw eslint # -w 表示添加到根目录 (workspace root)
3. 本地联调 (微前端模式)
当需要在宿主应用(如 宿主项目)中调试 a-project 微前端模块时:
# Step 1: 监听模式构建微前端产物
cd apps/a-project
pnpm build:mf --watch
# Step 2: 新开终端,启动本地静态服务器
npx serve dist-mf -l 8098 --cors
# Step 3: 配置宿主应用加载本地模块
// 用宿主加载的方式加载即可
# Step 4: 刷新宿主应用
# 宿主会加载 http://localhost:8098/entry.js
联调流程图:
sequenceDiagram
autonumber
participant Dev as 开发者
participant Build as build:mf --watch
participant Serve as serve (8098)
participant Host as 宿主应用 (宿主项目)
Note over Dev,Host: 本地微前端联调流程
Dev->>Build: pnpm build:mf --watch
Build->>Build: 监听文件变化<br/>自动重新构建
Dev->>Serve: npx serve dist-mf -l 8098 --cors
Serve->>Serve: 提供本地静态服务<br/>http://localhost:8098
Host->>Serve: 加载 entry.js
Serve-->>Host: 返回微前端模块
Note over Dev,Host: 开发循环
Dev->>Dev: 修改源码
Build->>Build: 检测变化,重新构建
Build->>Serve: 更新 dist-mf/
Dev->>Host: 刷新页面
Host->>Serve: 重新加载 entry.js
Serve-->>Host: 返回最新模块
4.2 编码最佳实践
-
统一出口:
package应通过src/index.ts统一导出其 API,保持接口清晰。 -
避免循环依赖: 严格遵守
appspackages的单向依赖流。
# 定期检测循环依赖
pnpm add -D madge
madge --circular --extensions ts,tsx ./packages ./apps
# 或在 package.json 中添加脚本
"scripts": {
"check:circular": "madge --circular --extensions ts,tsx ./packages ./apps"
}
# CI 流程中自动检测
pnpm check:circular || exit 1
依赖规则:
- 允许: service/component base(服务/组件层依赖基础层)
- 允许: apps base/service/component(应用依赖共享包)
- 禁止: base 层之间相互依赖
- 禁止: service 层之间相互依赖
- 禁止: component 层之间相互依赖
- 禁止: component service(组件层不能依赖服务层)
- 禁止: packages apps(共享包不能依赖应用)
解决循环依赖的方法:
- 提取公共代码:将被循环引用的代码提取到新的 base 层包
- 使用依赖注入:通过参数传递而非 import
- 事件/回调模式:用事件驱动替代直接依赖
注意:项目已集成自动化检查工具,违反依赖规则的代码无法提交(详见 2.2.5 节)
-
无状态共享包:
packages应尽量保持无状态。需要状态时,应由应用层通过参数注入,或通过工厂函数模式创建实例。 -
类型与实现分离: 使用
export type { ... }导出类型,export { ... }导出具体实现。
4.3 调试技巧
- 缓存问题: 若修改
package后未生效,最常见的原因为缓存。执行rm -rf node_modules/.cache && rm -rf apps/a-project/dist*清理缓存后重试。 - Vue DevTools: 用于检查组件层级、Props 和 Vuex 状态。
- VS Code 调试: 配置
launch.json以附加到 Chrome,可以直接在编辑器中对源码(包括packages中的代码)设置断点。