POST · 前端 · 文章
Monorepo 与依赖共享:npm / yarn / pnpm / Lerna 四套 workspaces 实战
2025/06/18 · by ZHANG XING YUN · 阅读 1 次 · 4335 字 · 预计阅读 11 分钟
适用环境:Node ≥ 18(Lerna 8 要求)、npm ≥ 7(workspaces 从 v7 引入)、pnpm ≥ 8、Yarn 1.x(下文会标出 2+ 的差异) 文中所有命令都在配套工程里实跑验证过,结论见每节末尾的「验证」块。
一、要解决的问题:为什么需要 monorepo
先说清楚动机,否则容易把"共享 node_modules"当成目的,它只是结果。
遇到的问题:
- 重复安装:
project-a和project-b都要vue@3.4、vite、eslint,各自装一遍,磁盘翻倍、安装时间翻倍。 - 跨包调试靠"发版":改公共组件库 → 发 npm → 在业务项目升级版本 → 再调试。改一行要等一次发布会。
- 版本漂移:两个项目里
axios一个^1.6一个^1.7,线上行为不一致,排查半天。 - 规范重复:
eslint/prettier/tsconfig/ CI 配置每个仓库抄一遍。
monorepo 的答案: 一个 git 仓库、一个依赖安装、包之间用符号链接互相引用。
代价(必须提前说):
- 仓库变大,CI 要按包做缓存与增量构建,否则每次全量。
- 权限粒度变粗(同仓库成员都能改所有包)。
- 依赖版本冲突时会出现嵌套
node_modules,需要团队约定"统一版本"的规则。 - 发布需要工具配合(这就是 Lerna 还存在的原因)。
二、原理:依赖到底被放在哪
2.1 提升(hoisting)与符号链接
假设两个包里都声明了 lodash 和 md5:
monorepo/
├─ package.json # private: true, workspaces: ["packages/*"]
├─ node_modules/ # 绝大多数依赖被"提升"到这里
│ ├─ lodash/
│ ├─ md5/
│ ├─ @packages/
│ │ ├─ project1 -> ../../packages/project1 # 符号链接
│ │ └─ project2 -> ../../packages/project2
└─ packages/
├─ project1/ # 自己声明 lodash@4.17
└─ project2/ # 自己声明 lodash@4.3(版本不同)
- 提升:公共依赖装到根
node_modules,子包不再各自装一份 → 这是"共享 node_modules"的真正来源。 - 符号链接:
node_modules/@packages/project2指向源码目录,所以project1里import '@packages/project2'直接读到最新源码,不需要发版。 - 版本冲突:npm/yarn 遇到版本不一致时,会把"让大多数人满意"的版本提到根,另一个版本留在子包的
node_modules里(嵌套)。pnpm 不这么做,见第五节。
2.2 幽灵依赖(phantom dependency)
因为提升,project1 即使没在自己的 package.json 里声明 lodash,也能 import 'lodash' 成功——它只是恰好被 project2 提升到了根。
后果:某天 project2 删掉 lodash,project1 立刻崩,而它"明明什么都没改"。
规避:每个包只 import 自己 package.json 里声明过的依赖。pnpm 默认就在结构上强制这一点。

