基于 pnpm + Turbo 的 Monorepo 工程化实践

11 阅读15分钟

这篇文章整理了一套可复用的 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-typesTypeScript 类型定义用户类型、消息类型、任务类型、认证类型、扩展接口等全局类型
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. 机制 1 生效 pnpm workspace 的符号链接使修改对所有依赖方可见
  2. 机制 2 保证 peerDependencies 确保只有一个 base/utils 实例被更新
  3. 机制 3 处理 Webpack alias 将源码编译并通过 HMR 推送到浏览器
  4. 浏览器更新 应用自动刷新,显示最新代码效果
2.2.4 架构优势

零循环依赖:严格的单向依赖流(apps service/component base),通过自动化工具检测 高复用性:通用能力下沉,避免重复开发,新应用可快速接入 类型安全:TypeScript 直接从源码推断类型,无需维护 .d.ts 声明文件 开发体验:HMR 即时生效,修改 packages 代码应用立即更新 独立演进:每个包和应用都有独立的版本和生命周期 渐进式接入:新应用只需依赖所需的 packages,不必全量引入


2.2.5 架构约束与校验

为确保分层架构的严格执行,项目通过 Git 提交钩子(pre-commit) 自动检查依赖规则,任何违反架构约束的代码都无法提交。

依赖规则:

1. 分层依赖规则

层级允许依赖禁止依赖说明
base任何 @org/* 包基础层必须零依赖,保持纯净
servicebase 层service 层、component 层、apps 层服务层包之间不能互相依赖
componentbase 层service 层、component 层、apps 层组件层包之间不能互相依赖
appsbase、service、componentapps 层应用之间不能互相依赖

规则示例:

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-project8099vue.config.js当前已开发
b-project8100vue.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,保持接口清晰。

  • 避免循环依赖: 严格遵守 apps packages 的单向依赖流。

# 定期检测循环依赖
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(共享包不能依赖应用)

解决循环依赖的方法:

  1. 提取公共代码:将被循环引用的代码提取到新的 base 层包
  2. 使用依赖注入:通过参数传递而非 import
  3. 事件/回调模式:用事件驱动替代直接依赖

注意:项目已集成自动化检查工具,违反依赖规则的代码无法提交(详见 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 中的代码)设置断点。