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"当成目的,它只是结果。

遇到的问题:

  1. 重复安装:project-a 和 project-b 都要 vue@3.4、vite、eslint,各自装一遍,磁盘翻倍、安装时间翻倍。
  2. 跨包调试靠"发版":改公共组件库 → 发 npm → 在业务项目升级版本 → 再调试。改一行要等一次发布会。
  3. 版本漂移:两个项目里 axios 一个 ^1.6 一个 ^1.7,线上行为不一致,排查半天。
  4. 规范重复: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 默认就在结构上强制这一点。

根 node_modules 下 @packages 里是各子包的符号链接


三、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 直接报错退出:

yarn 报错 Workspaces can only be enabled in private projects

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

yarn 官方文档对 private 要求的解释

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 只剩三件事:

  1. 版本管理(Fixed 统一版本 / Independent 独立版本)
  2. 发布(lerna publish,含 changelog、tag、registry 处理)
  3. 批量执行(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,包管理器足够。

八、常见坑

  1. private: true 忘了加:yarn 直接报错;npm 则允许,但你可能会误 publish 根包。
  2. 在根 package.json 装业务依赖:只有工具类(eslint、prettier、lerna、typescript)该放根;业务依赖放各自的包,否则所有包共享同一个版本,改动影响面不可控。
  3. lockfile 提交策略:一个仓库只提交一份 lock(package-lock.json / yarn.lock / pnpm-lock.yaml 三选一),并且与 CI 使用的包管理器一致。留着 yarn.lock 却用 npm install,会让 Lerna(底层 Nx)误判包管理器,报 Command failed: yarn --version。
  4. CI 用 npm ci:根目录执行即可,它会按 lock 安装所有 workspace;不要在子目录分别 npm ci。
  5. TS 路径解析:子包互相引用时,开发期用 workspace 链接,构建期可能需要 tsconfig 的 paths 或 references,否则 tsc 找不到类型。
  6. peerDependencies:pnpm 默认严格检查,缺 peer 会报 warning/error,用 public-hoist-pattern 或显式补依赖解决,别直接 shamefully-hoist=true 掩盖问题。
  7. 版本冲突:统一版本靠 overrides(npm)/ resolutions(yarn)/ pnpm.overrides;出现嵌套 node_modules 时先用 npm ls <pkg> / pnpm why <pkg> 定位。
  8. 发布前检查:子包里不该有 private: true(除非确实不发布),files 字段决定打包内容,publishConfig.registry 别指向镜像(示例工程里就有,注意改成官方源)。
  9. 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 等。

← 返回帖子 更多前端内容 →