
1. 先从 Monorepo 依赖管理的困扰说起搞 Monorepo 这些年我经常被同事问到一个看起来很基础的问题“我的子包到底该怎么装依赖”很多人一开始都会觉得子包不就是目录里的小项目吗直接cd packages/xxx npm install不就行了真这么干了很快就会碰到版本冲突、依赖重复、node_modules 大爆炸、子包之间互相引用却找不到模块这些诡异现象。这背后的原因不复杂Monorepo 并不是“几个独立小项目硬拼在一起”它是一个整体工作区workspace依赖管理必须放在整个仓库的维度去处理。只盯着某个子包单独安装依赖本质上是把一个整体系统拆成碎片去修问题只会越修越多。这篇文章我会用实际工作里最常遇到的操作场景把子包安装依赖的原理、命令、坑和排查思路一次性讲清楚。内容以目前社区使用率最高的 pnpm、npm、yarn workspace 为主适合正在搭 Monorepo 或者已经被依赖问题折磨过的人。1.1 为什么子包依赖会成为问题先说个我自己的经历。早年在公司内部推行 Monorepo 时有个后端的同事把项目从原来的多仓库合并到单仓库然后每个子包都保留了各自的node_modules和package-lock.json。结果每次跑构建耗时直接从 5 分钟涨到 20 分钟各种“模块版本对不上”的报错轮番轰炸。这类问题看起来很随机但根子是同一个多个子包共用一套源码树却按“每个包独立安装依赖”的逻辑去执行导致依赖树完全失控。在 Monorepo 里子包之间的关系通常有三种A 包依赖外部第三方库比如 lodashB 包依赖仓库内的另一个子包比如packages/utilsC 包既依赖第三方库也依赖仓库内的多个子包。如果不使用工作区统一管理第二种关系根本没办法优雅地处理。你只能在 package.json 里写file:../utils这种本地路径或者干脆用 npm link 手动软链一旦子包变多维护成本直接起飞。所以正确的做法是把整仓库当作一个依赖解析单元让包管理器统一处理所有子包的依赖关系。这也是后面所有操作的大前提。1.2 三种主流包管理器的 Monorepo 支持差异npm、yarn、pnpm 现在都支持 workspace但实现方式区别很大。我用一个表格把核心差异列出来特性npm workspaceyarn (berry) workspacepnpm workspace依赖安装命令npm install -wyarn workspacepnpm add --filter是否支持 workspace 协议不支持workspace:*支持支持默认依赖提升策略提升到根 node_modules提升到根 node_modules默认不提升使用符号链接幽灵依赖风险高高低磁盘占用高多次安装可能重复下载同 npm但 berry 有改进低全局 store 复用锁文件单一 package-lock.json单一 yarn.lock单一 pnpm-lock.yaml看到这个表你就明白了pnpm 在设计上就是冲着解决依赖隔离和重复安装去的。npm 和 yarn 都能跑 Monorepo但如果你要求严格、团队成员多我基本都会建议用 pnpm。下面所有实操我会以 pnpm 为主npm 和 yarn 的对应写法也会给出来。2. 子包安装依赖的核心机制说实话命令只是表面你需要先搞懂子包依赖在 Monorepo 里到底是怎么被解析的。理解了这套机制以后遇到任何奇怪的报错都能自己推出来问题出在哪。2.1 workspace 协议省心的本地链接workspace:是 pnpm 和 yarn 提供的一种依赖协议它专门用来声明“这个依赖来自本仓库的某个子包”。在包的 package.json 里看起来是这样的{ name: my/app, dependencies: { my/utils: workspace:* } }这里的*表示直接使用子包当前版本不需要关注子包版本号具体怎么变化。它解决的就是“子包之间互相引用”的问题。当你执行pnpm install包管理器会在node_modules里建立一个指向源码目录的符号链接而不是把子包文件复制一份进去。这样你在packages/utils里改代码packages/app里立刻就能引用到修改后的内容不需要每次都重新安装。我给你的建议是仓库内部依赖一律用workspace:*不要用workspace:^1.0.0。因为你自己的子包更新节奏和价值取向跟第三方库完全不同用*才能在本地开发时永远指向最新源码避免改完代码还要去手动对齐版本号。到发布的时候包管理器会自动把workspace:*替换成真实的版本号不会把你仓库内部协议泄漏到 npm 上。2.2 依赖提升与幽灵依赖依赖提升hoisting是 Monorepo 依赖机制里最容易被误解的概念。npm 3 以后和 yarn classic 默认把所有子包的依赖尽量提升到根目录的 node_modules 下。好处是避免同一个依赖被安装很多次坏处是产生了“幽灵依赖”。我举个例子。packages/app里没声明 lodash但因为packages/admin声明了 lodashnpm 把 lodash 提升到了根 node_modules于是packages/app的代码里写import _ from lodash也能正常跑。表面上看起来没问题实际非常危险一旦packages/admin某天升级后不再需要 lodashnpm 提升策略发生变化packages/app就会毫无预兆地报“找不到模块”。pnpm 的做法完全不同。它默认不提升第三方依赖而是在每个包的 node_modules 下只放该包显式声明的依赖然后通过硬链接和符号链接把包连接到全局 store。这样幽灵依赖问题基本被根除但也会让第一次接触的人疑惑“我看着 node_modules 里没有 xxx怎么代码能跑起来”这就是 pnpm 的机制在起作用不是 bug是特性。2.3 Lock 文件一个仓库只应该有一把锁很多从多仓库迁移到 Monorepo 的团队最大的习惯误区就是把原先的 lock 文件也原样带进来每个子包锁自己的依赖版本。这在 Monorepo 里是绝对的禁忌。原因很简单子包之间存在依赖关系如果app用的utils版本是 1.2.0但utils自己的依赖版本又和另一个子包冲突整个仓库就无法保持一致。单一 lock 文件可以保证任何人、任何机器pnpm install之后得到的依赖树严格一致。CI 上跑pnpm install --frozen-lockfile开发机上也跑同样的命令谁也不会出现“本地能跑服务器上却挂了”的玄学问题。所以你只要用了 workspace就应该把 lock 文件提升到仓库根目录并且提交到版本控制里。遇到 lock 冲突用包管理器自带的 merge 功能去处理不要手动删除重新生成否则很容易引入一次无谓的全量依赖升级。2.4 本地依赖和远程依赖的解析顺序本地依赖和远程依赖的解析顺序也很关键。当你执行pnpm add lodash --filter my/app包管理器先看的是仓库内部的 workspace 包再看 registry 上的远程包。对于内部依赖它直接走workspace:协议对于外部依赖它根据版本范围去 registry 找找到以后写入 lock 文件。如果你安装的是“本地已经下载好的依赖”比如一个.tgz文件、一个放在vendor/目录里的源码包或者一个手动 checkout 的第三方库情况就稍微不同。你可以用file:协议明确指定路径{ dependencies: { comfyui-local-lib: file:./vendor/comfyui-lib } }使用file:协议的包不会进入远程 registry 解析流程包管理器直接按本地目录做链接和处理安装速度通常也更快。但这里有个隐患把绝对路径写进去没用必须用相对路径而且一旦这个包发布到 npmfile:协议下的依赖很可能让其他安装者拿不到内容。所以这种写法更适合内部工具链不要轻易发布到公共源。3. 实操在 workspace 里正确安装子包依赖下面进入真正能照抄的部分。我会按 pnpm 和 npm/yarn 两条线分别给出标准命令再补充本地依赖安装和缺依赖排查的方法。3.1 pnpm 下的标准姿势add --filterpnpm 提供了一个很实用的--filter参数简写是-F用来精确指定往哪个子包安装依赖。命令格式是pnpm add hexo --filter my/blog这条命令的意思是在仓库根目录执行但只修改my/blog这个子包的 package.json把 hexo 添加为它的 runtime 依赖。它不会给其他子包装上 hexo也不会像在子包目录里直接pnpm add那样只安装局部依赖而破坏整体依赖树。如果想装开发依赖加上-D如果想装到根目录比如给所有子包共享的构建工具直接不加--filterpnpm add -D typescript pnpm add vite --filter my/app pnpm add -D eslint --filter my/app --filter my/admin注意--filter可以写多次也可以支持通配符pnpm add lodash --filter my/*这条命令会给所有myscope 下的子包添加 lodash。听起来很方便但我个人不推荐轻易使用因为如果将来子包增多你未必希望所有子包都依赖同一个第三方库。装依赖这种事还是越明确越好。安装内部子包依赖时我推荐在子包 package.json 里手动写my/utils: workspace:*然后执行pnpm install。直接写协议能避免一些包管理器版本差异带来的解析问题也更方便 code review 的人一眼看出“这是仓库内依赖”。3.2 npm / yarn workspace 里的安装命令如果你还在用 npm 7对应的命令是npm install lodash --workspace my/app简写形式是-wnpm install -D typescript -w my/appnpm 的 workspace 解析依赖的底层逻辑仍然是 hoisting所以它在大型 Monorepo 里的性能和组织能力都不如 pnpm。但当团队已经标准化使用 npm 时也可以正常工作。只是要记住npm 并不支持workspace:*协议如果你在 package.json 里写了my/utils: workspace:*npm 会把它当成一个普通版本范围然后去 registry 里找名为workspace:*的版本自然是找不到的。很多人遇到的 “workspace:* 无法解析” 报错就是拿 pnpm 式的写法套到了 npm 上。yarn classic 和 yarn berry 的写法不同。yarn classic 支持yarn workspace my/app add lodashyarn berry 也兼容这个命令但在 berry 里我更推荐从仓库根目录统一管理依赖靠yarn constraints做依赖规则约束。3.3 安装本地下载好的依赖包离线包与 file: 协议有些场景下你不能从公共 registry 下载依赖比如在内网环境或者第三方库已经被下载到本地你想直接安装。这时有两个常见做法。第一个做法是直接用相对路径引用适合源码包目录。在子包 package.json 里写{ dependencies: { local/comfyui: file:../../vendor/comfyui } }然后执行pnpm install --filter my/app第二个做法是引用打包好的 .tgz 文件。这种适合你已经从源站下载了发布包的情况{ dependencies: { company/analytics: file:./vendor/analytics-1.2.3.tgz } }执行安装后包管理器会把这个本地包解压到合适的 node_modules 位置并写进锁文件。需要特别提醒的是这种file:依赖在团队协作时很容易出问题成员的代码路径不同、vendor 目录没有被拉取、文件忘记提交等等。所以我通常在团队里约定file:依赖要么不进仓库要么必须保证版本管理里包含这个本地文件。还有一种“伪离线”做法是修改包管理器的安装源指向本地镜像比如把 registry 指向公司内部部署的私有 registry。这样pnpm install的执行逻辑完全不变但下载源变了网速和稳定性反而更好。对于大规模 Monorepo这也是更推荐的方案因为 lock 文件里的版本信息不会被破坏。3.4 遇到“缺少依赖项”该按什么顺序排查很多人习惯一看到“缺少依赖项”就去搜解决方案。网上搜出来一堆驱动、环境变量、系统组件相关的内容比如什么realtek-realtekh、ComfyUI 安装本地下载好的依赖、python 在哪个文件配置依赖。这些搜索结果不一定错但十有八九和你的 Monorepo 问题不在一个层面。Monorepo 里真正的“缺依赖”报错绝大多数属于模块解析失败而不是操作系统层面缺东西。所以排查顺序非常重要。第一步先看报错信息里的模块名。比如Cannot find module vue那你就去对应的子包 package.json 里查vue 是不是明确写在 dependencies 里。如果没有写那这就不是“安装失败”是“你本来就不该用它”需要补声明。第二步检查这个依赖是不是在仓库别的地方存在只是没提升到当前子包能访问的位置。在 npm 下可以通过根目录npm ls vue查看视图在 pnpm 下可以用pnpm list vue --filter my/app第三步如果模块存在但运行时报错就要考虑是不是有两个不同版本。比如应用里引用的 vue 和底层某个组件库引用的 vue 不是同一个实例。这个问题后面我单独讲。遇到这样的“缺少依赖项”先不要急着乱装一通冷静定位到具体模块再决定是补声明、调版本、还是改包管理器配置。4. 常见问题与排查技巧实录这一部分我把这些年实际踩过的坑以及社区里高频出现的问题汇总一下。每一条都能对应到具体场景建议收藏当速查表用。4.1 Cannot find module十有八九是幽灵依赖报错长这样Error: Cannot find module lodash Require stack: - /workspace/packages/app/dist/index.js很多人第一反应是重新安装但重新安装十次也没用。这通常是因为代码里用了没有在 package.json 里声明的依赖之前 npm hoisting 把它恰好提升到了根 node_modules所以能跑后来依赖树稍微一变它就从你的模块解析路径里消失了。解决方式很明确在所有子包里面禁止“用了再声明”的反模式只要代码里有 import 或 require就必须把它写进当前子包的 package.json。为了从流程上拦住我建议加 eslint 插件比如eslint-plugin-import的import/no-extraneous-dependencies规则。它能自动检查当前文件在哪个包目录如果 import 的模块不在这个包的 dependencies 里直接报错。如果是在产生了幽灵依赖之后才排查最简单的处理就是给对应的子包补上依赖声明然后重新 install。4.2 workspace:* 协议在不同包管理器间的陷阱workspace:*报错最常见的场景就是团队里有人用 pnpm 写好子包依赖后又用 npm 跑了一次安装。npm 不认workspace:*于是从远端拉依赖时找不到直接报错npm ERR! Could not resolve dependency: npm ERR! my/utilsworkspace:* npm ERR! Cannot resolve to a matching version处理办法有两个。要么统一包管理器你既然用了workspace:*仓库里就应该用 pnpm 或 yarn要么如果你必须用 npm就把依赖声明改成 npm 能理解的方式。npm 的 workspace 对内部依赖的处理方式是自动符号链接不需要显式写协议可以直接写my/utils: *或者具体版本范围。但这样又可能因为版本范围不匹配导致意外可维护性不如workspace:*。这个坑提醒我们一件事Monorepo 是一个团队协作项目包管理器的选择必须写进项目文档最好再通过 CI 校验。可以在根目录 package.json 里加packageManager字段{ packageManager: pnpm9.0.0 }再配合 corepack团队成员执行包管理命令时会被强制切换到指定版本能省掉大量“版本不一致”的破事。4.3 同一个依赖装了多份类型冲突怎么查在 Monorepo 里同一个版本范围的依赖因为解析路径不同经常会被安装成多份。这种场景在 TypeScript 项目里很折磨人运行时错误一个没有但类型检查报出一堆Duplicate identifier或者Type X is not assignable to type Y。查看依赖到底装了几份pnpm 下用这个命令pnpm why react它会列出 react 被哪些包依赖安装在哪里版本是什么。npm 下有npm explain reactyarn 下有yarn why react。找到重复来源后处理手段通常是利用包管理器的 overrides 或 resolutions 强制指定一个统一版本。pnpm 的写法是在根目录 package.json 里加{ pnpm: { overrides: { lodash: ^4.17.21 } } }npm 对应的字段是overridesyarn 对应的是resolutions。这里有一个原则能靠 overrides 统一版本不要去改子包里的版本声明因为 overrides 只管当前仓库的解析结果不会影响发布出去的包版本影响面更可控。4.4 Vue/React 双实例问题Monorepo 里的经典翻车现场在 Monorepo 里Cannot find module vue反而是小事更隐蔽的是“组件库插件的类型不对”“对象不是 Vue 实例”“hook 不生效”这类运行时玄学。这类问题的根源通常是同一份 Vue 或 React 在仓库里被装了两份。举个例子。packages/components是一个开发态下的组件库它声明了vue: ^3.4.0而packages/app也声明了vue: ^3.4.0。由于某些依赖版本解析差异pnpm 会为两个子包分别安装一份 vue它们虽然在物理上来自同一个 registry但符号链接指向的是 store 里的两个不同目录。于是 app 里注册组件时用的是 app 自己的 Vue 实例组件内部用的却是 components 里的 Vue 实例插件和响应式系统就错位了。排查这类问题最有效的是在运行时打印 Vue 的使用路径node -e console.log(require.resolve(vue))分别在两个包目录下执行如果打印路径不同就证明存在双实例。然后把两个子包声明里的 vue 版本号统一再通过 overrides 锁定全局版本。React 同理require.resolve(react)如果指向不同目录需要检查是不是有react,react-dom,types/react版本不一致的情况。这个问题的本质是“同一依赖的多个副本”不是某一个具体框架的 bug所以不管你是 Vue 还是 React 还是别的什么框架排查思路完全通用。4.5 Node 版本与包管理器版本不一致子包安装依赖时报错有时和依赖本身无关而是 Node 版本不对。特别是用了一些新语法的新版本依赖在旧 Node 上会直接编译失败反过来太新的 Node 也可能让某些仍在使用旧 API 的依赖行为异常。我遇到过最典型的场景是团队里两个人 node 版本差异很大一个人本地安装一切正常另一个人却怎么都装不上。用node --version一对比问题就暴露了。Monorepo 里推荐在根目录放.nvmrc内容写上 Node 版本号比如20.11.0然后在文档里要求成员执行nvm use或fnm use切换版本。配合前面提到的packageManager字段Node 和包管理器版本都能被固定下来。还有一种很隐蔽的情况包管理器版本过旧根本不支持某种新特性。比如早期的 pnpm 版本对 workspace 协议的支持并不完整升级 pnpm 后问题自动消失。所以遇到解析层面的意外报错不妨先看一眼pnpm --version尝试升级到当前大版本的最新版再试。4.6 网络与缓存导致的安装失败Monorepo 包数量多依赖下载量大大增加网络问题尤其容易暴露。常见的报错有ERR_PNPM_TARBALL_INTEGRITY ETIMEDOUT ECONNREFUSED这类问题不是依赖版本错误是下载链路不稳定或者下载的包校验失败。处理方法一个是降低并发数pnpm install --network-concurrency 1一个是开启离线模式用本地缓存安装pnpm install --offline如果之前已经完整安装过一次--offline能直接用本地 store 里的包快速重建 node_modules不发起任何网络请求。这在网络环境差的时候特别管用。但注意如果 lock 文件里出现了 store 里没有的新包--offline会失败这时去掉--offline正常安装一次就行。还有一类“下载包损坏”问题通常是因为缓存里的文件不完整。把 pnpm store 清理后重装pnpm store prune pnpm install不要在官方文档没让你清 store 的时候手抖去删除全局目录先prune就够了。5. 我的实战经验总结最后这部分我不打算再重复上面的原理而是把多年踩坑之后沉淀下来的工作方式直接交代出来全是干活时能直接用的东西。5.1 把子包依赖安装固化成一条命令Monorepo 最大的敌人是“每个包各自为政”。为了防止成员自己跑到子包目录里乱执行 install我会在根目录 package.json 里定义一套标准脚本。比如{ scripts: { setup: pnpm install pnpm run build, install:app: pnpm install --filter my/app, clean: find . -name node_modules -prune -exec rm -rf {} } }这样团队里每个人的行为路径都收敛到同一个入口。CI 的构建脚本也直接复用根目录命令保证本地和远端行为一致。有几个细节值得留意clean脚本不建议直接给所有人都开放因为全量删除 node_modules 后重新安装很耗时只建议在确实遇到 node_modules 损坏问题时使用setup脚本里的pnpm build是因为很多子包之间通过workspace:引用时还需要对方先把源码构建成目标产物否则直接引用源码在某些脚手架下会出问题。5.2 别被 Monorepo 绑架什么时候不用 workspace 反而更好Monorepo 不是银弹依赖管理也一样。如果你的所谓“子包”实际上没有任何代码复用关系只是把几个独立项目放在一个 Git 仓库里那 workspace 不但帮不上忙还会增加依赖解析的复杂度。我见过有人因为“大家都在用 Monorepo”就强行把所有项目塞进一个工作区结果每个子包仍然互相独立、没有共享代码反而要应付统一 lock 文件和版本策略带来的额外束缚。这种情况老老实实用多仓库或者用不带 workspace 的纯目录结构成本更低。还有一类情况是跨语言项目。Monorepo 的 workspace 机制是为 JavaScript/TypeScript 生态设计的如果你的仓库里既有 Node 子包又有 Python、Go、甚至 ComfyUI 这种带独立依赖环境的第三方工具不要指望pnpm install能把所有语言的依赖一并解决。Python 该用 venv 还是用 venv虚拟环境该建还是要建。Monorepo 能统一的是源码组织方式不是所有语言的依赖管理方式。5.3 最后的几条血泪建议第一子包的 dependencies 必须完整声明。这是 Monorepo 依赖管理里最重要的纪律。宁可多声明用不到的依赖也不要让代码偷偷使用未声明的依赖。后者带来的幽灵依赖问题排查代价远高于几行声明。第二所有内部子包引用统一使用workspace:*不要在内部依赖上写死版本号。每次改内部包的版本再逐个去上游同步版本号纯属浪费时间。第三lock 文件是仓库资产不是负担。它提交进 Git能保证团队所有成员和 CI 环境完全一致。遇到锁文件合并冲突就老老实实重新pnpm install让包管理器处理不要手动删掉锁文件否则很容易在升级依赖的同时引入一堆你根本没发现的破坏性变更。第四遇到“缺少依赖”报错先报模块名再定位 package.json再看 lock 文件最后才考虑重装。按这个顺序排查90% 的安装问题都能快速弄清原因。回到最初的问题子包安装依赖其实只有三条准则在仓库根目录统一管理依赖声明全部显式化内部依赖走 workspace 协议。把这三件事做扎实Monorepo 带来的收益会远大于它引入的管理成本。真实项目里依赖问题几乎都不是“安装不上”而是“组织方式错了”。希望这篇文章能让你少走几条弯路。