深入理解 Monorepo:子包安装依赖

19 阅读11分钟

在 pnpm workspace 中,正确地为每个子包安装依赖是 Monorepo 工程化的基础。本文详细介绍 4 种安装方式、依赖类型区分、验证方法和最佳实践。

概念梳理文章地址:深入理解 Monorepo:概念梳理新技术的的诞生一定是有它的技术背景的。新技术一定是适合公司、团队的技术吗?我看这倒未 - 掘金

工程落地文章地址:深入理解 Monorepo:工程落地在上一篇中对 Monorepo 的基本概念进行了讲解,在本篇文章中,将会结合项目代码 - 掘金

前言

当你从传统的单仓单包项目迁移到 Monorepo 后,第一个遇到的问题往往是:

"我想给 apps/web 装一个 axios,应该在根目录装还是在子目录装?命令怎么写?"

在 Monorepo 中,依赖安装不再是简单的 pnpm install xxx。你需要明确:

  • 这个依赖是给哪个包装的?
  • 是运行时依赖还是开发依赖?
  • 是第三方包还是 Monorepo 内部的本地包?
  • 是所有包共享的工程工具,还是某个包专属的业务依赖?

本文以 pnpm workspace 为例,系统讲解 Monorepo 中子包依赖的安装方法。


前置知识:pnpm workspace 的依赖结构

在开始之前,先理解 pnpm workspace 的核心特点:

my-monorepo/
├── node_modules/              # 根目录依赖(全局工程工具)
│   ├── .pnpm/                 # pnpm 的真实存储(硬链接到全局 store)
│   ├── eslint -> .pnpm/...   # 符号链接
│   └── turbo -> .pnpm/...
├── apps/
│   └── web/
│       ├── node_modules/      # 子包专属依赖
│       │   ├── axios -> ../../node_modules/.pnpm/...
│       │   └── @my-monorepo/
│       │       ├── ui -> ../../../packages/ui        # 软链接到本地包
│       │       └── utils -> ../../../packages/utils  # 软链接到本地包
│       └── package.json
├── packages/
│   ├── ui/
│   │   └── package.json
│   └── utils/
│       └── package.json
├── package.json               # 根目录配置
└── pnpm-workspace.yaml        # workspace 声明

关键特点:

  1. 根目录的 node_modules 存放所有包共享的工程工具(eslint、turbo 等)
  2. 子包的 node_modules 存放该包专属的依赖,通过符号链接指向根目录的 .pnpm 存储
  3. 本地包依赖通过软链接直接指向源码目录,修改即时生效,无需发布
  4. pnpm-lock.yaml 在根目录,统一管理所有包的依赖版本锁定

方法一:根目录 + --filter(最推荐)

根目录执行命令,通过 --filter 参数指定要给哪个包装依赖。这是最推荐的方式,不需要切换目录,也不容易装错位置。

基本语法

pnpm --filter <包名> add <依赖名> [参数]

实际示例

# 给 @my-monorepo/web 安装运行时依赖 axios
pnpm --filter @my-monorepo/web add axios

# 给 @my-monorepo/web 安装开发依赖 vitest
pnpm --filter @my-monorepo/web add -D vitest

# 给 @my-monorepo/ui 安装 peer 依赖 vue
pnpm --filter @my-monorepo/ui add --save-peer vue

# 同时安装多个依赖
pnpm --filter @my-monorepo/web add axios vue-router pinia

--filter 的多种匹配方式

--filter 非常灵活,支持以下匹配方式:

# 1. 按包名精确匹配(最常用)
pnpm --filter @my-monorepo/web add axios

# 2. 按目录路径匹配
pnpm --filter ./apps/web add axios

# 3. 通配符匹配(给所有 packages 下的包装)
pnpm --filter "./packages/*" add lodash-es

# 4. 匹配某个包的所有依赖(...前缀)
# 例如:@my-monorepo/web 依赖了 ui 和 utils,这个命令会给 web、ui、utils 都装
pnpm --filter ...@my-monorepo/web add -D eslint

