npm 的 node_modules 目录结构分析
-
安装直接依赖(你通过
npm i安装的包):- 当你运行
npm i A时,A包总是 会作为一级依赖被安装到node_modules的根目录下。这是没有疑问的。
- 当你运行
-
安装间接依赖(
A所依赖的C包):C包最终安装在哪里,取决于当前node_modules根目录的状态:-
情况 1:根目录
node_modules下没有C包- 那么 npm 会把
C提升 (hoist) 安装到根目录下的node_modules里(即node_modules/C)。 - 结果:
C存在于根目录。
- 那么 npm 会把
-
情况 2:根目录
node_modules下已经存在一个C包(可能是其他直接或间接依赖已安装的)-
npm 会检查已存在的
C的版本是否满足A对C的版本要求(查看A的package.json中dependencies里C的版本范围)。-
如果满足(兼容):
npm就不会再为A单独安装C。A将直接使用根目录下已存在的这个C包。- 结果:
C只存在于根目录(一份),A的文件夹下不会 创建node_modules/C。
- 结果:
-
如果不满足(不兼容):
npm就会在A自己的文件夹下创建一个node_modules子目录,并将A所需的特定版本的C安装在那里(即node_modules/A/node_modules/C)。-
结果 :
C有两份(或不限于两份):- 一份是根目录下的版本(其他包在用)。
- 另一份是
A专有的、特定版本的C(位于A的本地node_modules里)。
-
-
-
-
-
在项目代码中
import/requireC:-
当你在项目根目录的源代码(例如
src/index.js)中写const c = require('C')或import c from 'C'时:-
Node.js 的模块解析器会首先 在项目根目录的
node_modules(即node_modules根目录)中查找C。 -
如果根目录
node_modules下有C:- 无论这个
C是直接安装的还是被提升上来的,你的代码都会加载到它。
- 无论这个
-
如果根目录
node_modules下没有C(比如因为版本冲突,所有C都只存在于某个包的嵌套node_modules里):- 你的代码将找不到
C(除非你有其他机制)。这就是为什么你不能在项目代码中直接使用那些因为版本冲突而被嵌套安装的包。
- 你的代码将找不到
-
-
在
A包的代码中import/requireC:- 如果
A自己文件夹下有node_modules/C(因为版本冲突),那么A内部的代码加载到的就是它自己专有的C。 - 如果
A自己没有node_modules/C(因为根目录的C兼容可用),那么A内部的代码加载到的就是根目录的C。
- 如果
-
注意 :这里其实有个 bug,因为如果你项目里没有安装
C包,安装了A包,A包依赖C包,此时C包会被提升到项目根目录的node_modules,然后你在项目里导入C包没有问题,但是如果你哪天把A包删掉了,C包也会跟着删掉,就出现了幽灵依赖的问题,项目里导入C包直接报错。
-
pnpm 核心机制梳理
📂 文件结构
perl
node_modules/
├── .pnpm/ # 所有依赖的「唯一真实存储」
│ ├── esbuild@0.25.8/ # 包文件夹
│ ├── vite@7.0.6/ # 包文件夹
│ │ └── node_modules/ # 隔离层:仅存自身 + 其直接依赖的软链接
│ │ ├── esbuild # vite 的直接依赖(软链接指向 .pnpm/esbuild@0.25.8 硬链接)
│ │ └── vite # 自身(硬链接)
│ └── node_modules/ # 内部 Hoist 区:存放共享包(软链接)
└── vite # 软链接指向 .pnpm/vite@7.0.6/node_modules/vite 硬链接
ヽ(ー_ー)ノ pnpm 官方原理图

