Vue 3、Vite、Pinia 这些我们熟悉的项目,源码层面无一例外都采用了 Monorepo 架构来管理多个子包。而在 Monorepo 的工程实践中,有一个绕不开的话题:依赖提升带来的幽灵依赖问题。它平时不动声色,一旦爆发却可能让构建失败、产物异常,甚至把问题带到线上。这篇文章就来把这件事讲透,从 node_modules 的目录结构说起,到 pnpm 为什么能天然规避幽灵依赖,最后给出一套可以直接落地的配置方案。

一、先搞清楚:依赖提升到底做了什么
在 npm v3 之前,node_modules 是纯嵌套结构的,每个包自己的依赖装在自己目录下。这样做虽然隔离干净,但目录层级极深、磁盘占用巨大,同一个依赖可能被重复安装几十次。为了解决这个问题,npm v3 引入了扁平化策略,也就是依赖提升:npm 会把所有依赖尽量提升到 node_modules 根目录,只有遇到版本冲突时才降级为嵌套安装。
yarn 早期版本沿用了同样的策略,并在 Monorepo 场景下通过 workspaces 进一步强化了依赖提升——所有子包的依赖会被统一提升到项目根目录的 node_modules 中。这带来了安装速度和磁盘空间上的收益,但也埋下了隐患:扁平化的 node_modules 让所有包的依赖都「互相可见」了。
举个具体的例子,假设 Monorepo 中有两个包:packages/ui 依赖 vue,packages/utils 依赖 lodash。经过依赖提升后,根目录的 node_modules 里同时存在 vue 和 lodash。此时如果 utils 包的代码里写了一句 import { camelCase } from 'vue',构建竟然能通过——因为 Node 的模块解析算法会沿着目录向上查找,最终在根目录的 node_modules 里找到了 vue。这种「没有在 package.json 中声明,却能直接使用」的依赖,就是幽灵依赖。
二、幽灵依赖的三宗罪
第一宗罪是隐式耦合导致难以拆包。幽灵依赖在单仓库内使用时往往一切正常,因为大家共享同一个 node_modules。可一旦某个子包需要单独发布到 npm,或者被抽离成独立仓库,缺失的依赖声明会立刻暴露,运行时报 module not found。这种问题往往在拆分时才被发现,排查成本很高。
第二宗罪是版本不可控。幽灵依赖使用的是其他包提升上来的版本,这个版本不受当前包控制。比如 utils 包幽灵依赖了根目录某工具库的 v4 版本,某天另一个包把它升级到 v5 并且 API 发生变更,utils 包的代码就在毫不知情的情况下被破坏了。更危险的情况是,某个间接依赖从依赖树中被移除,幽灵依赖直接消失,而这一切对当前包的开发者来说完全是黑盒。
第三宗罪是本地能跑,CI 或生产环境挂掉。不同环境的 node_modules 结构可能因为 lock 文件差异、缓存策略不同而略有区别。本地开发时扁平结构恰好让幽灵依赖可用,而在 CI 的严格环境或者生产构建中,依赖解析失败导致构建中断。这类「薛定谔的依赖」问题排查起来非常折磨人,因为你无法在本地稳定复现。
三、pnpm 为什么是 Monorepo 的更优解
pnpm 的核心设计是内容寻址存储 + 符号链接。所有包的真实文件只存储一份在全局 store 中,项目内的 node_modules 通过硬链接和符号链接指向 store。关键在于,pnpm 的 node_modules 结构是非扁平的:.pnpm 目录存放所有依赖的真实结构,而根目录的 node_modules 里只包含 package.json 中直接声明的依赖。
这种结构带来的直接效果是依赖隔离。你的代码只能 require 或 import 到自己声明过的依赖,任何未声明的包都会在解析阶段直接失败。也就是说,pnpm 从机制上让幽灵依赖无法被访问到,问题在开发阶段就会暴露,而不是留到构建甚至线上。这也是 Vue 3、Vite 等项目从 yarn 迁移到 pnpm 的主要原因之一。
不过 pnpm 也留了一个口子:shamefully-hoist 选项。开启它后 pnpm 会把所有依赖提升到 node_modules 根目录,行为退化为接近 yarn 的模式。这个选项主要是为了兼容一些依赖了幽灵依赖行为的老旧第三方包(比如某些包硬编码引用了未声明的依赖)。如果不得不开启,一定要明白这是妥协,而不是常态。
四、Vue 3 风格的 Monorepo 实操配置
pnpm 原生支持 workspace,不需要额外工具。在仓库根目录创建 pnpm-workspace.yaml 即可:
packages: - 'packages/*' - 'examples/*'
Monorepo 内部包之间的引用,pnpm 推荐使用 workspace 协议。它明确了「这个依赖来自本地工作区」的语义:
{
"name": "@my/app",
"dependencies": {
"@my/ui": "workspace:*",
"@my/utils": "workspace:*",
"vue": "^3.4.0"
}
}
workspace:* 表示任意版本匹配本地包,发布时 pnpm 会自动将其替换为真实的版本号。如果想锁定具体的版本范围,也可以写 workspace:^ 或 workspace:~。此外,pnpm 还支持 catalog: 协议,可以在根 package.json 中统一定义依赖版本,避免多个子包各自声明导致版本漂移:
{
"name": "my-monorepo",
"pnpm": {
"catalogs": {
"default": {
"vue": "^3.4.21",
"typescript": "^5.4.0"
}
}
}
}
子包中引用时写 "vue": "catalog:" 即可,全仓库的 vue 版本由一处配置统一掌控。
五、给存量项目加一道兜底防线
即使采用了 pnpm,也建议在工程层面做额外的依赖校验。一方面可以用 eslint 插件检查 import 的模块是否在 package.json 中声明,比如 eslint-plugin-import 配合 import/no-extraneous-dependencies 规则;另一方面可以在 CI 中加入依赖一致性检查,确保 lock 文件与 package.json 严格同步。
对于仍在使用 yarn 或 npm 的存量 Monorepo,可以考虑 dependency-cruiser 这类工具生成依赖关系图,配合规则校验找出未声明的引用。迁移到 pnpm 时也不必一步到位,可以先把公共依赖逐步改为显式声明,观察一段时间后再切换包管理器,风险会小很多。
总结一下:幽灵依赖的本质是扁平化 node_modules 破坏了包的依赖边界,pnpm 通过符号链接结构从物理上恢复了这道边界,再配合 workspace 协议和 catalog 版本管理,就能搭建出一套依赖关系清晰、可维护性强的 Vue 3 Monorepo 工程。工程上的问题往往不是靠某个黑科技解决的,而是靠机制让错误提前暴露——幽灵依赖正是这样一个典型案例。