一、整体执行流程概览

二、详细执行阶段分解
1. 第一阶段:依赖解析 (Dependency Resolution)
这是最关键也是最复杂的一步。npm
需要确定要安装的所有包的具体版本
。
-
读取 package.json
:- npm 首先读取你项目根目录下的
package.json
文件,获取dependencies 和 devDependencies
字段中声明的所有包及其版本范围(如 ^5.0.0, ~1.2.3, latest)。
- npm 首先读取你项目根目录下的
-
检查
package-lock.json 或 npm-shrinkwrap.json
:-
如果存在
package-lock.json
:npm 会优先使用这个文件
。它的存在意味着npm会尝试进行确定性安装。锁文件精确地描述了上一轮安装时生成的依赖树,包括所有直接依赖和间接依赖(的依赖)的确切版本和下载地址(resolved 字段)。npm 会以此文件为蓝图来安装,确保每次安装的结果完全一致。 -
如果
不存在 package-lock.json
:npm 会进入"浮动安装"
模式。它会遍历依赖树,对于 package.json 中的每一个版本范围
,向 npm registry 查询符合该范围的最新版本
。例如,^5.0.0 可能会被解析为 5.5.1。
-
-
构建完整的依赖树:
-
npm
必须递归地处理所有依赖
。比如,你的项目依赖 A@^1.0.0,而 A@1.2.0 又依赖 B@^2.0.0。npm 需要为整个依赖关系图确定一个可用的版本集合,这个过程称为依赖解析。 -
如果不同包依赖了同一个包的不同版本(即版本冲突),npm 需要决定如何安置这些版本。在 npm v3 之后,它采用了一种
"扁平化"(Deduping)
的策略来解决这个问题。
-
2. 第二阶段:包下载 (Package Downloading)
确定了所有要安装的包及其具体版本后,就开始下载。
-
检查缓存 (Cache):
-
npm
有一个全局的缓存目录
(可以通过 npm config get cache 查看位置)。 -
在下载任何包之前,npm 会先检查这个包的确切版本
(如 lodash@4.17.21)是否已经存在于本地缓存中。 -
如果缓存命中
:npm 会直接从这个缓存中解压包文件到项目的 node_modules 目录
,这比从网络下载要快得多。 -
如果
缓存未命中
:npm 会从配置的 registry
(默认是 https://registry.npmjs.org)下载该包的压缩包(tarball,格式为 .tgz)。
-
-
下载包:
-
包被下载后,会首先
被存入缓存目录
,以便后续安装使用。 -
然后,压缩包会被解压到项目下的
node_modules
目录中。
-
3. 第三阶段:依赖树构建与链接 (Tree Building & Linking)
这是将下载的包放置到 node_modules 目录的过程,经历了从"嵌套"到"扁平化"的演变。
-
历史方式(npm v2):嵌套结构
早期版本中,
依赖关系被严格嵌套地安装在各自包的 node_modules 文件夹中
。-
优点:
结构非常清晰,每个包只能看到自己直接依赖的包
。 -
缺点:
路径极深,大量重复安装
(如果多个包依赖同一个库的不同版本,每个都会安装一次),浪费磁盘空间且可能超出系统路径长度限制。
-
-
现代方式(npm v3+):扁平化结构
从 npm v3 开始,安装算法变得复杂且智能。它尝试将所有依赖(包括间接依赖)尽可能扁平化地安装在顶层的 node_modules 中。
-
工作流程:
-
npm 会
首先将所有的直接依赖(dependencies)安装在顶层的 node_modules 中
。 -
然后处理这些
直接依赖的依赖(
间接依赖)。它会尝试将这些间接依赖也安装在顶层
。 -
如果版本冲突(例如,
两个直接依赖需要同一个包的两个不兼容版本
,如 lodash@4.17.21 和 lodash@3.10.0),npm 的解决方法是:-
将
其中一个版本
(通常是首先被声明的那个依赖所需的版本)安装在顶层 node_modules
。 -
将另一个冲突版本嵌套地安装在其依赖者的 node_modules 目录下。
例如:
your-project/node_modules/lodash (v4.17.21) 和 your-project/node_modules/some-package/node_modules/lodash (v3.10.0)。
-
-
-
创建符号链接(对于本地依赖和全局链接):
- 如果你的
依赖是 file:../some-local-package,npm 不会复制文件
,而是会创建一个符号链接
(symlink),指向本地的那个包目录
。这在 Monorepo 开发中非常有用
- 如果你的
-
4. 第四阶段:安装准备与执行脚本 (Installation & Lifecycle Scripts)
-
写入 node_modules:
- 根据构建好的依赖树结构,将所有包的文件解压/链接到正确的 node_modules 位置。
-
更新 package-lock.json:
-
如果
安装过程中依赖树有任何变化(
例如,因为不存在锁文件,或者用了 npm install some-package 更新了依赖),npm 都会自动生成或更新 package-lock.json 文件
,记录下当前确定的、精确的依赖树状态。 -
执行生命周期脚本 (Lifecycle Scripts):
这是安装的最后阶段。npm 会按顺序执行包中定义的以下脚本(如果存在的话):
-
preinstall: 在包安装之前运行。
-
install: 在包安装之后运行。
-
postinstall: 在包安装之后运行,这是最常见的一个钩子,常用于编译原生扩展(如 node-gyp)或进行一些初始化操作。
-
-
-
重要提示:
在执行这些脚本时,npm 会将包的 bin 字段指定的可执行文件链接到项目的 node_modules/.bin/ 目录下
。这就是为什么你可以在 package.json 的 scripts 中直接使用这些命令
(如 "build": "webpack ..."),因为 npm 在执行脚本时会自动将 node_modules/.bin/ 加入 PATH 环境变量
。
总结与最佳实践
-
package-lock.json 至关重要
:务必将其提交到版本控制系统(如 Git)中。它确保了所有团队成员和 CI/CD 环境安装完全一致的依赖树,避免了"在我机器上是好的"这类问题。 -
理解扁平化的副作用
:扁平化结构可能导致"幽灵依赖
(Phantom Dependencies)"问题------即你的代码可以引用一个你没有在 package.json 中直接声明的包
(因为它被扁平化安装在了顶层)。这存在潜在风险,因为一旦依赖关系改变,这个包可能不再被安装在顶层
,你的代码就会报错。使用 package-lock.json 可以锁定这种结构。 -
使用 npm ci:在 CI/CD 或生产环境构建时,
使用 npm ci 代替 npm install
。它的速度更快、要求更严格
:它会根据 package-lock.json 精确安装,如果锁文件与 package.json 不匹配,直接报错并退出,而不是尝试去调整锁文件。这保证了构建的绝对一致性
。
整个过程体现了 npm 如何从简单的包管理器演变成一个复杂的依赖管理引擎,致力于在功能、性能和一致性之间取得平衡。