npm 是如何安装模块以及依赖版本控制的?

npm 是当下前端开发中必不可缺的工具,能够方便我们管理项目中的各种依赖模块。按官方给出的描述;

npm 是世界上最大的软件注册中心。来自各大洲的开源开发人员使用 npm 来共享和借用软件包,许多组织也使用 npm 来管理私人开发。

或许像npm installnpm uninstallnpm upgrade 等命令我们经常会用到,但到底它们是如何去安装、卸载或者去更新一个包的呢。此外,package.jsonpackage-loock.jsonyarn-lock等这些用于版本控制的文件到底是如何控制的呢?本文就带着这些疑问一一探讨。

一、相关文件简介

在探索上述问题之前,我们先来了解与之相关的一些文件。已经了解的,这part就可以跳过了。

1.1 package.json

package.json文件是基Node.js项目必不可少的配置文件,它是一个存放在项目根目录的普通JSON文件

下面先简单介绍一下pacakge.json中的常用配置。

  • name:包的名称

name需要满足以下条件:

  1. 名称必须小于或等于 214 个字符。这包括范围软件包的范围。
  2. 作用域软件包的名称可以以点或下划线开头。如果没有作用域,则不允许这样做。
  3. 新软件包名称中不能有大写字母
  4. 名称最终会成为 URL、命令行参数和文件夹名称的一部分。因此,名称中不能包含任何非 URL 安全字符
  • version: 版本号

注意:如果您要发布包,则 nameversion 是必需的,它们一起形成一个标识符

  • description :包的描述。npm search 会搜索该字段。
  • keywords :关键词。npm search 会搜索该字段。

descriptionkeywords 字段都是帮助别人发现您的包。

  • files:描述当您的包作为依赖项安装时要包含的条目。
  • main:main 字段是模块 ID,它是程序的主入口。(如果未设置,则默认为包根文件夹中的index.js)
  • bin:指定包的可执行文件。

它是命令名到本地文件名的映射。当此软件包全局安装时,该文件将链接到全局 bins 目录内,或者将创建一个 .cmd(Windows 命令文件)来执行 bin 字段中的指定文件,因此可以按namename.cmd运行。当此包作为另一个包中的依赖项安装时,该文件将被链接到该包可以直接通过 npm exec 或通过 npm run-script 调用其他脚本时通过名称来访问该文件

注意: bin 中引用的文件以 #!/usr/bin/env node开头,否则脚本将在没有node可执行文件的情况下启动

  • man:用于指定关于该软件包的官方文档手册(manual)的路径。它通常是指向一个或多个手册页的本地文件系统路径或者URL。
  • repositoryrepository字段是用于指定代码存放的位置。它是一个对象,包含typeurl两个属性。其中type属性是版本控制系统的类型,常见的有gitsvn等;url属性是代码库的地址。

注意:可以使用npm repo命令在浏览器中打开该字段指定的仓库。

  • scripts:一个字典,其中包含在包生命周期中不同时间运行的脚本命令。键是生命周期事件,值是在该点运行的命令。
  • config:用于指定运行时配置的选项。它是一个对象,可以包含多个配置选项,例如端口号、数据库连接等。这些配置选项可以在程序运行时从环境变量中获取,也可以在启动程序时通过命令行参数进行设置。
  • engine:用于指定项目所运行的 Node.js 版本,或指定安装该包的 npm 版本。

注意:除非用户设置了 engine-strict 配置标志,否则此字段仅供参考,并且仅在您的软件包作为依赖项安装时才会产生警告

  • os:指定模块将在哪些操作系统上运行
  • cpu:指定 cpu 架构
  • private :如果指定为true,则该包不能发布。
  • publishConfig:发布到npm仓库的相关设置
  • workspaces:指定工作区中包含的各个工作区路径

下面介绍和依赖项相关的一些配置,也是本文重点关注的内容:

dependencies

指定了项目运行所依赖的模块。这些模块通常是指外部依赖的库或框架,用于支持项目的核心功能。

npm install 安装一个包时,默认会添加到dependencies