🔗 核心机制
-
硬链接存储
→ 所有依赖包物理文件只存一份 在
.pnpm store,通过硬链接复用,节省磁盘。 -
软链接隔离
→ 项目直接依赖在根
node_modules以软链接 指向.pnpm中的真实文件,不是项目的直接依赖不会放在根node_modules里面。 -
.pnpm 文件的结构
→ 每个依赖包的
node_modules(/.pnpm/包@版本/node_modules),只包含自身硬链接和其直接依赖的软链接。→ 直接依赖和间接依赖都会平铺在
.pnpm文件根目录。 -
幽灵依赖防御
→ 项目代码
require(X)只能访问项目显式依赖 (根node_modules里的软链接)。→ 无法访问任何间接依赖!✅
🧩 Hoist 机制(兼容第三方包问题)
- 内部共享区 :
.pnpm/node_modules→ 存放被多个包依赖的公共包的软链接。 - 作用 :当包
A未声明依赖C时,内部代码require('C')可向上回溯到此区找到C。
pnpm 如何区分直接/间接依赖

从图中左侧的 package.json 内容可看出判断逻辑:
go
项目根的 package.json →
"dependencies": {
"bar": "^1.0.0" // 👉 直接依赖
}
bar/package.json →
"dependencies": {
"foo": "^1.0.0" // 👉 对项目来说是间接依赖
}
pnpm 的具体操作:
-
递归解析依赖树 读取项目根
package.json→ 找到直接依赖bar→ 再解析bar的package.json找到foo。 -
标记依赖类型
- 直接依赖 = 项目
package.json中显式声明的包(如图中的bar) - 间接依赖 = 由直接依赖引入的包(如图中的
foo,是bar的依赖)
- 直接依赖 = 项目
那这其实就可以说明,为什么项目根里的 node_modules 只存放直接依赖了。
全局安装机制(npm 与 pnpm)
1. 全局安装的存储位置:确实有「全局 node_modules」
无论是 npm 还是 pnpm,全局安装的包都会被存储在一个特定的「全局目录」中,但具体路径不同。
npm:可通过npm config get prefix查看「全局目录」路径。pnpm:可通过pnpm config get global-dir查看「全局目录」路径。
简单说:全局安装的包确实放在了全局相关目录中 ,但这个目录是工具统一管理的,和项目内的 node_modules 是完全分离的。
2. 全局安装的核心作用:注册「全局命令」,而非供项目引入
全局安装的核心目的是让包提供的「命令行工具」能在系统任何位置被直接调用,而不是让项目代码直接引入使用。
举个例子:
- 安装
npm install -g @vue/cli后,你可以在任何目录下运行vue create my-project命令(这是全局命令的作用)。 - 但如果你的项目代码里写
import VueCli from '@vue/cli'会报错 ------ 因为 Node.js 在解析依赖时,只会查找项目本地的 node_modules(和 Node 核心模块),不会去扫描全局目录。
3. 为什么项目里不能直接引入全局包?
Node.js 的模块查找机制是「局部优先」的:当你在代码中写 require('xxx') 或 import 'xxx' 时,它只会按以下顺序查找:
- 项目当前目录的
node_modules - 上级目录的
node_modules(递归直到根目录) - Node.js 内置的核心模块(如
fs、path)
全局安装的包不在这个查找链中,所以即使全局装了,项目里不本地安装就无法引入。
pnpm 的目录配置与多磁盘设计
三个关键目录配置
storeDir: 全局存储目录,存放所有包的实际文件virtualStoreDir: 项目虚拟存储目录,通常在 node_modules/.pnpm- virtualStoreDirMaxLength: 60:限制虚拟存储目录中单个依赖路径最大长度为 60 字符。若路径超长(如 73 字符的依赖路径),pnpm 会自动缩短处理
多磁盘设计原理
1. 硬链接限制
- 硬链接只能在同一文件系统(磁盘)内创建
- 跨磁盘无法使用硬链接
2. 每磁盘都会有一个单独的 store
- C盘项目使用C盘存储目录(硬链接)
- D盘项目使用D盘存储目录(硬链接)
- 实现最优性能和空间效率
全局强制统一 store 的后果
1. 跨磁盘项目降级
- 无法使用硬链接
- 自动降级为文件复制
2. 性能影响
- ✅ 不会出错,正常工作
- ❌ 安装速度变慢
- ❌ 占用更多磁盘空间
- ❌ 无法享受硬链接的高效共享