Monorepo 这个词,但凡你所在的前端团队规模超过三个人、仓库里躺着两个以上会互相同步发版的子项目,大概率已经听过无数次了。但我发现一个很有意思的现象:很多人对 Monorepo 的讨论都停留在"把多个包放在一个仓库里"这个表面印象上,真正跑起来、尤其是到了"子包安装依赖"这个环节,各种诡异问题就全冒出来了。有人把依赖装到了根目录导致子包引用不到,有人被 workspace 协议搞到版本对不上,还有人干脆因为幽灵依赖查了一整天 bug 最后发现是提升策略在捣鬼。
这篇文章我想把 Monorepo 里"子包安装依赖"这件事的整体逻辑、实操路径和背后的机制一次说透。它适合正在使用或打算切换 Monorepo 架构的开发者,也适合那种"已经在用了但每次装依赖都心里没底"的人。我会按实际场景讲,不会只给结论——毕竟依赖安装这种事,不知道"为什么"的话,换个工具版本你就又不会了。
1. 为什么子包安装依赖是 Monorepo 里最容易翻车的环节
先聊个我自己的经历。之前带一个前端项目,仓库里有四个包:一个 UI 组件库、两个业务应用、一个公共工具集。刚开始用 npm workspaces 的时候其实挺顺的,根目录一条npm install全部搞定。但后来某一次 UI 组件库的维护者新增了一个第三方依赖,因为只在他的子包里跑了npm install xxx,结果根目录的 lock 文件被改得乱七八糟,安装完以后连业务应用的构建都崩了。那天晚上排查到凌晨,最后原因竟然是:他顺手把同一个依赖的不同版本同时装进了根 node_modules 和子包 node_modules,而业务应用引用到的那个被 hoist 上去的版本根本不兼容。
这个场景在 Monorepo 里太典型了。子包安装依赖之所以容易翻车,核心原因有三层:
第一层是"依赖应该装在哪"的边界模糊。普通单仓库项目只有一个 package.json,依赖装哪不会产生歧义。但 Monorepo 里有根依赖和子包依赖两套体系,每个子包又是独立模块,它既可以声明适合自己的依赖,也可以依赖 workspace 里其他兄弟包。这些依赖放在不同层级会直接影响解析路径,放错了地方,代码能跑纯粹是运气。
第二层是提升(hoisting)机制带来的隐藏风险。经典 npm 和 yarn 会把所有子包的依赖尽量提升到根 node_modules 以节省磁盘空间和避免重复安装。这个设计本身很聪明,副作用是:子包代码里明明没声明过某个依赖,但因为提升,它也能require('/import')到。开发时一切正常,发布到线上或者换一台新机器重新安装后,依赖树一变化就变成 module not found。这就是著名的"幽灵依赖"问题。
第三层是 workspace 协议的语义容易被误解。在 pnpm、yarn berry、npm 这些支持 workspace 的包管理器里,子包引用兄弟包时通常用workspace:前缀的版本号,比如"@my-lib/utils": "workspace:^"。但这个协议在发布时怎么降级成正式版本号、在安装时怎么建立链接关系,各个包管理器行为不完全一样。如果根本不知道这个机制,直接在这里写死1.0.0,发布后就等着手动同步版本吧。
所以你会发现,"子包安装依赖"不是一行npm install那么简单,它实际上牵扯到包管理器的提升策略、workspace 链接机制、lock 文件维护、安装顺序和构建缓存等一系列问题。下面我按"理解机制"到"实际动手"的顺序,一个一个拆开讲。
2. 先把 workspace 和依赖提升机制搞清楚,再谈安装
如果你不想做那个"每次装依赖都怕装坏"的人,就必须先理解两件底层的事:一是 workspace 到底靠什么把子包关联起来,二是你用的包管理器在物理结构上怎么摆放 node_modules。这两件事决定了所有命令行为上的差异。
2.1 workspace 建立的不是"软链接"这么简单
很多人以为 Monorepo 里 A 子包依赖 B 子包,安装后就是 node_modules 里放了个软链接指向 B 的源码目录。这个说法只对了一半。
以 pnpm 为例,它处理 workspace 内部依赖的方式是:仍然在全局的 content-addressable 存储里保存 B 包的文件内容,然后通过硬链接放进 A 包自己的 node_modules 里 B 的位置,也就是说 A 引用 B 时,B 实际上是被当成一个"已经发布的包"来对待的。这保证了 B 的代码一旦被打包,对外暴露的入口和 B 自己 package.json 里的 main/module/exports 字段完全一致——这对实现"子包隔离"和"单一依赖来源"至关重要。
npm 和 yarn classic 的方式则是符号链接(symlink)。它们通常把 B 包通过 symlink 放进 A 的 node_modules 里,指向 B 在仓库里的实际目录。这种方式更直接,但它会带来"B 的 node_modules 和 A 的 node_modules 是两套目录"的问题。如果 B 依赖了 C,而 A 也依赖了 C,你怎么保证 A 引用到的 C 和 B 引用到的 C 是同一份?这就要看提升策略了。
理解这一点的价值在于:当你在子包里执行npm install some-package的时候,你以为只是给这个子包添加依赖,但实际上包管理器需要同时更新 workspace 里相关的链接/硬链接结构、可能影响根 lock 文件、还可能把新依赖提升到根 node_modules 供其他包共用。任何一步没跟上升级,就会出现"装了这个包,别的包反而坏了"的情况。
2.2 三种主流包管理器的 node_modules 排列逻辑
这里我直接上对比表,按照"传统 npm/yarn classic"和"pnpm/yarn berry"划分成两个阵营来理解。
| 维度 | npm / yarn classic | pnpm / yarn berry |
|---|---|---|
| node_modules 结构 | 扁平化(尽量提升到根目录) | 严格嵌套(每个包都有一份自己的依赖) |
| 幽灵依赖 | 容易产生 | 基本杜绝 |
| 磁盘占用 | 存在重复时较高 | 全局 store + 硬链接,很省 |
| 子包之间依赖 | symlink 指向仓库源码 | 硬链接到全局 store,再通过隔离结构链接 |
| 安装速度 | 依赖量大时会慢 | 增量安装快,冷安装因硬链接也很有优势 |
| 主要风险 | 版本冲突、幻影依赖、lock 文件难以细控 | 方案复杂、工具有学习成本、旧工具链可能不兼容 |
从这个表能看出一件事:pnpm 的设计哲学是"严格",它牺牲了一部分对传统 node 解析规则的天真信任,换来的是"你在 package.json 里写了什么就能用什么"的确定性。npm 的设计哲学是"兼容",它尽量让依赖解析和 vanilla Node.js 时代保持一致,副作用就是各种隐性问题都得靠开发者自觉。
这不是说哪一个绝对好。如果你的项目里还有一些老旧的 build 工具链、Native 模块或者不遵循约定的依赖包,pnpm 的严格隔离反而会增加适配成本。反之,如果团队纪律好、工具链相对现代,pnpm 带来的确定性收益非常明显。
2.3 从"为什么要在根目录安装"到"哪些依赖必须归子包"
我经常被问到的一个问题是:Monorepo 里,到底哪些依赖应该装在根目录,哪些应该装进子包?
这个问题没有一刀切的答案,但有一个非常实用的判断标准:如果这个依赖是被多个子包共同使用的、而且版本策略一致的,优先考虑提升到根目录;如果这个依赖是某个子包特有的,或者不同子包需要不同版本,就必须声明在子包自己的 package.json 里。
举个例子:typescript和eslint这种开发工具链,几乎每个子包都需要,而且它们的最好是保持统一版本,这类就放根目录。而 UI 组件库用到的react版本可能和业务应用依赖的react版本不一致,这种就得各自声明在自己的 package.json 里。
在 pnpm 里,你还可以用pnpm.overrides或pnpm.peerDependencyRules这种配置来精细控制依赖解析,但那是进阶话题。对大多数人来说,严格遵守"子包自己能决定的事情不要上升到根目录"这一条,就能避免大量摩擦。
3. 子包安装依赖的三种典型实操场景与正确姿势
理论讲完了,下面进入实际操作。我会按三种最常见的场景来拆解:在根目录统一安装、在某个子包里单独安装、以及发布前对 workspace 版本号的最终处理。每种场景我都会给出命令、原理和背后为什么这样做。
3.1 场景一:根目录统一安装所有子包依赖
这是 Monorepo 最常用的安装方式。一个仓库里多个子包,所有依赖通过一条命令安装到位。
以 npm 来说,在根目录执行:
npm installnpm 会读取根目录和所有子包的 package.json,基于 workspaces 配置(通常是"workspaces": ["packages/*"])解析整个 workspace 图,然后统一安装、统一生成 lock 文件。
以 pnpm 为例,在根目录执行:
pnpm installpnpm 会按自己的规则解析整个 workspace,生成pnpm-lock.yaml,所有子包共享同一个 lock 文件。这也是 Monorepo 最核心的价值之一:lock 文件的统一化让依赖版本具备了全局一致性。
这里有一个非常容易踩的坑:有些开发者习惯在子包目录里直接执行npm install来"只装当前子包的依赖"。这个行为在 npm 里会怎样?它会读取当前目录的 package.json,把它当成一个独立的项目来安装——你不会报错,但它会生成一个子包级别的package-lock.json,并且很可能因为 npm 的解析逻辑把部分依赖提升到子包自己的 node_modules 里,最终导致根目录 lock 和子包 lock 重叠冲突。一旦这样操作过,再回到根目录执行统一的 install 时,你会发现两个 lock 文件内容不一致,版本错乱、依赖重复,各种诡异问题都来了。
所以第一条铁律就是:在 Monorepo 里,日常开发的安装动作永远发生在根目录,而不是子包目录。要装一个新的依赖,也是在根目录指定 workspace 参数来装。
3.2 场景二:给指定子包单独安装新依赖
这就是"在根目录操作子包"的标准姿势。
npm 的命令:
npm install <package> -w packages/my-pkgpnpm 对应的是:
pnpm add <package> --filter my-pkgyarn classic 是:
yarn workspace my-pkg add <package>这条命令做的事情比看起来多得多:它不仅会修改packages/my-pkg/package.json里的 dependencies 字段,还会同步更新根目录的 lock 文件,并且根据提升策略决定这个包是放进根 node_modules 还是放进子包自己的 node_modules。换句话说,从依赖声明到锁定到安装位置,一步到位。
有人会问:我能不能直接在子包的 package.json 里手动加上依赖,然后再回到根目录执行 install?当然可以,效果上差不多。但对于使用 pnpm 的用户来说,pnpm add --filter的优势在于它会自动处理 workspace 协议的格式、自动更新 lock 文件,而且不会因为手动改 package.json 时粗心遗漏某个字段而出问题。
3.3 场景三:发布前如何处理 workspace: 版本号
这是 Monorepo 最容易忽略的环节。workspace:协议是给本地开发用的,如果你发布包的时候不处理,npm registry 根本不知道workspace:^1.0.0是什么意思,消费者装不到正确的依赖。
不同包管理器的处理方式不太一样:
- pnpm:
pnpm publish时会把 dependencies 里的workspace:前缀自动替换为实际版本号(默认行为)。如果你不希望它替换,可以用publishConfig.directory或者pnpm.workspace的相关配置来个性化处理。 - yarn berry:在
release工作流中自动处理,通常配合yarn version和changesets工具。 - npm workspaces:没有内置的自动替换逻辑,需要你自己在发布脚本里处理,或者借助
changesets、lerna、release-it这类工具。
这里我建议:如果你用的是 npm workspaces 做 Monorepo,强烈建议直接引入changesets作为版本管理和发布工具。它不仅能统一处理 workspace 版本号替换,还能自动生成 changelog、按依赖拓扑顺序发布多个包,省掉大量手工操作。
4. 子包之间依赖引用的两种方式与各自的边界条件
Monorepo 里"子包安装依赖"还有一个绕不开的话题:A 子包依赖 B 子包,这个"依赖"在代码里怎么引用、在 package.json 里怎么写、在构建时怎么解析。
4.1 相对路径引用:开发便利,生产踩雷
最朴素的方式是在源码里直接import相对路径,比如:
import { helper } from '../../packages/utils/src/index'这种方式在开发时最快,不需要任何 workspace 配置。它的问题也很明显:A 包直接引用了 B 包的源码,而不是 B 包通过 package.json 暴露出来的入口。如果 B 包对外暴露的入口做了构建处理(比如 TS 编译、CSS 提取),相对路径引用会完全跳过这些处理,直接把 TS 源码或者未经构建的文件带进 A 包,轻则构建速度下降,重则运行时直接报错。
而且,发布的时候基本都得改代码引用,不然消费者拿到的是"另一个包"的源码目录,完全不可用。
所以在相对路径引用这个方案上,我的态度很明确:只能用于本地快速联调,尽量不要进正式代码。
4.2 package.json 依赖 + workspace 链接:标准姿势
正确的方式是 A 包的 package.json 里声明:
{ "dependencies": { "@my-lib/utils": "workspace:^1.0.0" } }然后在 A 的源码里正常 import:
import { helper } from '@my-lib/utils'包管理器会在安装阶段把@my-lib/utils解析到 B 包的源码位置(pnpm 是硬链接 + symlink,npm/yarn 是 symlink),并让其遵循 B 包 package.json 里定义的入口文件。这样 B 包的构建逻辑会被正常触发,一切处理都符合"它是一个独立包"的预期。
这里有一个细节:使用 workspace 协议时,A 包如果引用了 B 包构建后产出的文件(比如lib/index.js),那么安装后 A 访问到的是 B 的构建产物还是源码?这取决于 B 的 package.json 主入口配置指向哪里。这也是为什么 Monorepo 里包的入口配置需要非常小心,因为它不仅影响发布后的消费者,也影响 workspace 内部引用的实际加载路径。
4.3 循环依赖:Monorepo 里的大坑
子包之间的循环依赖(A 依赖 B,B 又反向依赖 A)在 Monorepo 里是比单仓库更危险的存在。因为 workspace 链接是"真实链接",不会像 npm registry 安装那样做一个快照,循环依赖会直接导致构建阶段的无限递归或者模块初始化顺序异常。
最典型的报错是"ReferenceError: Cannot access X before initialization",这种往往是循环依赖在编译后加上了 TDZ(暂时性死区)导致的问题。要避免它,只能在架构层面做调整:把公共部分抽成第三个包,或者用依赖注入延迟解耦。单仓库里你还可以用模块懒加载来绕,子包之间就不建议用这种技巧硬扛了。
5. 实操中常踩的坑:从报错信息反推原因
积累这些坑的过程都是血泪,我把它们按"现象 → 根因 → 解法"的方式列出来,方便你遇到类似情况时快速定位。
5.1 报错信息指向"module not found",但明明装了依赖
这是幽灵依赖的典型症状。你检查子包 node_modules,依赖确实在那,但代码运行就是报 not found。大概率是依赖提升策略变化导致解析不到根 node_modules 里的包。
我之前一个项目用 npm,某天升级了某个共享依赖版本,另一个子包里require('semver')直接炸了。原因是 semver 并没有在该子包的 package.json 里声明,之前一直靠 npm 把它提升到根 node_modules 里"白嫖",升级后提升策略变了就不再可用。
解决办法:挨个检查所有 import 到的包是否都在当前子包的 package.json 里声明了。如果依赖确实被多个子包共享,统一提升到根目录并正式声明;如果只有某个子包用,就加到那个子包的 dependencies 里。根治方法是切换到 pnpm,它的隔离设计从根部消灭了幽灵依赖。
5.2 子包执行 install 后把根目录 lock 文件搞乱
这个前面提过,非常常见。子包目录下执行npm install或pnpm install后,系统会把它当成独立项目处理,生成一个子包独立的 lock 文件。这个 lock 文件和根目录 lock 文件的解析逻辑不一致,下次回到根目录 install 会重新解析,两个 lock 冲突就产生了。
正确做法是:单包安装依赖永远使用根目录命令配合 workspace/filter 参数。如果你已经踩了坑,解决办法是删除所有子包目录下的 lock 文件和 node_modules,回到根目录重新统一安装。
5.3 pnpm 安装子包依赖时遇到 peer dependency 冲突
pnpm 的严格模式对 peerDependencies 的处理非常刚性。比如你的 UI 组件库声明了peerDependencies: { react: "^18.0.0" },而某个子包依赖了react@17,pnpm 就会直接报错拒绝安装,即便实际上这个子包可能根本不会用到 UI 组件库里的 React 组件。
遇到这种情况,先用pnpm ls确认依赖关系,然后考虑两种解法:一是在子包 package.json 里把 react 的版本提升到兼容范围;二是在根目录的pnpm配置里使用peerDependencyRules.allowedVersions显式忽略某些 peer 冲突。我不会建议一上来就用忽略规则,因为那是绕过问题而不是解决问题,只有当确认这个 peer 冲突确实是误报时再这么干。
5.4 node_modules 里符号链接失效或指错位置
用了 pnpm 之后,偶尔会遇到 node_modules 里某个依赖是"断掉的链接"——你点进去发现它指向的目录不存在。这种情况通常发生在子包被移动、删除或 git 分支切换后。pnpm 的状态记录和实际文件系统不同步了。
解决办法很直接:执行pnpm install会进行一次全量状态校验,或者pnpm prune把孤儿链接清掉再 install。对 npm 来说,那就是npm install加npm dedupe了。
5.5 workspace 版本号发布后没有正确替换
如果你用 npm workspaces 但没有引入 changesets,发布时很可能遇到"消费者安装时报 workspace 版本无法解析"的问题。因为 workspace 协议是 npm 的私有扩展,registry 不认这个格式。
解法有三个方向:一是引入 changesets 自动处理版本替换;二是在发布脚本里做字符串替换(搜索所有"workspace:开头的版本号并替换为实际版本);三是在包发布前执行npm pack检查最终的 package.json 内容是否合规。我强烈推荐第一种,手工替换太容易漏了。
6. 一个完整可参考的 pnpm monorepo 依赖安装流程
如果你从零搭建一个 pnpm 的 monorepo,下面这套流程是经过多轮实践踩坑后沉淀下来的,可以直接照着做。
6.1 初始化与配置
# 安装 pnpm(建议用 corepack 或独立安装) corepack enable pnpm --version # 创建仓库目录 mkdir my-monorepo && cd my-monorepo # 初始化根 package.json pnpm init根目录的pnpm-workspace.yaml:
packages: - "apps/*" - "packages/*"根目录 package.json 里可以加:
{ "private": true, "scripts": { "build": "pnpm -r build", "test": "pnpm -r test" } }其中pnpm -r build表示在所有子包中递归执行 build 脚本,并且 pnpm 会按照依赖拓扑顺序来执行——被依赖的包先构建。
6.2 创建子包并互相引用
比如创建packages/utils和apps/web两个包。apps/web依赖packages/utils,在 apps/web 目录下执行:
pnpm add @my-monorepo/utils --workspace这个--workspace参数是告诉 pnpm:优先从 workspace 内部匹配这个版本,而不是去 registry 拉取。它会在 dependencies 里生成"@my-monorepo/utils": "workspace:^"这样的声明。
这里要注意版本号的写法。workspace:^表示"引用当前 workspace 里该包的最新版本,并且允许在 ^ 语义范围内做版本升级"。如果你希望精确锁定,可以写workspace:*或者具体版本号。在实际发布时 pnpm 会根据包的当前实际版本自动替换这个前缀,所以本地开发时你不需要手改。
6.3 日常安装依赖的操作习惯
- 要给所有子包统一装一个开发依赖:
pnpm add -D typescript -w(-w指只装在根目录) - 要给某个子包装运行时依赖:
pnpm add lodash --filter @my-monorepo/utils - 要清理所有子包 node_modules:
pnpm -r exec rm -rf node_modules(谨慎使用,建议用pnpm store prune配合) - 要查看整个 workspace 的依赖树:
pnpm ls -r
这里特别提一句--filter的灵活性:你可以用--filter "@my-monorepo/*"匹配一组子包,也可以用--filter "my-app..."表示"该包及其依赖的所有包"。这套语法熟悉之后,批量安装、批量构建效率提升很大。
6.4 构建与发布
构建通常按依赖拓扑顺序来,pnpm 的-r默认就带拓扑排序:
pnpm -r build发布时建议配合 changesets:
pnpm add -D @changesets/cli -w pnpm changeset init然后正常走 changeset 的版本管理和发布流程,它会自动处理 workspace 协议替换和按顺序发布。
7. 从工具链角度谈 Monorepo 依赖安装的选型建议
前面很多地方都在拿 npm 和 pnpm 对比,最后我再从实际选型的角度给一些偏经验式的建议。因为"子包安装依赖"这件事,最终的体验优劣完全取决于你选的包管理器和你团队的使用习惯是否契合。
7.1 什么时候选择 npm workspaces
npm workspaces 最大的优势是零学习成本和生态兼容性。很多 CI 镜像自带 npm,不需要额外安装 pnpm/yarn。如果你的团队规模不大、仓库结构简单、子包数量有限,npm workspaces 完全够用。
它的短板在于幽灵依赖和 lock 文件的细粒度控制不如 pnpm。如果你团队里没有"所有依赖都必须显式声明"这种纪律约束,长远来看会积累技术债。还有一个实际痛点:npm 的安装速度在大仓库场景下明显慢于 pnpm,这是磁盘 IO 和解析算法共同决定的。
7.2 什么时候选择 pnpm
团队规模超过 10 人、仓库里有超过 3 个活跃子包、或者你已经吃过幽灵依赖的亏——这三个条件满足任意一个,我都推荐切换到 pnpm。
pnpm 的代价是:它严格隔离的 node_modules 结构可能在极少数老旧工具上出问题(比如某些构建工具会强行扫描node_modules/.pnpm下的目录结构,结果以自己的假设方式遍历导致失败)。如果遇到这类问题,通常可以在.npmrc里配置:
node-linker=hoisted把 pnpm 的严格隔离模式切换成传统的提升模式来兼容。注意,这是"兼容模式",会损失一部分 pnpm 的隔离优势,必要时才用。
7.3 yarn berry 值得关注吗
yarn berry 的 Plug'n'Play(PnP)模式在依赖安装和加载效率上确实很有想象力,但 PnP 模式下 Node.js 需要额外的 loader 来解析依赖,这对某些生态工具(特别是原生模块、非 JS 的二进制依赖)兼容性是一个挑战。如果你的项目里全是纯 JS/TS,yarn berry 值得尝试;如果涉及 native 模块或者经常跟 electron、node-gyp 打交道,我会持保留态度。
7.4 一个快速决策参考表
| 团队情况 | 推荐 | 原因 |
|---|---|---|
| 小团队、2-3 个子包、以 JS/TS 为主 | npm workspaces | 零学习成本,够用 |
| 中大型团队、子包多、共享代码频繁 | pnpm | 确定性高、安装快、隔离好 |
| 对安装速度极敏感、纯 JS 生态 | yarn berry / pnpm | 两者都很快,看团队熟悉度 |
| 有大量 native 模块依赖 | npm / pnpm 的 hoisted 模式 | 兼容性优先 |
8. 我在跨包依赖版本管理上的几点心得
最后这一节不完全算技术教程,是我在实际项目中积累的一些"做事方式"。因为依赖管理这件事,工具只占一半,另一半是人和流程。
第一点:统一入口,禁止子包目录直装。这个纪律我在团队里反复强调:任何子包目录下直接执行install或add都是违规操作。所有安装动作必须从根目录发起,配合--workspace或--filter指定目标。原因前面已经说过,子包直装会破坏 lock 文件统一性和依赖提升策略。团队里如果有人违反,我建议通过 CI 脚本检查子包目录下是否存在独立的 lock 文件来防止——查得到就直接让流水线失败,用制度约束比靠自觉可靠得多。
第二点:lock 文件必须进版本库,而且必须定期检查变更内容。Monorepo 的 lock 文件是全局唯一事实来源,任何一次依赖变更都会反映在上面。每次 lock 文件变更,我都要求 team member 在 PR 描述里说明变更动机,review 的人要重点核对"为什么这个依赖的传递依赖变了这么多"。如果看不懂变更,宁可先不让它合并,也别带着疑问合进去。
第三点:依赖分组要分层。根目录只放开发工具链、构建相关和全局共享的依赖;子包只放自己运行期真正需要的依赖;peerDependencies 只保留真正的"对等依赖",能不用就不用。这个分层原则听起来简单,但在跨包重构时很容易被破坏,需要持续维护。
第四点:定期做依赖体检。我会定期跑pnpm outdated和pnpm audit,但真正有价值的是pnpm ls -r --depth Infinity,它能展示整个 workspace 的完整依赖树。每次都有人问这个东西看过一遍不就行了吗?不是的,依赖树在团队协作下会不断变形,隔一段时间看一眼能发现很多"当时觉得没什么,现在变成坑"的隐患,比如某个包装在了根目录但只有一个子包在用,完全可以下沉到子包去。
第五点:版本策略要跟发布节奏绑定。如果你的子包需要独立发版,建议用 changesets 管理;如果子包需要同步发版,考虑用固定版本模式(fixed mode)。不要混着来,混着来必然导致某次发布时版本号对不上、依赖关系剪不断。这个我见过太多次了,一个仓库里既有独立版本又有同步版本,"发个版"这件事最后变成了手动协调的噩梦。
我在实际维护 monorepo 项目的这两年多里,最深的一个体会是:子包安装依赖之所以频繁出问题,很少是包管理器本身不够强大,更多是使用者还没把"依赖的归属权"想清楚。任何一个依赖,要么属于根,要么属于某个子包,要么属于某个子包的 peer 范畴——想清楚了再动手,大多数依赖相关的坑都能绕过去。希望这篇文章能把 Monorepo 子包依赖安装这件事从"变魔术"变成"按流程操作",那我的目的就达到了。