dependencies字段是一个对象,其中的每个成员都由模块名 和对应的版本组成。这些版本要求可以根据不同的模块和依赖情况进行灵活配置,例如指定具体的版本号、版本范围、或使用语义化版本号等。

有关指定版本范围的更多详细信息可以参考 semver 。下面列出一些常见的例子:

  • version等于指定的版本
  • >version大于指定的版本
  • >=version大于等于指定的版本
  • <version小于指定的版本
  • <=version小于等于指定的版本
  • ~version约等于指定的版本。
  • ^version 兼容指定的版本
  • 1.2.x 1.2.0, 1.2.1等。
  • *"": 匹配任意版本
  • version1 - version2version1version2版本之间,即 >=version1 <=version2.
  • range1 || range2range1range2
  • http://...: 指定 tarball URL,该 tarball 将在安装时下载并本地安装到您的软件包中。
  • git...:指定git url。
ruby 复制代码
<protocol>://[<user>[:<password>]@]<hostname>[:<port>][:][/]<path>[#<commit-ish> | #semver:<semver>]

git+ssh://git@github.com:npm/cli.git#v1.0.27
  • user/repo : git 仓库
json 复制代码
"express": "expressjs/express",
  • tag:标记并发布了特定版本,可参考 npm dist-tag
  • path/path/path:本地路径,即本地开发的包。

devDependencies:

指定了项目开发时所需要的包。这些包通常是指用于支持开发过程中所需的工具、库或框架。

例如:我们常用的webpack打包工具,或者一些以@types开头的ts类型文件,这些包在生产环境中并不会使用到,所以 devDependencies中指定的依赖项不会自动安装。

可使用npm install pkg -D/--save-dev命令将包添加到 devDependencies中。

peerDependencies:

有时,你的项目和所依赖的模块,都会同时依赖另一个模块,但是所依赖的版本不一样。比如,你的项目依赖A模块和B模块的1.0版,而A模块本身又依赖B模块的2.0版。

大多数情况下,这不构成问题,B模块的两个版本可以并存,同时运行。但是,有一种情况,会出现问题,就是这种依赖关系将暴露给用户。

最典型的场景就是插件,比如A模块是B模块的插件。用户安装的B模块是1.0版本,但是A插件只能和2.0版本的B模块一起使用。这时,用户要是将1.0版本的B的实例传给A,就会出现问题。因此,需要一种机制,在模板安装的时候提醒用户,如果A和B一起安装,那么B必须是2.0模块。

peerDependencies字段,就是用来供插件指定其所需要的主工具的版本。

在 npm 版本 3 到 6 中,peerDependency 不会自动安装,如果在树中发现对等依赖项的无效版本,则会发出警告。从 npm v7 开始,默认安装peerDependency.

peerDependenciesMeta:当用户安装包时,如果尚未安装在 peerDependency 中指定的包,npm 将发出警告。 peerDependencyMeta 字段用于向 npm 提供有关如何使用对等依赖项的更多信息。

bundleDependencies

指定在发布包时一起打包的模块。

bundleDependency可以定义为布尔值。 true 值将捆绑所有依赖项, false 值将不捆绑任何依赖项

optionalBundleDependency

指定可选依赖项

如果在安装过程中遇到可选依赖项的安装错误,npm会继续初始化,不会中断安装过程。可选依赖项通常用于处理缺乏依赖的情况,以使程序能够继续运行。

需要注意的是,由于可选依赖项可能未成功安装,因此需要做好异常处理,以避免在获取依赖项时出现错误。

运行 npm install --omit=optional 将阻止安装这些依赖项。

overrides

用于重写项目依赖的依赖及其依赖树下某个依赖的版本号,进行包的替换。

以上就是对package.json文件的简单介绍,详细介绍可参考官方文档或阮一峰的# package.json文件

1.2 pacakge-lock.json

由上述我们可知,package.json文件中的dependencies已经记录了项目的所有依赖项,但是其中记录的是包名版本范围的映射。

注意: dependencies中指定的是版本范围,而不是确切的版本。

所以,这会导致不确定性 。例如,您今天在某个项目中运行npm install和您3个月后再去运行npm install,生成的node_modules树可能不一样。

