npm install 的执行过程

一、整体执行流程概览

二、详细执行阶段分解

1. 第一阶段:依赖解析 (Dependency Resolution)

这是最关键也是最复杂的一步。npm 需要确定要安装的所有包的具体版本

  • 读取 package.json

    • npm 首先读取你项目根目录下的 package.json 文件,获取 dependencies 和 devDependencies 字段中声明的所有包及其版本范围(如 ^5.0.0, ~1.2.3, latest)。
  • 检查 package-lock.json 或 npm-shrinkwrap.json

    • 如果存在 package-lock.jsonnpm 会优先使用这个文件。它的存在意味着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 如何从简单的包管理器演变成一个复杂的依赖管理引擎,致力于在功能、性能和一致性之间取得平衡。