☰
npm、Yarn与pnpm的node_modules哲学:从嵌套地狱到严格隔离
2026/10/14 17:43:46 网站建设 项目流程

我刚帮一个项目从 npm 切到 pnpm,历时两天,中间差点被团队骂死。倒不是说切换本身多难——删 node_modules、装一遍、跑测试,半天就完事。真正折腾的是切换后发现一堆代码在 npm 下能跑、到 pnpm 下就报Cannot find module,那种问题一多,团队第一反应永远是"新工具不行",而不是"我们项目里的依赖声明一直不干净"。

这件事让我想认真写一篇对比。网上讲 npm、Yarn、pnpm 的文章不少,但大多是罗列功能点,比如"npm 有锁文件""pnpm 磁盘占用小"。真正搞清楚这三者为什么会有这些差别的其实很少。而我认为,搞懂了它们对 node_modules 的组织哲学,功能差异根本不用背。

如果你正准备在团队里换包管理工具,或者想弄明白为什么同事苦口婆心劝你弃用 npm,这篇内容应该能帮到你。我会从目录结构的历史演变讲到三种工具的安装机制,再结合我在真实项目里踩过的坑,最后给一套可落地的选型建议。

1. 都是安装依赖,为什么能争十年

1.1 包管理器的本质:解析、获取、链接

很多人把包管理器理解成"下载依赖的工具",这是最浅的一层。往前再走一步,包管理器其实干三件事:解析(resolution)、获取(fetch)、链接(link)。

解析是根据 package.json 里声明的语义化版本范围(比如^18.2.0),结合当前环境和锁文件,决定到底该装哪个精确版本。获取是把选定的包从镜像源下载到本地。链接则是把下载下来的包放进项目的 node_modules,并处理包与包之间的相互依赖关系。

这三件事里,npm、Yarn、pnpm 的差异主要集中在第三件——链接。说句可能有点绝对的话:把链接方式搞懂了,工具之间的对比你基本就通了。下载速度、磁盘占用、幽灵依赖、安全隔离,全部根源于"怎么把包放进 node_modules"这个设计决策。

1.2 为什么 node_modules 的摆放方式这么重要

这就要说到 Node.js 的模块解析机制。

你在代码里写require('react'),Node 会从当前文件所在目录开始,向上逐级查找 node_modules 目录。当前目录没有就往上一级找,再没有继续往上,直到文件系统根目录。这个"逐级向上查找"的机制,意味着只要依赖被放在了路径上的任意一个 node_modules 里,require就可能找到它。

所以问题的关键不在于"能不能找到",而在于"能找到哪些、找不到哪些"。npm、Yarn、pnpm 的争论,本质上是三种哲学:

  • npm 早期追求"只要能装上就行",结果装出了嵌套地狱
  • npm v3 和 Yarn 追求"尽量扁平化、减少重复",结果留下了幽灵依赖
  • pnpm 干脆回到"非扁平"结构,但用符号链接绕开了嵌套的低效

用一个生活化的比喻来帮助理解。npm v2 时期是"每家店各自建仓库,货送到店门口,家家各囤一份"。npm v3 之后是"几家店共用一个公共仓库,货统一放,谁要用谁去拿"。pnpm 则是"全城只有一个总仓,每家门牌上挂一个牌子,牌子直接指向总仓里那一份货"。牌子就是符号链接,总仓就是全局内容寻址存储。

三种方案谁优谁劣,取决于你怎么衡量"空间换时间"和"隔离换便利"。下面我分别拆开讲。

2. npm 的两次转身:从嵌套地狱到扁平化治理

2.1 v2 时代的嵌套地狱

如果你是近几年才入行的前端,可能没经历过 npm 最黑暗的时期。npm v2 及更早版本,node_modules 是完全嵌套的:项目依赖 A,A 依赖 B,B 依赖 C,那么物理目录就是node_modules/A/node_modules/B/node_modules/C。

如果项目同时依赖 A 和 B,而 A 依赖 C 的 1.x 版本,B 依赖 C 的 2.x 版本,C 就会被装两份,分别嵌套在 A 和 B 下面。只声明了几十个直接依赖的项目,装完 node_modules 里可能冒出一两千个目录,因为传递依赖是层层嵌套、指数级增长的。