此外,在一个多人协同开发的项目中,由于每个开发人员的开发环境或工具有所区别,可能会导致不同开发人员在同一个项目中npm install,安装的依赖不同。再者,可能会导致break changes。

针对该问题,npm5 推出了 package-lock.json文件,使用语义化版本规范描述对于其他包的依赖。package.json 只描述了第一层依赖,也就是我们项目所直接使用的第三方模块的依赖。而 package-lock.json 是完整描述了整个项目的依赖树,包含了所有包及其确切版本号信息
该文件的存在确保了我们的项目在任何环境下都能保持一致的全部依赖

总结来说:
package-lock.json文件是一个锁定文件,用于记录项目的依赖项及其确切版本号。

package.json文件或node_modules树发生改变时,会自动重新生成package-lock.json文件。它描述了生成的确切树,以便后续安装能够生成相同的树,而不管中间依赖项更新如何。

package-lock.json文件通常会被提交到源代码仓库中,用于以下目的:

  • 描述依赖关系树的单一表示,以便保证团队成员、部署和持续集成安装完全相同的依赖关系
  • 为用户提供一种工具,记录 node_modules 的状态,而无需提交目录本身
  • 通过可读的源代码控制差异,提高树更改的可见性
  • 通过允许 npm 跳过先前安装的软件包的重复元数据解析来优化安装过程。
  • 从 npm v7 开始,lock文件包含足够的信息来获取包树的完整刻画,减少了读取 package.json文件的需要,并显著提高了性能。

文件格式

了解了package-lock.json的作用之后,我们再来看看该文件中具体记录了哪些内容。

  • name 名称。将与 package.json 中的内容匹配。
  • version 版本。与 package.json 中的内容匹配。
  • lockfileVersion:一个整数版本,从 1 开始,该文档的版本号在生成此 package-lock.json 时开始使用。
    • npm v5 之前未提供版本。
    • 1:npm v5 和 v6 使用的锁定文件版本。
    • 2:npm v7 和 v8 使用的锁文件版本。向后兼容 v1 锁定文件。
    • 3:npm v9及以上版本使用的lockfile版本。向后兼容 npm v7。
pacakges

一个将包位置 映射到包含该包信息的对象 的对象。根项目通常使用键""列出,所有其他包都使用根项目文件夹的相对路径 列出。

其包含以下字段:

  • version:package.json 中找到的版本
  • resolved:实际解析包的位置。对于从注册表获取的包,这将是 tarball 的 url。对于 git 依赖项,这将是带有提交 sha 的完整 git url。在链接依赖性的情况下,这将是链接目标的位置。其中 registry.npmjs.org 指的是"当前配置的注册表"。
  • integrity:在此位置解压的工件的 sha512 或 sha1 标准子资源完整性字符串。主要用于保证包的完整性。
  • link:指示这是符号链接的标志。如果存在,则不指定其他字段,因为链接目标也将包含在锁定文件中。
  • devoptionaldevOptional
    • 如果包严格属于 devDependency 树的一部分,则 dev 将为 true。
    • 如果它严格属于可选依赖关系树的一部分,则将设置optional
    • 如果它既是开发依赖项又是非开发依赖项的可选依赖项,则将设置 devOptional。 (开发依赖项的可选依赖项将同时具有 dev 和可选集。)
  • inBundle:指示该包是捆绑依赖项的标志
  • hasInstallScript:指示软件包具有preinstall, install, 或 postinstall脚本的标志。
  • hasShrinkwrap:指示包具有 npm-shrinkwrap.json 文件的标志。
  • bin, license, engines, dependencies, optionalDependencies: package.json 中的字段
dependencies

