同样是装依赖,为什么 npm 长成 node_modules,Maven 长成 .m2

同样是装依赖,为什么 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,三件事必须一起看:

  1. 内容寻址的 store。 每个包的每个版本,在全局 store 里只存一份,按内容哈希寻址。具体路径各平台不同,pnpm store path 可以查。
  2. hardlink 进项目。 node_modules/.pnpm/<pkg>@<version>/node_modules/<pkg>/ 里的文件,是 store 里那份文件的硬链接------同一份磁盘数据,两个名字,不占额外空间。
  3. 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 项目到底要不要打包"------那是这条线的延长段:一旦把解析整个搬进构建期,交付形态会怎么变?这个话题够长,下一篇单独聊。

相关推荐
屈冠成3 小时前
ShopXO商城系统测试报告
开发语言·spring·maven·lua
FungLeo5 小时前
成为全栈·Go 后端篇·重写之前先锁基线:契约、Node 版本与行为清单
golang·node.js·go·openapi·契约测试·成为全栈
FungLeo1 天前
成为全栈·Go 后端篇·契约一致性与差分测试:两套实现如何互相校验
golang·node.js·go·合约测试·差分测试·成为全栈·api兼容
月夏1 天前
前端第一次接自己的后端:mock 切真接口踩了 2 个坑,组件只改了一行 import
前端·后端·node.js
SEO_juper1 天前
Node.js 页面主题聚焦度检测:title 和正文各说各的,AI 也读不懂
开发语言·人工智能·node.js·seo·外贸独立站
SEO_juper2 天前
Node.js 重定向链检测工具:301 跳了几跳、有没有成环,一次看清
xml·开发语言·python·node.js·json·bash
king13598745502 天前
AI Agent 端云协同选型:Node.js 任务成本算例与权限检查清单(2027—2028 展望)
人工智能·ai·node.js·ai agent
chunyublog2 天前
HDFS 上传文件名空格问题排障教程
前端·javascript·npm
居里先生7522 天前
PDF 批注到底存在文件的哪里?从 /Annots 讲到导出验证
node.js