# 5. 取反匹配(排除某个包)
pnpm --filter "!@my-monorepo/utils" add -D prettier

执行结果

执行后,依赖会被写入对应子包package.json,而不是根目录的:

// apps/web/package.json(自动更新)
{
  "name": "@my-monorepo/web",
  "dependencies": {
    "axios": "^1.6.0"
  }
}

同时,根目录的 pnpm-lock.yaml 会自动更新,apps/web/node_modules/axios 会自动建立符号链接。


方法二:进入子包目录直接安装

cd 到子包目录后,直接执行 pnpm add,pnpm 会自动识别当前目录属于哪个 workspace 包,并把依赖写入该包的 package.json

基本语法

cd <子包目录>
pnpm add <依赖名> [参数]

实际示例

# 进入 web 应用目录
cd apps/web

# 安装运行时依赖 → 写入 apps/web/package.json 的 dependencies
pnpm add axios

# 安装开发依赖 → 写入 devDependencies
pnpm add -D vitest

# 安装 peer 依赖 → 写入 peerDependencies
pnpm add --save-peer vue

效果

和方法一完全一样,依赖会被写入当前目录的 package.json。区别只是需要先 cd 进子目录。

提示:如果你不确定当前目录属于哪个包,可以执行 pnpm list 查看,或者看当前目录的 package.json 中的 name 字段。


方法三:安装本地包(workspace 内部依赖)

当一个子包需要依赖另一个本地子包时(例如 apps/web 需要使用 packages/ui 的组件),必须使用 workspace:* 协议,而不是写具体版本号。

什么是 workspace:*

workspace:* 是 pnpm workspace 的特殊协议,表示"这个依赖来自 Monorepo 内部,使用本地最新源码"。pnpm 解析时会:

  1. 在 workspace 中查找同名的包
  2. 在使用方的 node_modules 中创建软链接,直接指向本地源码目录
  3. 修改本地包的源码后,使用方即时生效,无需发布或重新安装

方式 A:用命令安装

# web 依赖本地的 ui 包
pnpm --filter @my-monorepo/web add @my-monorepo/ui@workspace:*

# web 同时依赖 ui 和 utils
pnpm --filter @my-monorepo/web add @my-monorepo/ui@workspace:* @my-monorepo/utils@workspace:*

方式 B:手动编辑 package.json(更常用)

直接编辑子包的 package.json,在 dependencies 中添加本地包引用:

// apps/web/package.json
{
  "name": "@my-monorepo/web",
  "dependencies": {
    "vue": "^3.3.0",
    "@my-monorepo/ui": "workspace:*",
    "@my-monorepo/utils": "workspace:*"
  }
}

然后在根目录执行一次安装,让 pnpm 建立软链接:

pnpm install

安装后的软链接结构

执行 pnpm install 后,apps/web/node_modules/@my-monorepo/ 下会创建软链接:

apps/web/node_modules/@my-monorepo/
├── ui    -> ../../../packages/ui      # 软链接,指向本地源码
└── utils -> ../../../packages/utils    # 软链接,指向本地源码

你可以用以下命令验证:

ls -la apps/web/node_modules/@my-monorepo/

为什么不能写具体版本号

// ❌ 错误:写具体版本号
{
  "dependencies": {
    "@my-monorepo/ui": "1.0.0"
  }
}

如果写具体版本号,pnpm 会去 npm registry 查找 @my-monorepo/ui@1.0.0,而不是使用本地包。如果这个包没有发布到 npm,就会安装失败。

// ✅ 正确:使用 workspace:* 协议
{
  "dependencies": {
    "@my-monorepo/ui": "workspace:*"
  }
}

workspace:* 告诉 pnpm:"这个依赖来自本地 workspace,不要去 npm 找。"


方法四:安装到根目录(全局共享)

工程治理类工具(eslint、prettier、turbo、typescript、syncpack 等)应该装在根目录,所有子包共享,不要每个包都装一遍。

基本语法

