NPM 包管理机制

解析 package.json

我们知道当运行 npm install 命令的时候,会根据 package.json 文件中的配置自动下载所需的模块,也就是配置项目所需的运行和开发环境。

package.json 文件中必填选项只有两个:nameversion,代表项目名称和版本号。

name & version

nameversion 字段是必填项,它俩一起构成一个标识符,如果作为一个 NPM 包发布的话,这个标识符被认为是唯一的。如果更改包内部的代码,就要同步更改版本号。

name 命名规范:必须小于 214 个字符,不能以 ._ 开头,不能有大写字母。因为名称最终成为 URL 的一部分因此不能包含任何非 URL 安全字符。若包名称中存在一些符号,将符号去除后不得与现有包名重复

如果你的包名与现有的包名太相近导致你不能发布这个包,那么推荐将这个包发布到你的作用域下。用户名 conard,那么作用域为 @conard,发布的包可以是@conard/react

可以使用 npm view 包名 查看一个包的信息。

version 一般的格式是 x.x.x, 并且需要遵循 SemVeropen in new window 规范。

一些不太重要的属性

  • description:一个字符串,用于编写描述信息。有助于人们在 NPM 库中搜索的时候发现你的模块。
  • keywords:一个字符串组成的数组,有助于人们在 NPM 库中搜索的时候发现你的模块。
  • bugs:用于项目问题的反馈 issue 地址或者一个邮箱。
  • homepage:项目的主页地址。
  • license:当前项目的协议,让用户知道他们有何权限来使用你的模块,以及使用该模块有哪些限制。
  • author & contributorsauthor 是具体一个人,contributors 表示一群人,他们都表示当前项目的共享者。同时每个人都是一个对象。具有 name 字段和可选的 urlemail 字段。
  • man:用来指定当前模块的 man 文档的位置。
  • directories:制定一些方法来描述模块的结构, 用于告诉用户每个目录在什么位置。
  • repository:指定一个代码存放地址,对想要为你的项目贡献代码的人有帮助。
  • bundledDependencies:指定发布的时候会被一起打包的模块。
  • optionalDependencies:如果一个依赖模块可以被使用, 同时你也希望在该模块找不到或无法获取时 NPM 继续运行,你可以把这个模块依赖放到 optionalDependencies 配置中。
  • engines:指明了该模块运行的平台,比如 Node 或者 npm 的某个版本或者浏览器。
  • os:指定你的模块只能在哪个操作系统上运行。
  • cpu:限制模块只能在某种架构的 cpu 下运行。
  • private:如果这个属性被设置为 true,npm 将拒绝发布它,这是为了防止一个私有模块被无意间发布出去。
  • publishConfig:这个配置是会在模块发布时生效,用于设置发布用到的一些值的集合。如果你不想模块被默认标记为最新的,或者默认发布到公共仓库,可以在这里配置 tag 或仓库地址。
  • preferGlobal:布尔值,表示当用户不将该模块安装为全局模块时(即不用–global 参数),要不要显示警告,表示该模块的本意就是安装为全局模块。
  • browser:指定该模板供浏览器使用的版本。

files

一个数组,内容是模块下文件名或者文件夹名,如果是文件夹名,则文件夹下所有的文件也会被包含进来(除非文件被另一些配置排除了)。

可以在模块根目录下创建一个 .npmignore 文件,写在这个文件里边的文件即便被写在 files 属性里边也会被排除在外,这个文件的写法与 .gitignore 类似。

main

入口文件加载路径,require 导入的时候就会加载这个文件。这个字段的默认值是模块根目录下面的 index.js

main 的值应该是一个相对于根目录的文件路径。

bin

指定每个内部命令对应的可执行文件的位置。如果你编写的是一个 node 工具的时候一定会用到 bin 字段。

当我们编写一个 cli 工具的时候,需要指定工具的运行命令,比如常用的 webpack 模块,他的运行命令就是 webpack

"bin": {
  "webpack": "bin/index.js",
}

当我们执行 webpack 命令的时候就会执行 bin/index.js 文件中的代码。

在模块以依赖的方式被安装,如果存在 bin 选项。在 node_modules/.bin/ 生成对应的文件,NPM 会寻找这个文件,在 node_modules/.bin/ 目录下建立符号链接。由于 node_modules/.bin/ 目录会在运行时加入系统的 PATH 变量,因此在运行 NPM 时,就可以不带路径,直接通过命令来调用这些脚本。

所有 node_modules/.bin/ 目录下的命令,都可以用 npm run [命令] 的格式运行。在命令行下,键入 npm run,然后按 tab 键,就会显示所有可以使用的命令。

scripts

指定了运行脚本命令的 npm 命令行缩写,比如 start 指定了运行 npm run start 时,所要执行的命令。

scripts 可以直接使用 node_modules 中安装的模块,这区别于直接运行需要使用 npx 命令。

"scripts": {
  "build": "webpack"
}
// npm run build
// npx webpack

config

config 字段用于添加命令行的环境变量。

"config" : { "port" : "8080" }

然后,在 server.js 脚本就可以引用 config 字段的值。