三、npm workspaces
3.1 创建
mkdir monorepo && cd monorepo
npm init -y
根 package.json 里加 workspaces(packages/* 表示 packages 下所有一级子目录):
{
"name": "monorepo",
"version": "1.0.0",
"private": true,
"workspaces": ["packages/*"],
"scripts": {
"dev:p3": "npm run dev --workspace=@packages/project3",
"build:p3": "npm run build --workspace=@packages/project3",
"build:all": "npm run build --workspaces --if-present"
}
}
private: true必须加:workspace 根包不该被发布,加了private才能避免npm publish把它发出去(yarn 则更严格,不加直接报错)。
也可以用命令直接建子包,它会同时把路径写进 workspaces:
npm init -w ./packages/project1 -y
子包的 name 建议用作用域命名,后文所有 -w 都靠它定位:
{
"name": "@packages/project1",
"version": "1.0.0",
"main": "index.js",
"scripts": { "test": "echo \"Error: no test specified\" && exit 1" }
}
目录结构(示例工程就是这样):
workspace/npm
├─ package.json
└─ packages
├─ project1/package.json
├─ project2/package.json
└─ project3/package.json
3.2 安装依赖
# 根目录:安装所有子包依赖,统一提升到根 node_modules
npm install
# 只装到某个子包(会写进该子包的 package.json)
npm install md5@latest -w @packages/project2
# 同时操作多个子包
npm install axios -w @packages/project1 -w @packages/project2
# 装到根(工具类依赖,如 eslint、prettier、lerna)
npm install eslint -W
-w(--workspace)指定一个子包;-W(--workspaces)表示所有子包/根,装到根 package.json。
关于"在子目录里直接 npm install":npm 7+ 会向上找到 workspace 根,把依赖记录到当前子包的 package.json,然后把包装到根 node_modules。所以和用 -w 的结果基本一致。老版本 npm(8 以前)可能在该子包下再生成一份完整的 node_modules,这才是网上"不要在子目录装依赖"说法的由来。
3.3 子包互相引用
npm ls -ws --depth=0 # 看所有 workspace 的依赖
node_modules/@packages/project2 已经是指向源码的链接,所以:
import _ from "@packages/project2";
不需要发版。但为了"依赖可读",建议在 package.json 里补一条显式声明:
{
"dependencies": {
"@packages/project2": "*"
}
}
"*" 表示"用仓库里的这个包";写具体版本号也可以,但 npm 会用 workspace 里的版本而不是去 registry 下载。
用 "file:../project2" 也能装上,但它走的是"文件依赖"语义,包管理器不会把它当成 workspace 链接:yarn 会在 yarn workspaces info 里把它列进 mismatchedWorkspaceDependencies,npm 也可能在子包里生成一份拷贝。所以 workspace 里统一用 "*" 或版本号。
⚠️ 避免环形依赖:A 依赖 B、B 依赖 C、C 依赖 A,链接会绕成环,构建顺序也无法确定。
3.4 验证
npm ls -ws --depth=0 # 列出所有 workspace 及其直接依赖
npm query ".workspace" # npm 8.16+:以 JSON 输出 workspace 信息
四、yarn workspaces(Yarn 1)
本节命令是 Yarn 1.x(Classic)。Yarn 2+(Berry)不兼容:配置在
.yarnrc.yml、用yarn workspaces foreach、默认 PnP 没有node_modules。用yarn -v确认版本。
4.1 创建
mkdir yarn-monorepo && cd yarn-monorepo
yarn init -y
package.json:
{
"name": "yarn-monorepo",
"version": "1.0.0",
"private": true,
"workspaces": ["packages/*"],
"scripts": {
"dev:p3": "yarn workspace @packages/project3 dev",
"build:p3": "yarn workspace @packages/project3 build"
}
}
private: true 是硬性要求,否则 yarn 直接报错退出:

官方给出的原因(workspace 是纯本地概念,远端 registry 不认识它,所以必须标记私有以免误发布):

4.2 安装与运行
yarn # 安装所有子包依赖(提升到根 node_modules)
yarn workspace @packages/project2 add lodash # 装到指定子包,写进该子包 package.json
yarn add vuex -W # 装到根
yarn workspace @packages/project3 dev # 在子包里跑脚本
4.3 与 npm 的差别
yarn add <pkg> -W的-W是--ignore-workspace-root-check,语义是"我知道我在往根装";npm 的-W是"装到所有 workspace/根"。- yarn 1 安装完后会在根生成
yarn.lock,只需提交这一个。 - yarn 1 没有
--if-present的等价物,批量跑脚本要写yarn workspaces run <script>(所有子包都执行,缺脚本会报错)。
4.4 验证
yarn workspaces info # 输出每个 workspace 的依赖树(JSON)
五、pnpm workspaces
pnpm 是专为 monorepo 设计的包管理器,三点优势:
- 硬链接 + 全局内容寻址存储:同一个版本在磁盘上只存一份,多个项目共享。
- 非扁平
node_modules:依赖只能访问自己声明过的包,天然杜绝幽灵依赖。 - 速度快:安装与并发能力更强。
5.1 创建
mkdir pnpm-monorepo && cd pnpm-monorepo
pnpm init
与 npm/yarn 不同,workspace 配置不在 package.json 里,而是根目录的 pnpm-workspace.yaml:
packages:
- "packages/*"
# - "components/**"
# - "!**/test/**"
子包:
mkdir -p packages/project1 && cd packages/project1
pnpm init
5.2 安装与运行
pnpm install # 安装所有子包依赖
pnpm add md5 --filter @packages/project4 # 装到指定子包
pnpm add dayjs -w # -w = --workspace-root,装到根
pnpm --filter @packages/project3 run dev # 在子包跑脚本
pnpm -r build # 在所有子包跑 build(递归)
--filter 支持依赖图语法,比 npm 的 -w 灵活得多:
| 写法 | 含义 |
|---|---|
--filter @packages/project3 |
只选这个包 |
--filter "@packages/project3..." |
这个包及其依赖 |
--filter "...@packages/project3" |
这个包及其依赖者(谁依赖它) |
--filter "!@packages/project4" |
排除某个包 |
--filter "./packages/**" |
按目录匹配 |
常用组合:pnpm --filter "...{packages/*}[HEAD]" 这类按 git 变更过滤,CI 增量构建很省时间。
5.3 子包互相引用
pnpm 用 workspace 协议表达"用仓库里的版本":
{
"dependencies": {
"@packages/project2": "workspace:^"
}
}
安装后会被替换成真实版本号;发布时会自动转换,避免把 workspace:^ 发到 npm 上。这是 pnpm 相对 npm/yarn 更规范的地方。
5.4 三个必知开关
node-linker=hoisted(.npmrc):把依赖摊平成 npm 那样,兼容不认符号链接的老工具(某些 webpack 4 / react-scripts 项目)。public-hoist-pattern/shamefully-hoist=true:把某些包提升到根,解决插件找不到 peer 依赖的问题。onlyBuiltDependencies(pnpm 10 起):出于安全考虑,pnpm 10 默认不执行依赖的 postinstall 构建脚本,会提示Run "pnpm approve-builds"。像react-scripts、esbuild、sharp这类需要编译的包,要么手动执行pnpm approve-builds勾选,要么在pnpm-workspace.yaml/package.json的pnpm.onlyBuiltDependencies里声明。
5.5 验证
pnpm -r list --depth -1 # 所有子包及其名称/版本
pnpm why lodash # 某个包为什么被装(定位幽灵依赖/重复版本)
注意 pnpm 没有把依赖提升到根:示例工程里根 node_modules 只有寥寥几个条目,依赖都在各子包自己的 node_modules 下(符号链接到根 node_modules/.pnpm)。所以验证"子包能否被解析"要在依赖它的子包内执行:
cd packages/project3
node -e "console.log(require.resolve('@packages/project2'))" # 应输出 ../project2/index.js
示例工程里 project3 声明 lodash@^4.17、project4 声明 lodash@4.3,正好可以观察 pnpm 如何为两个包分别解析版本。
六、Lerna
6.1 它现在负责什么
Lerna 2015 年诞生时,包管理器还没有 workspaces,所以 bootstrap / add / link 是刚需。现在这些能力已经全部由包管理器接管,Lerna 只剩三件事:
- 版本管理(Fixed 统一版本 / Independent 独立版本)
- 发布(
lerna publish,含 changelog、tag、registry 处理) - 批量执行(
lerna run,带拓扑排序和并行/流式输出)
6.2 已删除的命令(v7 起弃用,v9 彻底移除)
| 旧命令 | 状态 | 现在怎么做 |
|---|---|---|
lerna bootstrap |
v7 弃用、v9 移除 | 直接 npm install / yarn / pnpm install |
lerna add <pkg> |
v7 弃用、v9 移除 | npm i <pkg> -w <包名> / pnpm add <pkg> --filter <包名> |
lerna link |
v7 弃用、v9 移除 | install 时由包管理器自动链接 |
v7/v8 里执行这三条会报错并提示安装 @lerna/legacy-package-management 做兼容,v9 起彻底没有。
6.3 初始化
npm i -D lerna # 推荐装成项目依赖,CI 上才有确定的版本
npx lerna init # 生成 packages/ 与 lerna.json
npx lerna init --independent # 独立版本模式(每个包各自版本号)
lerna.json(v7+ 不再需要 packages 字段,直接从 package.json 的 workspaces 读):
{
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
"version": "0.0.0"
}
根 package.json:
{
"name": "root",
"private": true,
"workspaces": ["packages/*"],
"scripts": {
"dev": "lerna run --parallel dev",
"build": "lerna run build",
"dev:p2": "lerna run dev --scope=@packages/project2"
},
"dependencies": {
"md5": "^2.3.0"
},
"devDependencies": {
"lerna": "^8.1.3"
}
}
lerna run build:按依赖拓扑顺序依次构建。lerna run --parallel dev:所有包同时起 dev server(顺序无关的任务才用)。--stream:把各包日志带前缀实时输出,排查构建失败很有用。
6.4 创建子包
lerna create utils # 生成 packages/utils
lerna create project1
6.5 常用命令(v9 仍然保留的)
| 命令 | 作用 |
|---|---|
lerna list |
列出本地包(默认不显示 private: true 的包,加 --all 才全显示) |
lerna changed |
自上个 tag 以来有变更的包 |
lerna diff [pkg] |
查看某个包的 git 变更 |
lerna exec -- <cmd> |
在每个包里执行任意命令,如 lerna exec -- rm -rf dist |
lerna run <script> -- <args> |
跑所有包里的同名 npm script |
lerna version |
计算并写入新版本号、打 tag |
lerna publish |
发布到 npm |
lerna import |
把带提交历史的仓库并入 monorepo |
lerna clean |
删除所有包的 node_modules(部分版本保留) |
lerna info |
打印本地环境信息 |
6.6 验证
npx lerna list --all # 列出所有包(含 private)
npx lerna run --stream build
实测:示例工程里
@packages/project2是private: true,所以lerna list只显示 2 个包,加--all才是 3 个——第一次用容易以为"包丢了"。
七、四者对比与选型
| 维度 | npm workspaces | yarn 1 workspaces | pnpm workspaces | Lerna |
|---|---|---|---|---|
| 配置位置 | package.json 的 workspaces |
同 npm | pnpm-workspace.yaml |
复用包管理器 + lerna.json |
| 依赖布局 | 提升(扁平) | 提升(扁平) | 符号链接 + 非扁平 | 取决于底层包管理器 |
| 幽灵依赖 | 有 | 有 | 无(默认严格) | 取决于底层 |
| 磁盘占用 | 中 | 中 | 低(全局存储 + 硬链接) | 取决于底层 |
| 子包互引 | "*" 或版本号 |
"*" 或版本号 |
workspace:^ 协议 |
同所选包管理器 |
| 批量执行 | -ws + --if-present |
yarn workspaces run |
-r + --filter 图语法 |
lerna run(拓扑排序) |
| 版本/发布 | 无内置 | 无内置 | pnpm publish -r 基础支持 |
强项:Fixed/Independent、changelog、tag |
| 上手成本 | 最低 | 低 | 中(要理解严格结构) | 中高(概念多) |
选型建议:
- 新项目、团队只用 npm:npm workspaces,够用且零额外依赖。
- 库多、要求依赖隔离严格、CI 想快:pnpm workspaces(目前社区主流)。
- 需要给一批包做版本对齐、生成 changelog、发布到 npm:Lerna + pnpm/npm,Lerna 只当"发布层"。
- 只是想把几个项目放一个仓库共享依赖、不发 npm:不要引入 Lerna,包管理器足够。
八、常见坑
private: true忘了加:yarn 直接报错;npm 则允许,但你可能会误 publish 根包。- 在根
package.json装业务依赖:只有工具类(eslint、prettier、lerna、typescript)该放根;业务依赖放各自的包,否则所有包共享同一个版本,改动影响面不可控。 - lockfile 提交策略:一个仓库只提交一份 lock(
package-lock.json/yarn.lock/pnpm-lock.yaml三选一),并且与 CI 使用的包管理器一致。留着yarn.lock却用npm install,会让 Lerna(底层 Nx)误判包管理器,报Command failed: yarn --version。 - CI 用
npm ci:根目录执行即可,它会按 lock 安装所有 workspace;不要在子目录分别npm ci。 - TS 路径解析:子包互相引用时,开发期用 workspace 链接,构建期可能需要
tsconfig的paths或references,否则tsc找不到类型。 - peerDependencies:pnpm 默认严格检查,缺 peer 会报 warning/error,用
public-hoist-pattern或显式补依赖解决,别直接shamefully-hoist=true掩盖问题。 - 版本冲突:统一版本靠
overrides(npm)/resolutions(yarn)/pnpm.overrides;出现嵌套node_modules时先用npm ls <pkg>/pnpm why <pkg>定位。 - 发布前检查:子包里不该有
private: true(除非确实不发布),files字段决定打包内容,publishConfig.registry别指向镜像(示例工程里就有,注意改成官方源)。 - pnpm 10 的构建脚本限制:安装时看到
Run "pnpm approve-builds"不是错误,但不处理会导致 esbuild/react-scripts 之类编译产物缺失,项目起不来。
九、验证与排错清单
# 1) 结构是否正确
npm ls -ws --depth=0 # npm
yarn workspaces info # yarn 1
pnpm -r list --depth -1 # pnpm
npx lerna list # lerna
# 2) 子包链接是否生效(装完依赖后,应解析到 packages/project2 源码目录)
node -e "console.log(require.resolve('@packages/project2'))" # npm / yarn:根目录可用(依赖被提升)
cd packages/project3 && node -e "console.log(require.resolve('@packages/project2'))" # pnpm:必须在依赖它的子包内
# 3) 依赖从哪来(排查幽灵依赖、重复版本)
npm ls lodash --all
pnpm why lodash
# 4) 脚本能否跨包执行
npm run build -w @packages/project3
pnpm --filter @packages/project3 build
npx lerna run build --stream
# 5) 只有一个 lockfile,且和实际使用的包管理器一致
ls *.lock package-lock.json pnpm-lock.yaml 2>/dev/null
常见现象对照:
| 现象 | 原因 | 处理 |
|---|---|---|
npm run dev -w xxx 报 No workspaces found |
作用域/包名写错,或 workspaces 路径不匹配 |
用 npm ls -ws 确认真实包名 |
子包 import 另一个包报 Cannot find module |
没装依赖,或包名与 name 字段不一致 |
根目录 install,核对 name |
yarn 报 Workspaces can only be enabled in private projects |
根 package.json 缺 private: true |
补上 |
pnpm 报 ERR_PNPM_WORKSPACE_PKG_NOT_FOUND |
子包引用了 workspace:* 但该包不在 workspace 内 |
检查 pnpm-workspace.yaml 的 packages |
lerna run 报 Command failed: yarn --version |
仓库里留着 yarn.lock,但实际用 npm 安装;Nx 据此判断包管理器为 yarn |
删掉多余的 lockfile,只保留与安装方式一致的那份 |
lerna list 少了一个包 |
该包是 private: true,默认不列出 |
lerna list --all |
lerna run 某个包被跳过 |
该包没有同名 script | lerna run build --stream 看输出,或用 --no-bail |
装完出现多层 node_modules |
子包声明的版本与根冲突 | 统一版本,或用 overrides/resolutions |
pnpm 装完提示 Run "pnpm approve-builds" |
pnpm 10 默认不执行依赖的构建脚本 | 执行 pnpm approve-builds,或配置 onlyBuiltDependencies |
十、FAQ
Q1:为什么根 node_modules 里能直接 import 子包的依赖?
因为提升。这也正是幽灵依赖的成因,靠"每个包只 import 自己声明的依赖"来规避;要求严格就用 pnpm。
Q2:-w 和 -W 到底差在哪?
npm:-w <name> 指定单个 workspace,-W 表示所有 workspace(实际效果是装到根)。yarn 1:-W 是 --ignore-workspace-root-check,只是"允许往根装"的确认开关。pnpm:-w 是 --workspace-root。
Q3:可以在子目录里 npm install 吗?
可以。npm 7+ 会识别 workspace 根,把依赖写进当前子包并提升到根。老版本 npm 可能生成嵌套 node_modules,那才是"不要在子目录装"的来源。
Q4:lerna bootstrap 为什么跑不了?
v7 起弃用、v9 删除。用包管理器的 install 代替;还需要老命令的话,只能在 v7/v8 里额外装 @lerna/legacy-package-management。
Q5:file:../project2 和 workspace:^ 有什么区别?
file: 是"文件系统依赖",语义接近复制;workspace: 是"同仓库包",pnpm 在发布时会自动替换成真实版本。npm/yarn 里等价写法是直接写版本号或 "*"。
Q6:monorepo 一定要用 Lerna 吗?
不需要。不发布 npm 包的项目,包管理器自带的 workspaces 已经覆盖 90% 需求;Lerna 的价值在版本与发布。
Q7:lerna init 之后 lerna.json 里没有 packages 字段?
v7+ 的行为:包列表从 package.json 的 workspaces 推导,lerna.json 只保留 $schema 和 version 等。