pnpm add -Dw <依赖名>
  • -D = --save-dev,写入 devDependencies
  • -w = --workspace-root,安装到根目录

实际示例

# 安装全局工程工具
pnpm add -Dw eslint prettier turbo typescript

# 安装依赖检查工具
pnpm add -Dw syncpack dependency-cruiser

# 安装 git hooks 工具
pnpm add -Dw husky lint-staged

执行结果

依赖会被写入根目录package.json

// 根目录 package.json
{
  "name": "my-monorepo",
  "private": true,
  "devDependencies": {
    "eslint": "^8.50.0",
    "prettier": "^3.0.0",
    "turbo": "^1.10.0",
    "typescript": "^5.0.0"
  }
}

这些工具会安装在根目录的 node_modules 中,所有子包都可以访问到。

为什么工程工具要装在根目录

原因说明
避免重复安装每个包都装一遍 eslint,浪费磁盘空间,且版本可能不一致
版本统一所有包使用同一个版本的 eslint,lint 行为一致
全局配置eslint、prettier 的配置文件通常在根目录,工具也应该在根目录
CI 简化CI 中只需要在根目录执行 pnpm install,所有工具就都可用了

依赖类型详解

在安装依赖时,需要明确依赖的类型,不同类型写入 package.json 的不同字段,对运行时和构建时的影响也不同。

类型命令参数写入字段说明示例
运行时依赖pnpm add xxxdependencies生产环境运行时需要的包vue、axios、vue-router、express
开发依赖pnpm add -D xxxdevDependencies构建、测试、开发时需要的包vite、vitest、eslint 插件、typescript
对等依赖pnpm add --save-peer xxxpeerDependencies可发布库要求使用方自己安装的包vue、react(组件库的 peer)
可选依赖pnpm add -O xxxoptionalDependencies可选的、安装失败不影响主流程的包某些平台特定的原生模块
本地 workspace 包pnpm add xxx@workspace:*dependenciesMonorepo 内部的本地包@my-monorepo/ui@workspace:*

运行时依赖 vs 开发依赖

# ✅ 运行时依赖:代码中 import 了,生产环境需要
pnpm --filter @my-monorepo/web add axios

# ✅ 开发依赖:只在构建/测试时用,生产环境不需要
pnpm --filter @my-monorepo/web add -D vitest

判断标准:在 src/ 目录的代码中 import 了的包,就是运行时依赖;只在配置文件、构建脚本、测试文件中用的,就是开发依赖。

对等依赖(peerDependencies)

对等依赖主要用于可发布的库。例如你开发了一个 Vue 组件库 @my-monorepo/ui,使用这个组件库的项目必须自己安装 Vue,组件库不应该把 Vue 打包进去(否则会导致项目中有多个 Vue 实例)。

// packages/ui/package.json
{
  "name": "@my-monorepo/ui",
  "peerDependencies": {
    "vue": "^3.0.0"
  },
  "devDependencies": {
    "vue": "^3.3.0"
  }
}
  • peerDependencies:声明"使用方需要自己安装 vue@^3.0.0"
  • devDependencies:组件库自己开发时需要安装 vue 用于测试和构建

安装后验证

安装依赖后,建议进行以下验证,确保安装正确。

1. 查看某个包的完整依赖树

pnpm --filter @my-monorepo/web list

输出示例:

@my-monorepo/web@0.1.0
├── axios@1.6.0
├── vue@3.3.4
├── vue-router@4.2.4
├── @my-monorepo/ui -> link:../../packages/ui
└── @my-monorepo/utils -> link:../../packages/utils

注意 -> link: 表示这是本地 workspace 包的软链接。

2. 检查本地包软链接

ls -la apps/web/node_modules/@my-monorepo/

输出示例:

ui -> ../../../packages/ui
utils -> ../../../packages/utils

如果看到 -> 指向本地目录,说明软链接建立成功。

3. 查看某个具体依赖是否安装

pnpm --filter @my-monorepo/web list axios

4. 验证构建是否正常

# 构建单个包
pnpm --filter @my-monorepo/web run build

