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
/require
C
:-
当你在项目根目录的源代码(例如
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
/require
C
:- 如果
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. 性能影响
- ✅ 不会出错,正常工作
- ❌ 安装速度变慢
- ❌ 占用更多磁盘空间
- ❌ 无法享受硬链接的高效共享