这个方案的致命问题,做过那年头 Windows 前端开发的人一定深有体会:

  • 目录层级极深,require查找要走很多层,启动慢
  • 同一个包的不同版本被反复复制到磁盘,体积巨大
  • Windows 的MAX_PATH限制是 260 个字符,路径一深,删除 node_modules 都会报"路径太长无法删除"
  • 安装结果受安装顺序影响,同一份 package.json 在不同机器上可能装出不同结构的依赖树

最后一点尤其坑。A 和 B 都依赖 C 的不同版本时,谁先被安装,C@1 就会嵌套在谁下面。你根本没法预测最终目录长什么样。这就是当时社区常说的"依赖地狱"。

2.2 v3 的扁平化妥协:hoisting 与幽灵依赖的种子

npm v3 在 2015 年推出了扁平化安装机制,核心思路是"依赖提升"(hoisting)。能提到顶层就尽量提到顶层:安装每个包的时候,如果它的某个传递依赖已经以兼容版本存在于顶层 node_modules,就直接复用;只有版本冲突时,才把不兼容的那个嵌套在对应父级下面。

带来的好处很直接:目录深度大幅减小,重复安装减少,require查找命中率提升。当时大家普遍觉得问题解决了。

但实际上它只是把问题从"看得见的混乱"变成了"看不见的隐患"。扁平化结构里,一个依赖被提升到了顶层 node_modules,意味着它的位置和你的直接依赖平级。而你并没有在 package.json 里声明它——于是你的业务代码可以直接require一个你从未声明过的包。这就是幽灵依赖(phantom dependency)的由来。

典型场景是这样:项目依赖了 react,react 依赖了 loose-envify,在 npm 的扁平化安装下,loose-envify 被提升到顶层。你的代码写require('loose-envify')能跑。但哪天 react 升级不再依赖它,或者换了安装算法,loose-envify 不在顶层了,你的代码就莫名报错。这种 bug 排查起来极其痛苦,因为问题不在你写的代码里,而在"你依赖了一个未被声明的东西"。

而且扁平化不是银弹。依赖图复杂到一定程度时,顶层布局并没有严格的确定性规则,装出来的结构依然可能因安装顺序产生差异。

2.3 迟到的 lockfile:package-lock.json 与 npm v5 之后

npm 一直拖到 2017 年的 v5 才正式引入 package-lock.json。这个"迟到"非常关键:没有锁文件,意味着 package.json 里的^1.2.3只是"最低要求不是上限",一切都以安装那一刻解析出的最新版本为准。这导致老项目隔几个月重新npm install,一个传递依赖的小版本升级,就可能让项目从能跑到崩。

package-lock.json 做的事情其实是把"解析阶段"的产物固定下来:记录每个包最终安装的精确版本、下载地址和校验值。有了它,同一份代码在任何机器上、任何时间安装的结果都是一致的。npm v5 还引入了本地缓存,后续安装速度也提升不少。但说实话,当时的 npm 在缓存粒度、缓存命中率上都不如后来居上的 Yarn。

每次回顾这段历史我都会感慨:npm 是被 Yarn 逼着补完了确定性安装这门课的。package-lock.json 这个特性从技术上并不难做,为什么拖了那么多年?因为当时代谢太慢,npm 更像是在"能跑就行"的方向上狂奔,直到社区用脚投票。

3. Yarn 的进攻与自我分裂:当"确定性"成为第一原则

3.1 2016 年前后的三个痛点:慢、乱、险

2016 年前后的 npm 有几个非常突出的问题。第一是慢:网络请求和磁盘 I/O 的并发度都很低,一个稍大的项目安装动辄三五分钟起步。第二是乱:没有锁文件,版本漂移严重,今天能装的明天不能装。第三是险:印象里是当时一个下载量极高的核心包被恶意版本污染,大量项目在不知情的状态下把它装进了生产环境,npm 的安全响应机制又慢,社区信任度一度掉到谷底。

Yarn 就是在这个节骨眼上出现的。它的定位非常精准,就三个词:快速、可靠、安全。这三个词正好打在 npm 的三个痛点上。