包名称到依赖项对象的映射。其包含如下字段:

  • version:根据包的性质而变化的说明符,并可用于获取它的新副本。
    • bundled dependencies: 无论来源如何,这都是一个纯粹用于提供信息的版本号.
    • registry sources : 这是一个版本号. (1.2.3)
    • git sources : 这是一个已完成提交的 git 说明符(git+https://example.com/foo/bar#115311855adb0789a0466714ed48a1499ffea97e)
    • http tarball sources : 这是一个 tarball 的 URL (https://example.com/example-1.3.0.tgz)
    • local tarball sources : 这是一个 tarball 的文件 URL. (file:///opt/storage/example-1.3.0.tgz)
    • local link sources : 这是链接的文件 URL. (file:libs/our-module)
  • integrity:同上。
  • resolved:同上
  • bundled:如果为 true,则这是捆绑的依赖项,将由父模块安装。安装时,该模块将在提取阶段从父模块中提取,而不是作为单独的依赖项安装
  • dev:如果为 true,则此依赖项要么是顶级模块的仅开发依赖项,要么是其中之一的传递依赖项。对于既是顶层开发依赖项又是顶层非开发依赖项的传递依赖项的依赖项来说,这是错误的。
  • optional:如果为 true,则此依赖项要么是仅顶级模块的可选依赖项,要么是其中之一的传递依赖项。对于既是顶级可选依赖项又是顶级非可选依赖项的传递依赖项的依赖项,这是错误的。
  • requires:这是模块名称到版本的映射。子依赖的 package.jsondependencies的依赖项相同
  • dependencies:此依赖项的依赖项,与顶层完全相同。

1.3 npm-shrinkwrap.json

npm-shrinkwrap.json 是由 npm shrinkwrap 创建的文件。它与 package-lock.json 具有相同的格式,并在项目的根目录中执行类似的功能。

不同的是,package-lock.json 无法发布,如果在根项目以外的任何地方找到都会被忽略。

相反,npm-shrinkwrap.json 允许发布,并从遇到的点定义依赖关系树。不建议这样做,除非部署 CLI 工具或以其他方式使用发布过程来生成生产包。

如果 package-lock.json 和 npm-shrinkwrap.json 都存在于项目的根目录中,则 npm-shrinkwrap.json 将优先,而 package-lock.json 将被忽略。

二、npm install

通常,当我们从仓库中 clone 一个新的项目后,首先会执行npm install命令去安装该项目的所有依赖,然后再去运行该项目。那么npm install又是如何去安装项目的所有依赖的呢?

这里不讨论 npm install 如何使用,而是了解一下执行npm install命令后是如何去处理项目中的各种依赖关系并安装的。

2.1 依赖关系处理

(1)递归

早期版本的 npm 处理依赖的方式简单粗暴,以递归的形式,严格按照 package.json 结构以及子依赖包的 package.json 结构将依赖安装到他们各自的 node_modules 中。

例如:我的项目my-app依赖于A模块B模块,其中B模块自身又依赖于 C模块D模块

css 复制代码
{
    "name": "my-app",
    "dependencies": {
        "A": "^1.0.1",
        "B": "^1.0.1"
    }
}
json 复制代码
{
    "name": "B",
    "dependencies": {
        "C": "^1.0.1",
        "D": "^1.0.1"
    }
}

执行 npm install 就会形成如下嵌套结构。

这种方式的优点 在于 node_modules 的结构和 package.json 结构一一对应,层级结构明显,并且保证了每次安装目录结构都是相同的。

但是会存在下面一些问题

  • 不同层级的依赖中,可能引用了相同的模块,造成了大量冗余
  • 如果依赖的模块很多,会导致 node_modules 树太过庞大,且嵌套层级过深。此外,在 Windows 系统中,文件路径最大长度为260个字符,嵌套层级过深可能导致不可预知的问题

(2) 扁平化

为了解决上述问题,npm3.x 版本进行了一次较大的更新,引入了扁平化管理的方法。

扁平化管理的基本思路就是:

  • 首先,安装模块时,不管是直接依赖还是子依赖,优先 安装在项目的node_modules目录下。
  • 当安装到相同模块 时,判断已安装的模块版本是否符合新模块的版本范围,如果符合则跳过,不符合则在当前模块的 node_modules 下安装该模块。

假设 my-app 依赖于 C@1.0.2,而B依赖于C@1.0.2

css 复制代码
{
    "name": "my-app",
    "dependencies": {
        "A": "^1.0.1",
        "B": "^1.0.1"
        "C": "1.0.1"
    }
}
json 复制代码
{
    "name": "B",
    "dependencies": {
        "C": "1.0.2",
        "D": "^1.0.1"
    }
}

此时生成的树结构如下:

这种方式虽然解决了深层嵌套问题,但是并未解决模块冗余问题,且会导致依赖结构不确定性。

因为npm install会按package.jsondependencies的顺序解析安装包。如果顺序不同会导致node_modules树结构不同。例如:A模块依赖于 C@1.0.1,而B模块依赖于 C@1.0.2,两种不同的顺序会导致不同的结构。

为了让开发者在安全的前提下使用最新的依赖包,我们在 package.json 通常只会锁定大版本,这意味着在某些依赖包小版本更新后,同样可能造成依赖结构的改动,依赖结构的不确定性可能会给程序带来不可预知的问题。

(3)lock 文件

为了解决 npm install 的不确定性问题,npm 5.x 新增了 package-lock.json 文件。(详细介绍可见1.2)

package-lock.json 的作用是锁定依赖结构,即只要你目录下有 package-lock.json 文件,那么你每次执行 npm install 后生成的 node_modules 目录结构一定是完全相同的。

此外,package-lock.json 文件指定了每个包的具体版本(version)和下载地址(resolved),不需要访问远程仓库进行查询,减少了大量网络请求。

2.2 缓存

执行 npm installnpm update 命令下载依赖后,不仅会将依赖安装到 node_modules目录下,还会缓存到本地的缓存目录。

这个缓存目录,在 Linux 或 Mac 默认是用户主目录下的.npm目录,在 Windows 默认是%AppData%/npm-cache

通过命令npm config get cache,可以查看这个目录的具体位置。

在这个目录下又存在两个目录:content-v2index-v5

  • content-v2 目录用于存储 tar包的缓存
  • index-v5目录用于存储tar包的 hash

npm 在执行安装时,可以根据 package-lock.json 中存储的 integrity、version、name 生成一个唯一的 key 对应到 index-v5 目录下的缓存记录,从而找到 tar包的 hash,然后根据 hash 再去找缓存的 tar包直接使用。

以上的缓存策略是从 npm v5 版本开始的,在 npm v5 版本之前,每个缓存的模块在 ~/.npm 文件夹中以模块名的形式直接存储,储存结构是{cache}/{name}/{version}。

对于缓存,我们可以使用npm cache命令添加、列出或清理 npm 缓存文件夹:

  • add:将指定的包添加到本地缓存中。该命令主要供 npm 内部使用,但它可以提供一种将数据显式添加到本地安装缓存的方法。
  • clean:删除缓存文件夹中的所有数据。请注意,这通常是不必要的,因为 npm 的缓存具有自我修复功能并且能够抵抗数据损坏问题。
  • verify:验证缓存文件夹的内容,垃圾收集任何不需要的数据,并验证缓存索引和所有缓存数据的完整性。

npm cache 严格来说只是一种缓存:不应将其作为软件包数据的持久可靠数据存储来依赖。npm 不保证先前缓存的数据稍后可用,并会自动删除损坏的内容。缓存的主要保证是,如果它返回数据,该数据将与插入的数据完全相同。

此外,npm install 也提供了以下三种模式选择是否使用缓存数据:

  • --prefer-offline:优先使用缓存数据,如果没有匹配的缓存数据,则从远程仓库下载。
  • --prefer-online:优先使用网络数据,如果网络数据请求失败,再去请求缓存数据,这种模式可以及时获取最新的模块。
  • --offline:不请求网络,直接使用缓存数据,一旦缓存数据不存在,则安装失败。

2.3 npm install 完整流程

有关 npm install 的完整流程,这里借鉴大佬文章中的图。

  1. 首先检查 .npmrc 文件:确定 registry 等参数。

优先级为:

项目级的 .npmrc 文件 > 用户级的 .npmrc 文件> 全局级的 .npmrc 文件 > npm 内置的 .npmrc 文件

  1. 检查项目中有无 lock 文件。
    • lock 文件
      • npm 远程仓库获取包信息
      • 根据 package.json 构建依赖树,构建过程:
        • 构建依赖树时,不管其是直接依赖还是子依赖的依赖,优先将其放置在 node_modules 根目录。
        • 当遇到相同模块时,判断已放置在依赖树的模块版本是否符合新模块的版本范围,如果符合则跳过,不符合则在当前模块的 node_modules 下放置该模块。
        • 注意这一步只是确定逻辑上的依赖树,并非真正的安装,后面会根据这个依赖结构去下载或拿到缓存中的依赖包
      • 在缓存中依次查找依赖树中的每个包
        • 不存在缓存 :从 npm 远程仓库下载包 -> 校验包的完整性
          • 校验不通过:重新下载
          • 校验通过:将下载的包按照依赖结构解压到 node_modules并缓存到 npm 缓存目录
        • 存在缓存 :将缓存按照依赖结构解压到 node_modules
      • 生成 lock 文件
    • lock 文件
      • 检查 package.json 中的依赖版本是否和 package-lock.json 中的依赖有冲突。
      • 如果没有冲突,直接跳过获取包信息、构建依赖树过程,开始在缓存中查找包信息,后续过程相同

此外,如果您想查看某个包的详细安装流程,可以执行npm install package --timing=true --loglevel=verbose 命令。

三、npm、yarn、pnpm的区别

npm (Node Package Manager)

  • 默认工具:随同 Node.js 一起安装。
  • 命令行接口:较为直接,提供了广泛的命令集,如安装、卸载、更新等。
  • 性能:在过去可能存在一些性能方面的问题,但近期版本已经有所改进。
  • 安装包 :默认会将依赖安装到 node_modules 文件夹中。
  • 锁定版本 :通过 package-lock.json 文件锁定依赖的版本。

Yarn

  • 由 Facebook 开发:旨在解决 npm 的一些性能和安全问题。
  • 性能:通常比 npm 更快,特别是在下载依赖时。
  • 安装包 :同样将依赖安装到 node_modules 文件夹中。
  • 锁定版本 :使用 yarn.lock 文件来锁定依赖版本。

pnpm

  • 独特的依赖管理:相对于 npm 和 Yarn,pnpm 采用一种不同的方式来管理依赖,通过在全局使用一个共享的存储来节省磁盘空间。
  • 硬链接:通过硬链接和符号链接来减少重复下载,从而节省空间。
  • 性能:由于采用了共享存储和硬链接的方式,pnpm 在某些情况下可以更快地安装依赖。
  • 锁定版本 :使用 pnpm-lock.yaml 来锁定依赖版本。

参考:

[1] npm install 原理分析

相关推荐
滚雪球~43 分钟前
npm error code ETIMEDOUT
前端·npm·node.js
沙漏无语1 小时前
npm : 无法加载文件 D:\Nodejs\node_global\npm.ps1,因为在此系统上禁止运行脚本
前端·npm·node.js
supermapsupport1 小时前
iClient3D for Cesium在Vue中快速实现场景卷帘
前端·vue.js·3d·cesium·supermap
brrdg_sefg1 小时前
WEB 漏洞 - 文件包含漏洞深度解析
前端·网络·安全
胡西风_foxww1 小时前
【es6复习笔记】rest参数(7)
前端·笔记·es6·参数·rest
m0_748254881 小时前
vue+elementui实现下拉表格多选+搜索+分页+回显+全选2.0
前端·vue.js·elementui
星就前端叭2 小时前
【开源】一款基于Vue3 + WebRTC + Node + SRS + FFmpeg搭建的直播间项目
前端·后端·开源·webrtc
m0_748234522 小时前
前端Vue3字体优化三部曲(webFont、font-spider、spa-font-spider-webpack-plugin)
前端·webpack·node.js
Web阿成2 小时前
3.学习webpack配置 尝试打包ts文件
前端·学习·webpack·typescript
jwensh3 小时前
【Jenkins】Declarative和Scripted两种脚本模式有什么具体的区别
运维·前端·jenkins