同样是装依赖,为什么 npm 长成 node_modules,Maven 长成 .m2
先看两个场景。
一个 Spring Boot 项目 clone 下来,mvn compile 跑完,项目目录依然干净:依赖全在 ~/.m2/repository 里,按 groupId/artifactId/version 排得整整齐齐。十个 Java 项目共用同一份 Spring,硬盘上只有一份。
一个 React 项目 clone 下来,npm install 跑完,项目里多出一个 node_modules:几千个目录层层嵌套。十个 Node 项目,就是十份。
两边都在"装依赖",结果一个是全局仓库,一个是项目内的目录树。
最常见的解释是"npm 设计得烂"。这个答案便宜,但经不起追问:npm 背后有整个社区十几年的投入,真有那么蠢?pnpm 既然能解决,npm 为什么不直接抄?
答案和包管理器的代码质量关系不大,和一件更底层的事关系很大:模块解析的时候,运行时到底"看"什么。
一、分水岭:解析的输入,是显式还是隐式
先看 Node 怎么找包。
require('lodash') 的执行过程是:从当前文件所在目录开始,看有没有 node_modules/lodash;没有就上跳一层,再看;一直跳到根目录。这就是所谓的"目录爬升"。
这个过程里有个容易被忽略的事实:Node 的解析器从不读 dependencies 字段来判断某个包"该不该可见"。 它只看文件在不在。
所以"声明"和"可见"是两套独立的系统。声明给人看、给包管理器看;可见性给解析器看------而解析器只认文件系统的物理位置。
Python 更直接:import 遍历 sys.path,sys.path 里是什么路径,就能从哪里导入。
这两个生态的共同点是:位置是解析的输入,所以位置就是接口。
再看 Maven。
Maven 找依赖不靠猜,靠坐标。pom.xml 里写 groupId、artifactId、version,Maven 拿这三个字段去仓库取,取回来放进 ~/.m2/repository。这个路径由坐标唯一决定,跟你的项目在哪、目录叫什么,没有任何关系。
Go 和 Rust 走得更远:构建完成后,产出的是一个默认静态链接的二进制(Go),或者一个自带全部依赖的产物(Rust)。跑起来之后,"找模块"这件事根本不存在。
于是可以画一条线:
| 生态 | 解析的输入 | 解析发生在 | 位置的角色 |
|---|---|---|---|
| Node / Python | 当前路径 + 目录树 / sys.path |
运行时 | 接口,必须精确 |
| Java / JVM | 显式 classpath |
运行时 | 启动参数,可任意 |
| Go / Rust | go.mod / Cargo.toml 里的坐标 |
构建时 | 缓存落点 |
注意第二行。Java 是个很好的试金石:JVM 在运行时确实也要找类,但找的依据是启动时显式传入的 classpath ,不是"从当前目录往上爬"。所以 Java 和 Node 虽然都叫"运行时查找",命运却完全不同------前者可以把依赖整个丢进全局的 .m2,后者不行。
这说明"运行时 vs 构建时"这个二分还不够准。更准的说法是:解析的输入,是隐式的约定,还是显式的声明。
- 隐式输入 → 包管理器必须把文件放到"解析器恰好会看的位置" → 位置成了契约 → 只能复制。
- 显式输入 → 位置只是一个可以随便填的字符串 → 全局共享是免费的。
到这个程度,问题还能再往下问一句:模块的标识符,到底是一个"名字",还是一个"位置"?
Java 的 org.springframework:spring-core:6.1.0 是名字。名字和位置是两件事------同一个名字可以来自中央仓库、公司镜像、本地 .m2,甚至 vendor 目录,位置怎么换,都不影响它"是哪个包"。
Node 的 require('lodash') 看起来像名字,实际是位置:它的含义是"从当前目录往上爬,命中的那个 node_modules/lodash"。名字和位置是同一件事------换台机器、换个项目,这个名字指向的东西就必须在规则允许的位置上真实存在。
名字和位置是两件事,位置就自由;名字和位置是一件事,位置就必然成为契约。 剩下的差异------复制还是共享、扁平还是嵌套、要不要 lockfile------都只是这一句的推论。
二、Node 的两次妥协
理解了"位置是契约",npm 的历史就很好读了。
npm v2:嵌套。 每个包把自己的依赖装进自己的 node_modules。a 依赖 c@1,b 依赖 c@2,那就装两份,各放各的目录。这在隔离上是完美的------每个包只能看见自己的依赖,版本冲突不存在。代价是重复和深。Windows 上经典的 MAX_PATH 260 字符问题,就是那个年代留下的。
npm v3:扁平化。 把依赖尽量提升到项目根 node_modules,只有版本冲突时才嵌套。路径短了,重复少了,但引入三个新问题:
幽灵依赖。 依赖被提升到根之后,一个你在 package.json 里从没写过的包,也可能被 require 到。你的代码"恰好能跑",但它不在声明里。换台机器、换个安装顺序,可能就崩了。
依赖树不稳定。 提升结果取决于安装顺序和包的到达顺序,两次全新安装得到的目录结构可能不一样。package-lock.json 就是为了把这件事钉死才出现的。
跨项目依然重复。 扁平化只解决了单个项目内部的重复。十个项目还是十份。因为这层重复的源头不是依赖树形状,而是"位置是契约"------每个项目都得有一份物理上就在那儿的文件。
到这儿,npm 的两条路都走完了:要么干净但臃肿,要么轻一点但脏。只要位置还是契约,就没有第三条路。
三、pnpm:不是"全局缓存",是让位置可重定向
先澄清一个流传很广的误解:"全局缓存"不是 pnpm 的独门武器。 npm 从 v5 开始就有内容寻址缓存(~/.npm/_cacache),pnpm 有 store,在"不重复下载"这件事上两者是一回事。
区别在装完之后。
npm 是 cache + copy :缓存只负责让你别再下载一遍,装的时候照样把文件复制进 node_modules。十个项目,十份拷贝。
pnpm 是 store + hardlink + symlink,三件事必须一起看:
- 内容寻址的 store。 每个包的每个版本,在全局 store 里只存一份,按内容哈希寻址。具体路径各平台不同,
pnpm store path可以查。 - hardlink 进项目。
node_modules/.pnpm/<pkg>@<version>/node_modules/<pkg>/里的文件,是 store 里那份文件的硬链接------同一份磁盘数据,两个名字,不占额外空间。 - symlink 拼出依赖树。 项目根的
node_modules下只有你显式声明过的包,每一个都是指向.pnpm里对应版本的符号链接。
这里有个关键机制,很多文章没讲透:require 在解析时默认会把符号链接解析成真实路径 (除非显式开 --preserve-symlinks)。也就是说,require('a') 拿到的真实位置是 .pnpm/a@1.0.0/node_modules/a。而在这个真实位置旁边,只有 a 自己声明的依赖被链接进来。
于是:
- 你在项目里
require('lodash')------根目录有 symlink,找得到。 a内部去require('lodash')------如果a没声明 lodash,它旁边的目录里就没有 lodash 的链接,找不到。
幽灵依赖就这么消失了:它没有"被提升到根"这条路可走。
pnpm 没有改 Node 的查找规则。它改的是"文件在哪"这个事实本身。 位置仍然是契约,但不再是唯一确定的------同一个物理文件,在不同项目里有不同的逻辑位置,靠链接重定向。
这是在"接口不能动"的硬约束下能做到的最好:不动运行时,只动文件系统。
四、反证:位置一旦不是契约,问题就不成立
如果"位置是契约"是根因,那么不把位置当契约的生态,就应该天然没有这个问题。看几个样本。
Go:最彻底的一个。
go.mod 声明 module path 和版本,go.sum 记录校验和。构建时,Go 去 GOMODCACHE(默认 $GOPATH/pkg/mod)取源码、编译,产出静态链接的二进制。部署就是把一个文件扔上去,模块目录存不存在都无所谓。
Rust:同一个思路。
Cargo.toml 声明 crate 和版本,Cargo.lock 锁死整棵树,源码缓存在 ~/.cargo/registry,编译产物自带全部依赖。
Java:看起来是运行时,其实不是。
前面说过,classpath 是显式的。直接后果是:所有依赖可以平铺在一个全局仓库里,由构建工具在启动脚本或 manifest 里拼出一条 classpath。.m2 之所以能全局共享,不是因为它设计得多精妙,而是因为位置从头到尾都不参与猜测。
这里要回答一个常见的追问:~/.m2 不也是个固定位置吗?
是,但它是默认值 ,不是契约。~/.m2 可以在 settings.xml 里用 <localRepository> 改掉,也可以临时用 -Dmaven.repo.local=D:/repo 指到别处,业务代码一行都不用动。区别在于位置到底扮演什么角色:
- 把
node_modules从项目里挪走,require立刻失效------因为位置是解析的输入 ,解析器就是靠"当前目录 +node_modules"拼出候选路径的。 - 把
.m2挪走,改一个参数就好------因为位置是解析的输出,Maven 先按坐标算出该用哪个 jar,再把它的绝对路径写进 classpath。
输入还是输出,这才是分界线。 位置一旦成为输入,它就必须被精确满足,也就必然成为契约;位置如果只是输出,它就只是一个字符串,放在哪都行。
Java 生态里当然也有位置约定,但都在别处:~/.m2/settings.xml 是 Maven 的配置文件位置,仓库内部的 groupId/artifactId/version/ 是存储格式约定,Spring Boot 的 fat jar 则是固定从 BOOT-INF/lib/ 加载。这三处都在"按位置找东西",但找的人是构建工具和打包格式,规则写在工具这一侧、随时可以换。真正的契约,是运行时按位置找、而你无权改规则。 Go 和 Cargo 同理------GOMODCACHE、CARGO_HOME 都是可改的默认值,没人要求构建缓存必须住在 ~/go 或 ~/.cargo 里。
但显式输入也有它的账单。
冲突处理变难了。 classpath 是平铺的,同名类只能有一个。两个库分别依赖同一个库的不同版本时,Maven 用"最近优先"选一个,选错了就是运行期 NoSuchMethodError。Java 生态里依赖冲突是日常,不是意外。
对比之下,Node 的嵌套结构在这一点上更宽容------不同版本可以各待各的目录。Go 用最小版本选择(MVS)只选一个,Rust 允许同一 crate 的不同 major 版本共存。共享换来的便利,代价是把冲突判断提前到了构建期,而且必须有人替它做对。
"位置无关"在受控环境里会退回去。 Go 有 go mod vendor,Rust 有 cargo vendor,Node 侧也有 npm ci 配合缓存离线安装。vendor 做的事,就是把依赖复制进仓库,让构建不依赖网络、不依赖全局缓存此刻的状态。为了可复现和可审计,它们主动放弃了"位置无关"。
这说明:全局缓存不是免费的午餐。它把"依赖从哪来"这个问题,从"项目内"转移到了"机器上"。一旦机器不可控------CI、离线、代码审计------你就得把位置重新固化回去。
Python:一个中间态,正在向 pnpm 靠拢。
venv 的思路是:既然位置是接口,那就给每个项目造一个独立的 site-packages。这是拿文件系统去补"位置是契约"的债------隔离是有了,重复还在。
uv 往前一步:全局缓存 wheel,安装时优先用 hardlink,不支持就退回复制或 reflink。这跟 pnpm 是同一个思路,只是晚了几年。跨语言看,"全局内容寻址 + 硬链接"正在成为默认答案。
五、终点:把"位置"改回"名字"
如果根因是"标识符其实是个位置",那 JS 生态这几年的动作,方向就非常清楚:把它们重新变回名字。
package.json 的 exports 字段。 包自己声明对外暴露哪些入口,import 'pkg/foo' 能不能成立由它说了算,而不是靠"你恰好能爬到那个文件"。这一步把深路径导入从文件系统的偶然,变成了包的公开契约。
浏览器端的 Import Maps。 <script type="importmap"> 直接给出"模块名 → URL"的映射表,浏览器不再需要目录爬升。
Node 的 imports 字段。 用 # 前缀声明内部别名:
json
{
"imports": {
"#utils/*": "./src/utils/*.js"
}
}
import x from '#utils/date' 直接映射到具体文件,不依赖相对路径的层级。
打包工具。 Webpack、Rollup、esbuild 在构建期静态分析 import,建出整张图,把一切内联进产物,运行时不再解析任何东西。
但这里要克制一点。很多文章会顺势得出"node_modules 要消失了"的结论,这步跳得太快。
Import Maps 的映射表写在 HTML 里,imports 字段的映射表写在最近的 package.json 里------你依然需要一个"约定的位置"去读这张表 。Node 依然要向上找最近的 package.json。所以被消解的从来不是"目录",而是"目录布局作为契约"。
一句话:node_modules 可以从"一棵有形状的依赖树",退化成一个"扁平的、没有嵌套的产物目录"。但它不会凭空消失,除非整个解析过程被搬进构建期------也就是打包。
六、所以,这不是"谁好谁坏"
回到开头的问题。
npm 长成 node_modules,不是因为 npm 笨,而是因为它选择把不确定性留在运行时------让 require 足够简单、足够可预测。代价是位置必须精确,于是必须复制。
Maven 长成 .m2,也不是因为 Java 更先进,而是因为它把不确定性挪到了构建期和显式声明上。代价是依赖冲突要自己扛,可复现也要自己保证。
pnpm 的位置很微妙:在"运行时接口不能动"这个硬约束下,用文件系统做了一次偷换,把"位置唯一"降级成"位置可重定向"。它没有推翻规则,只是让规则变便宜了。
名字和位置是不是一回事,决定了一个生态的依赖管理长什么样;要不要让它们分开,则是一道"你把不确定性放在哪"的选择题。 放在运行时,就要接受复制;放在构建时,就要接受更复杂的冲突与可复现成本。每个生态都在自己的约束里做了取舍,没有免费的第三选项。
至于"Node 项目到底要不要打包"------那是这条线的延长段:一旦把解析整个搬进构建期,交付形态会怎么变?这个话题够长,下一篇单独聊。