3.2 yarn.lock、并行下载、离线缓存的组合拳

Yarn 带来的核心变化,我认为有三条:

一是确定性安装。yarn.lock 把所有依赖的解析结果——不只是直接依赖,还包括每一个传递依赖——全部锁定为精确版本,并记录下载地址和校验和。同一份 yarn.lock 加上同一份 package.json,在任何机器上、任何时间安装,都能得到完全一致的依赖树。

二是并行下载。Yarn 会把需要下载的所有包排成依赖图,并行发起请求,而不是像 npm 那样分批串行下载。这一步对安装速度的提升是肉眼可见的。

三是离线缓存。Yarn 把首次下载的包放进全局缓存目录,第二次安装时直接从缓存取,不需要再走网络。这和 npm 后来做的本地缓存思路一致,但 Yarn 的全局缓存是跨项目的:A 项目装过一遍,B 项目再装同一个包就直接命中。

这三件事合在一起,把"安装过程"从不可预测变成了可预测。我认为这是包管理器历史上的分水岭——之前大家追求"能装上",之后大家追求"装得对、装得一致"。

3.3 Classic 与 Berry 的分裂:一个名字两个物种

聊 Yarn 有一个绕不开的坑:Yarn 现在其实是两个物种。

Yarn 1(Classic)是 2016 年发布的那个版本,普及度极高,至今很多老项目的 README 里还写着yarn install。但官方早已停止维护,安全补丁和 bug 修复基本不再提供。很多人完全没有意识到这一点,还在把 Yarn Classic 当作长期依赖项。

Yarn 2 之后的产品线叫 Berry,它换了一套截然不同的哲学:默认使用 Plug'n'Play(PnP)模式。PnP 不再生成 node_modules,而是生成一个.pnp.cjs文件,里面记录了项目所有依赖在缓存 zip 包中的精确位置和解析规则。require时通过 Node.js 的解析钩子直接定位,根本不需要在磁盘上创建物理目录。安装速度极快,磁盘占用极小。

但 PnP 的代价是兼容性——它假设生态里所有工具都理解零 node_modules 的世界。编辑器、TypeScript、ESLint 这些都需要额外适配,很多旧的构建工具根本不认。因此 Yarn Berry 也保留了 node-modules linker 模式,可以随时配置切回传统的 node_modules 方案。

所以当有人跟你说"Yarn 很强"或"Yarn 没人用了"时,最好先确认他说的是哪个 Yarn。这种分裂本身就是工具选型困境的一个缩影:历史包袱和创新方向经常被混为一谈。

4. pnpm 的非扁平叙事:硬链接、符号链接与严格隔离

4.1 内容寻址存储:同一份依赖,全机只有一份

pnpm 的设计初衷和 npm、Yarn 都不一样。前两者主要在想"依赖树怎么组织",而 pnpm 先想到的是"同一个版本的包,物理上为什么要反复存那么多份"。

pnpm 的核心是一个全局的内容寻址存储(content-addressable store)。每个包(精确到版本)在 store 中的路径是由它自身内容的哈希值决定的。同一个版本的包,不管被多少个项目使用,物理上全机器只存在一份。

安装流程大致是:解析依赖图得到所有精确版本之后,检查全局 store,缺失的部分才从镜像源下载;然后在项目的 node_modules/.pnpm 下为每个包创建硬链接;最后在正确的位置创建符号链接,把依赖关系串起来。

这一步直接把"下载 + 复制到 node_modules"简化成了"下载 + 创建链接"。硬链接的创建是在文件系统层面完成的,几乎不占 I/O。这也是 pnpm 安装速度快、磁盘占用低的根本原因——不只是 cache 做得好,而是它连"实体文件"都只存一份。

4.2 .pnpm 目录结构:顶层只放你声明过的依赖

pnpm 安装完的项目目录,和 npm、Yarn 完全不同。用一张示意图说明:

node_modules/ ├── .pnpm/ │ ├── express@4.18.2/node_modules/ │ │ ├── express/ # 硬链接到全局 store │ │ ├── accepts/ # 符号链接指向 .pnpm/accepts@1.x │ │ └── ... │ ├── react@18.2.0/node_modules/react/ │ ├── loose-envify@1.4.0/node_modules/loose-envify/ │ └── ... ├── express -> .pnpm/express@4.18.2/node_modules/express └── react -> .pnpm/react@18.2.0/node_modules/react