console.log(process.env.npm_package_config_port) // 8080

用户可以通过 npm config set 来修改。

dependencies & devDependencies

dependencies 指定项目运行所依赖的模块,devDependencies 指定项目开发所依赖的模块。

它们的值都是一个对象。该对象的各个成员,分别由模块名和对应的版本要求组成,表示依赖的模块及其版本范围。

当安装依赖的时候使用 --save 参数表示将该模块写入 dependencies 属性,--save-dev 表示将该模块写入 devDependencies 属性。

版本说明:

  • ~5.38.1 表示安装 5.38.x 的最新版本(不低于 5.38.1),但是不安装 5.39.x
  • ˆ5.38.1 表示安装 5.x.x 的最新版本(不低于 5.38.1),但是不安装 6.x.x
  • latest 表示安装最新版本。

peerDependencies

当我们开发一个模块的时候,如果当前模块与所依赖的模块同时依赖一个第三方模块,并且依赖的是两个不兼容的版本时就会出现问题。

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

因此,需要一种机制,在模板安装的时候提醒用户,如果 A 和 B 一起安装,那么 B 必须是 2.0 模块

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

npm install 做了什么?

早期的 npm

早期的 npm 安装依赖的方式非常粗暴,递归通过 package.json 的结构以及子依赖包的 package.json 结构将依赖安装到它们各自的 node_modules 文件夹中,直到这个依赖包不再依赖其它包。

但这样有个问题,如果你引用了许多高层级依赖的包(这里指的是那些依赖了很多包,这些包也同时依赖了很多包的情况),这样一层一层会变得非常复杂,需要安装很多依赖,如果许多包同时引用了相同的依赖,那么 npm 就要重复安装很多次这些依赖。

在 Windows 系统中,文件路径最大长度为 260 个字符,嵌套层级过深可能导致不可预知的问题。

扁平化依赖结构

npm 3.x 版本将早期的嵌套结构改成了扁平结构。

安装模块时,不管是直接依赖还是依赖的依赖都优先安装到 node_modules 根目录

当依赖中包含版本不同的相同模块时,npm 会判断已经安装的模块版本是否符合新版本的模块范围,符合就跳过,不符合就安装该模块。

比如项目本身依赖于 1.0 版本的 B,现在要安装一个依赖 A,而 依赖 A 又依赖于 2.0 版本的 B,假设 B2.0 不兼容 B1.0 版本,那么最后 node_modules 的结构会是这样:

node_modules
  ├─B@1.0
  └─A@1.0
    └─node_modules
      └─B@2.0

这样做有一个问题:

如果我的项目又引入了 C1.0,而 C 也依赖于 B2.0 呢?

node_modules 的结构会是这样:

node_modules
  ├─B@1.0
  ├─A@1.0
  ├ └─node_modules
  ├   └─B@2.0
  └─C@1.0
    └─node_modules
      └─B@2.0

这样看来,似乎 npm 3.x 仍然没有解决包冗余的问题。

假如项目同时依赖了 1.0 版本的 A 和 1.0 版本的 C,他们又分别依赖于 1.0 版本的 B 和 2.0 版本的 B。那么这个时候 package.json 依赖的顺序就决定了 node_modules 的结构。

"dependencies": {
  "A": "1.0.0",
  "C": "1.0.0"
}

对应结构:

node_modules
  ├─B@1.0
  ├─A@1.0
  └─C@1.0
    └─node_modules
      └─B@2.0
"dependencies": {
  "C": "1.0.0",
  "A": "1.0.0"
}

对应结构:

node_modules
  ├─B@2.0
  ├─C@1.0
  └─A@1.0
    └─node_modules
      └─B@1.0

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

我们可以使用 npm dedupe 去除冗余模块。把所有二级的依赖模块 B@1.0 重定向到一级目录下(前提是所依赖的 B@1.0 模块升级到了 B@2.0)。

package-lock.json 文件

为了解决 npm install 的不确定性问题,在 npm 5.x 版本新增了 package-lock.json 文件,而安装方式还沿用了 npm 3.x 的扁平化的方式。

package-lock.json 的作用是锁定依赖结构,即只要你目录下有 package-lock.json 文件,那么你每次执行 npm install 后生成的 node_modules 目录结构一定是完全相同的。项目中使用了 package-lock.json 可以显著加速依赖安装时间package-lock.json 中已经缓存了每个包的具体版本和下载链接,不需要再去远程仓库进行查询,然后直接进入文件完整性校验环节,减少了大量网络请求。

npm 缓存

在执行 npm installnpm update 命令下载依赖后,除了将依赖包安装在 node_modules 目录下外,还会在本地的缓存目录缓存一份。

在命令行使用 npm config get cache 可以查询到缓存的目录。

.npmrc

.npmrc 文件,就是 npm 的配置文件所在位置。当然,寻找这个文件的目的,多数是为了修改 .npmrc 文件内容。但 npm 提供了方便快捷的修改方式,不知道这个文件的位置,其实也是可以修改的。在命令行输入 npm config edit

整体流程

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

参考文章