# 或构建所有包
pnpm build

如果构建成功,说明依赖安装正确,代码可以正常引用。


常见问题与最佳实践

问题 1:可以用 npm 或 yarn 安装吗?

不建议。 如果项目使用 pnpm workspace,就应该统一使用 pnpm。混用 npm/yarn 会导致:

  • lockfile 冲突(pnpm-lock.yaml vs package-lock.json vs yarn.lock
  • workspace:* 协议 npm 不支持,会安装失败
  • node_modules 结构不同,可能导致依赖解析错误

如果确实要切换包管理器,需要先删除旧的 lockfile 和 node_modules,再用新的包管理器安装。

问题 2:安装本地包时写了具体版本号会怎样?

// ❌ 错误
{
  "dependencies": {
    "@my-monorepo/ui": "1.0.0"
  }
}

pnpm 会去 npm registry 查找 @my-monorepo/ui@1.0.0,如果这个包没有发布,就会报 404 Not Found。即使发布了,也会使用 npm 上的版本,而不是本地最新源码,修改本地代码不会即时生效。

正确做法:始终使用 workspace:* 协议。

问题 3:每个包都需要装一遍 eslint 吗?

不需要。 eslint、prettier、turbo、typescript 等工程工具统一装在根目录:

pnpm add -Dw eslint prettier turbo

子包的 devDependencies 中不需要重复声明。在根目录执行 pnpm lint 时,eslint 会从根目录的 node_modules 解析。

问题 4:如何删除某个包的依赖?

# 删除 @my-monorepo/web 的 axios 依赖
pnpm --filter @my-monorepo/web remove axios

不要手动删除 node_modules 中的文件,应该用 pnpm remove 命令,它会同时更新 package.jsonpnpm-lock.yaml

问题 5:安装后软链接没生效怎么办?

执行 pnpm install 重新建立链接:

pnpm install

如果还是不行,可以尝试清理后重新安装:

# 删除所有 node_modules(谨慎操作)
rm -rf node_modules apps/*/node_modules packages/*/node_modules

# 重新安装
pnpm install

问题 6:如何给所有包批量安装同一个依赖?

# 给所有 workspace 包装 lodash-es
pnpm -r add lodash-es

# 给所有 packages 下的包装
pnpm -r --filter "./packages/*" add lodash-es

-r = --recursive,递归所有 workspace 包。


最佳实践总结

场景推荐做法命令示例
给某个包装第三方依赖根目录 + --filterpnpm --filter @my-monorepo/web add axios
给某个包装开发依赖根目录 + --filter + -Dpnpm --filter @my-monorepo/web add -D vitest
安装本地 workspace 包手动编辑 + workspace:*"@my-monorepo/ui": "workspace:*"
安装全局工程工具根目录 + -Dwpnpm add -Dw eslint prettier turbo
删除某个包的依赖pnpm --filter <包> removepnpm --filter @my-monorepo/web remove axios
批量给所有包装-r(递归)pnpm -r add lodash-es
重新建立软链接根目录执行pnpm install

一句话记忆

业务依赖装在子包,工程工具装在根目录,本地包用 workspace:*,安装统一用 pnpm --filter


总结

在 pnpm workspace Monorepo 中,依赖安装有 4 种方式:

  1. 根目录 + --filter:最推荐,不用切换目录,精确控制给哪个包装
  2. 进入子包目录直接装:直观,但需要 cd,效果和方法一相同
  3. 安装本地包:使用 workspace:* 协议,通过软链接指向本地源码,修改即时生效
  4. 安装到根目录:工程治理工具统一装在根目录,所有包共享,避免重复和版本不一致

正确区分依赖类型(运行时/开发/对等/可选),安装后验证软链接和构建,遵循"业务依赖装子包、工程工具装根目录"的原则,就能在 Monorepo 中高效地管理依赖。

希望这篇文章能帮助你在 Monorepo 项目中正确地为各个子包安装依赖。如果有任何问题,欢迎在评论区交流。