注意两个关键点:

第一,项目根目录的 node_modules 顶层,只有你直接声明的依赖,以符号链接形式出现。你没声明过的包,根本不会出现在顶层。

第二,每个真实包实体都放在.pnpm/<包名>@<版本>/node_modules/<包名>下。当这个包内部的代码require('accepts')时,Node 的向上查找会先从.pnpm/express@4.18.2/node_modules开始找。这里放着 express 自己声明的依赖的符号链接。换句话说,一个包能看到的世界,严格等于它声明过的依赖世界。

这就是"严格隔离"的含义。它同时解决了两件事:消灭了幽灵依赖(未声明的包你的代码摸不到),也消灭了重复安装(同一个版本物理上只有一份)。

4.3 硬链接的物理前提:同盘才生效

这里必须提醒一个容易被忽略的工程细节:硬链接只能在同一个文件系统内创建。

pnpm 的全局 store 默认在用户目录下,如果你的项目挂在了另一个磁盘分区或另一个挂载卷上,硬链接就无法创建。pnpm 会退化成复制文件——当然,功能没影响,但磁盘占用和速度优势都会大打折扣。

这个问题在 CI 环境里尤其常见。很多云端 CI 的工作目录是一个临时挂载卷,而 store 还在系统盘,结果 pnpm 在 CI 上跑得不快,大家还以为是 pnpm 的问题。我见过不少团队的修复方式,是把 store 路径显式配置到项目目录下,比如.pnpm-store,这样既和项目同盘,又方便 CI 缓存整个 store 目录。

另一个硬链接相关的坑是:硬链接让"项目 node_modules 里的文件"和"全局 store 里的文件"是同一个文件实体。如果你手动改了某个项目 node_modules 里的包文件,等于改了 store 里的原件,其他所有硬链接指向它的项目都会跟着变化。团队规范上应该明令禁止手动修改 node_modules 里的文件——这条规矩在 npm 时代是"改了自己负责",在 pnpm 时代是"改了全局爆炸"。

4.4 为严格隔离付出的代价:原生模块与构建链兼容

严格隔离不是没有代价。最直接的冲突就是安装脚本(install scripts)。

pnpm 出于安全考虑,默认不会执行依赖包里的 preinstall 和 postinstall 脚本。npm 和 Yarn 1 默认是执行的。这个设计是为了防供应链攻击——恶意依赖可以通过 postinstall 在你机器上执行任意代码。但很多正经包也需要安装后跑脚本,比如 esbuild 要下载自己的二进制文件、sharp 要编译原生模块。pnpm 下这些包会"安安静静装完,用起来直接报错"。

解决办法是给它们开白名单,在 package.json 里加:

{ "pnpm": { "onlyBuiltDependencies": ["esbuild", "sharp"] } }

pnpm 也提供了pnpm approve-builds交互式命令来手动批准。新团队切到 pnpm 时,这一步几乎是必踩的坑,遇到了别慌,列一下白名单就行。

工具链的兼容性问题也在少数场景存在。某些旧的打包器不理解符号链接式的依赖结构,解析时会出错。我在实际项目里遇到的概率不高,但一旦遇到,排查方向基本都在"某个依赖对真实路径做了硬编码假设"上。

5. 三个工具在真实项目里的差异表现

5.1 安装速度、磁盘占用与缓存命中

理论讲再多,最后还是要落到真实使用感受。我在一个典型的中大型前端项目上做过对比,直接说结论。

安装速度上,冷安装(无缓存)时 pnpm 明显优于 npm 和 Yarn Classic,大型项目可以达到数倍的差距。热安装(有缓存)时差距缩小,但 pnpm 依旧占优。现代 npm(v7 之后)已经补上了并行下载、本地缓存、确定性锁文件这些短板,日常小项目体感并不差。Yarn Berry 使用 PnP 模式时安装极快,因为压根不写 node_modules 实体文件,但这是建立在不生成 node_modules 的基础上。

磁盘占用上,pnpm 的优势会随着项目数量增大而放大。单个项目可能只省百分之三四十的空间,但如果一个 monorepo 里有二十个子包共享同一批依赖,npm 会把这些依赖在每个子包里各复制一遍,pnpm 则全部软链到同一个 store——差距是指数级的。

对比维度npm(v7+)Yarn ClassicYarn Berry(PnP)pnpm
冷安装速度中等中等快快
热安装速度中等中等极快快
磁盘占用高高极低低
锁文件package-lock.jsonyarn.lock.pnp.cjspnpm-lock.yaml
依赖隔离弱弱强强
幽灵依赖常见常见不存在不存在
兼容性最好较好较弱,需适配较好,偶有工具链问题

5.2 锁文件的格式与语义差异

锁文件是避免版本漂移的关键,但三种工具的实现差异不小。

package-lock.json 是 JSON 格式,记录的是实际安装出的依赖树结构,包含每个包的位置、版本、integrity 校验值和下载地址。它的结构是"树形"的,和 node_modules 实际布局强相关。

yarn.lock 是 YAML 格式,扁平列表,同一个依赖的多个版本会以不同条目列出。它的核心是保证解析结果的确定性,不关心安装时的物理布局。Yarn Berry 的锁文件和 Classic 完全不同,这也是两者不能混用的原因之一。

pnpm-lock.yaml 是 YAML 格式,记录方式接近 pnpm 的非扁平依赖树结构,包含每个包经过完整解析后的 resolution 信息。它和 pnpm 安装出的实际目录结构是严格对应的,这也是 pnpm 能实现强校验的基础。

这里有个容易踩的坑:不少团队会 npm 和 Yarn 交替使用,以为只要有 lockfile 就安全了。但实际上 package-lock.json 和 yarn.lock 是两套完全独立的解析结果,交替使用会让两个文件先后失效,最后还是在凭 package.json 重新解析,等于回到没有锁文件的状态。选定一个工具,就只用它的锁文件。

5.3 幽灵依赖实证:一次切换引发的连锁故障

前面讲了幽灵依赖的原理,这里给一个我实际遇到的案例。

有个项目 package.json 里只声明了 react 和 react-dom 两个依赖,但业务代码里直接写import canUseDOM from 'exenv'。在 npm 的扁平化安装下,exenv 作为 react 的传递依赖被提升到了顶层 node_modules,代码一直能跑。切换到 pnpm 之后,安装一切正常,启动时却报了一堆Cannot find module 'exenv'。

不是 pnpm 坏了,是这段业务代码一直在依赖一个"没被声明、也本不该直接使用"的内部依赖。npm 的扁平化结构掩盖了这个问题,pnpm 的严格隔离把它暴露了出来。团队当时的处理分两步:先逐个把报错的模块补充为直接依赖声明;然后在 ESLint 配置里加上了import/no-extraneous-dependencies规则,强制检查"你 import 的东西必须出现在 package.json 里"。

这类问题几乎每次 npm 切 pnpm 都会遇到,属于正常现象。如果你的项目之前一直在 npm 下运行且从未检查过依赖声明,切换后可以预期会有一定量的报错——这正是 pnpm 在帮你把账还干净。

6. 选型建议:什么时候用 npm、Yarn,什么时候必须用 pnpm

6.1 别让工具绑架项目:每种工具的合理归属

先说结论。个人项目、临时脚本、快速原型:npm 就足够。现代化 npm 的功能并不差,能用你熟悉的东西解决问题,没必要为了工具而工具。

团队新项目,没有历史包袱:我建议默认 pnpm。理由不是它快,而是它的严格隔离能让依赖声明始终保持健康;它的全局 store 在大团队多人协作时能明显节约磁盘和网络资源;它内置的 workspace 支持是三个工具里最好的,未来项目膨胀成 monorepo 时不需要换工具。

存量 npm 项目:没有强烈驱动因素就别折腾。工具切换本身不复杂,但切换过程中暴露出来的历史遗留问题会消耗团队精力。如果一定要切,建议选业务低谷期,并且把它当成一次"依赖健康体检",而不是单纯的工具替换。

还在用 Yarn Classic 的项目:建议认真评估迁移。Classic 已停止维护,安全补丁不再更新,作为长期依赖项风险会逐渐累积。可以迁到 Yarn Berry,也可以迁到 pnpm,具体看团队对 PnP 模式的接受度——如果不愿意适配编辑器插件和工具链,直接上 pnpm 的 node_modules 模式会更平滑。

涉及大量原生模块或老旧构建工具的项目:先用 pnpm 装一遍并完整跑通构建链路,再决定是否切换。这类项目是 pnpm 兼容性问题的高发区,但多数通过 build 白名单或工具升级可以解决。

6.2 从 npm 迁移到 pnpm 的完整动作清单

如果确定要切,我建议按下面的清单操作,每一步都确认后再走下一步。

第一,建一个单独的分支,不要直接在主干上做迁移。

第二,删除 node_modules 和 package-lock.json。不要在旧 lockfile 基础上混合 migration,直接让 pnpm 按 package.json 重新解析,生成 pnpm-lock.yaml。首次解析可能需要几分钟,但这是一次性的。

第三,执行pnpm install。重点观察两个地方:有没有依赖的 install 脚本被阻止执行;有没有因为符号链接结构导致的原生模块安装异常。有的话,按 4.4 小节的方式配置onlyBuiltDependencies白名单。

第四,全量运行测试和构建脚本。所有Cannot find module报错,先判断是不是未声明依赖,然后在 package.json 里补上对应声明。不要为了让报错消失而关掉严格模式,那属于饮鸩止渴。

第五,更新团队脚本里的命令。npm run 换成 pnpm run,npx 换成 pnpm dlx,npm ci 换成 pnpm install --frozen-lockfile。注意 pnpm cli 也支持直接pnpm dev这样的简写,但pnpm run dev更直观,团队里怎么选,统一就好。

第六,在根目录 package.json 里加"packageManager"字段,比如"packageManager": "pnpm@9.x.y",配合 Node.js 自带的 corepack,可以让团队所有成员强制使用同一版本的 pnpm,避免"我本地好好的怎么他的环境就装了别的版本"的扯皮。

6.3 CI 与 Monorepo 场景下的隐藏注意事项

CI 环境里,第一优先是用--frozen-lockfile安装。它要求 package.json 和锁文件完全一致,否则直接失败。这是防止 CI 和本地安装结果漂移的最稳手段。

pnpm 在 CI 里的性能完全取决于 store 的缓存策略。建议同时做两件事:把 store 路径显式配置到一个可以被整个 CI 缓存处理的位置,并确保该路径和项目目录在同一文件系统内,保证硬链接生效;把整个 store 目录加入 CI 的缓存 key。这样同一批依赖在不同 CI 任务里反复安装时,速度会有量级提升。

Monorepo 场景,pnpm workspace 是目前我体验最好的方案。项目根目录放一个 pnpm-workspace.yaml:

packages: - 'apps/*' - 'packages/*'

子包之间互相引用时,用workspace:*协议声明依赖,pnpm 会正确地把它们链接成 workspace 内置依赖。常用命令也简单:想只在某个子包里跑脚本,用pnpm --filter <包名> run <脚本>;想在所有包里执行,用pnpm -r run <脚本>。

最后一个隐藏注意点:monorepo 的依赖提升策略。pnpm 默认不像 npm 那样把依赖大规模提升到根 node_modules,这会让某些子包访问根依赖时失败。如果确实有提升需求,可以通过配置显式声明提升规则,但更好的做法是让每个子包把自己用到的依赖声明清楚。这又回到了 pnpm 的核心理念:你是谁,就用什么,别靠别人'带'给你的依赖活着。

我在实际项目里摸索一圈后的体会是,新项目默认 pnpm,存量 npm 项目尊重历史,只要团队能清楚说明自己选型的理由和代价,用哪个工具都不算错。真正危险的从来不是选错工具,而是在没有任何人说得清为什么换、换了之后要注意什么的情况下,稀里糊涂完成了切换。读完这篇,希望你在下一次敲下安装命令时,能准确说出自己手里的工具正在做什么——以及它为什么值得你信任。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询