林秀栋的技术博客

npm的包管理机制

文章来源1 文章来源2

package.json

模块的描述文件

必备属性

package.json 中有非常多的属性,其中必须填写的只有两个:name 和 version ,这两个属性组成一个 npm 模块的唯一标识。

npm包命名规则

name 即模块名称,其命名时需要遵循官方的一些规范和建议:

例如:由于react-native已经存在,react.native、reactnative都不可以再创建。

例如:用户名 conard,那么作用域为 @conard,发布的包可以是@conard/react。

查看包是否被占用

name 是一个包的唯一标识,不得和其他包名重复,我们可以执行 npm view packageName 查看包是否被占用,并可以查看它的一些基本信息

若包名称从未被使用过,则会抛出 404 错误:

描述信息

{
  "name": "",
  "version": "",
  // 添加模块的的描述信息,方便别人了解你的模块。
  "description": "it is description",
  // 给你的模块添加关键字。
  // 当然,他们的还有一个非常重要的作用,就是利于模块检索。当你使用 npm search 检索模块时,会到description  keywords 中进行匹配。写好 description  keywords 有利于你的模块获得更多更精准的曝光:
  "keywords": [
    "ant",
    "design",
    "react",
    "ui"
  ],
  // 主要作者
  "author": {
    "name" : "author",
    "email" : "xx@163.com",
    "url" : "https://github.com/xx"
  },
  // 贡献者,可多个
  "contributors": [
    {
      "name" : "k1",
      "email" : "xxxx@163.com",
      "url" : "https://github.com/xxx"
    }
  ],
  // 指定该模块的主页。
  "homepage": "http://ant.design/",
  // 指定一个地址或者一个邮箱,对你的模块存在疑问的人可以到这里提出问题。
  "bugs": {
    "url": "https://github.com/ant-design/ant-design/issues"
  },
  // 指定模块的代码仓库
  "repository": {
    "type": "git",
    "url": "https://github.com/ant-design/ant-design"
  },
}

依赖配置

"dependencies": {
  "antd": "ant-design/ant-design#4.0.0-alpha.8",
  "axios": "^1.2.0",
  "test-js": "file:../test",
  "test2-js": "http://cdn.com/test2-js.tar.gz",
  "core-js": "^1.1.5",
}

依赖配置遵循下面几种配置规则:

VERSION是一个遵循SemVer规范的版本号配置,npm install 时将到npm服务器下载符合指定版本范围的包。

DWONLOAD_URL 是一个可下载的tarball压缩包地址,模块安装时会将这个.tar下载并安装到本地。

LOCAL_PATH 是一个本地的依赖包路径,例如 file:../pacakges/pkgName。适用于你在本地测试一个npm包,不应该将这种方法应用于线上。

GITHUB_URL 即 github 的 username/modulename 的写法,例如:ant-design/ant-design,你还可以在后面指定 tag 和 commit id。

GIT_URL 即我们平时clone代码库的 git url,其遵循以下形式:

<protocol>://[<user>[:<password>]@]<hostname>[:<port>][:][/]<path>[#<commit-ish> | #semver:<semver>]

其中 protocal 可以是以下几种形式:

dependencies

dependencies 指定了项目运行所依赖的模块,开发环境和生产环境的依赖模块都可以配置到这里,例如

"dependencies": {
  "lodash": "^4.17.13",
  "moment": "^2.24.0",
}

devDependencies

有一些包有可能你只是在开发环境中用到,例如你用于检测代码规范的 eslint ,用于进行测试的 jest ,用户使用你的包时即使不安装这些依赖也可以正常运行,反而安装他们会耗费更多的时间和资源,所以你可以把这些依赖添加到 devDependencies 中,这些依赖照样会在你本地进行 npm install 时被安装和管理,但是不会被安装到生产环境:

"devDependencies": {
  "jest": "^24.3.1",
  "eslint": "^6.1.0",
}

peerDependencies

peerDependencies 用于指定你正在开发的模块所依赖的版本以及用户安装的依赖包版本的兼容性。

上面的说法可能有点太抽象,我们直接拿 ant-design 来举个例子,ant-design 的 package.json 中有如下配置:

"peerDependencies": {
  "react": ">=16.0.0",
  "react-dom": ">=16.0.0"
}

当你正在开发一个系统,使用了 ant-design ,所以也肯定需要依赖 React。同时, ant-design 也是需要依赖 React 的,它要保持稳定运行所需要的 React 版本是16.0.0,而你开发时依赖的 React 版本是 15.x:

这时,ant-design 要使用 React,并将其引入:

import * as React from 'react';
import * as ReactDOM from 'react-dom';

这时取到的是宿主环境也就是你的环境中的 React 版本,这就可能造成一些问题。在 npm2 的时候,指定上面的 peerDependencies 将意味着强制宿主环境安装 react@>=16.0.0和react-dom@>=16.0.0 的版本。

npm3 以后不会再要求 peerDependencies 所指定的依赖包被强制安装,相反 npm3 会在安装结束后检查本次安装是否正确,如果不正确会给用户打印警告提示。

optionalDependencies

某些场景下,依赖包可能不是强依赖的,这个依赖包的功能可有可无,当这个依赖包无法被获取到时,你希望 npm install 继续运行,而不会导致失败,你可以将这个依赖放到 optionalDependencies 中,注意 optionalDependencies 中的配置将会覆盖掉 dependencies 所以只需在一个地方进行配置。

当然,引用 optionalDependencies 中安装的依赖时,一定要做好异常处理,否则在模块获取不到时会导致报错。

bundledDependencies

和以上几个不同,bundledDependencies 的值是一个数组,数组里可以指定一些模块,这些模块将在这个包发布时被一起打包。

"bundledDependencies": ["package1" , "package2"]

协议

{
    "license": "MIT"
}

license 字段用于指定软件的开源协议,开源协议里面详尽表述了其他人获得你代码后拥有的权利,可以对你的的代码进行何种操作,何种操作又是被禁止的。同一款协议有很多变种,协议太宽松会导致作者丧失对作品的很多权利,太严格又不便于使用者使用及作品的传播,所以开源作者要考虑自己对作品想保留哪些权利,放开哪些限制。

软件协议可分为开源和商业两类,对于商业协议,或者叫法律声明、许可协议,每个软件会有自己的一套行文,由软件作者或专门律师撰写,对于大多数人来说不必自己花时间和精力去写繁长的许可协议,选择一份广为流传的开源协议就是个不错的选择。

以下就是几种主流的开源协议:

01

目录、文件相关

程序入口

{
  "main": "lib/index.js",
}

main 属性可以指定程序的主入口文件,当我们在代码用引入 antd 时:import { notification } from ‘antd’; 实际上引入的就是 lib/index.js 中暴露出去的模块。

命令行工具入口

当你的模块是一个命令行工具时,你需要为命令行工具指定一个入口,即指定你的命令名称和本地可指定文件的对应关系。如果是全局安装,npm 将会使用符号链接把可执行文件链接到 /usr/local/bin,如果是本地安装,会链接到 ./node_modules/.bin/。

{
  "bin": {
    "conard": "./bin/index.js"
  }
}

例如上面的配置:当你的包安装到全局时:npm 会在 /usr/local/bin下创建一个以 conard 为名字的软链接,指向全局安装下来的 conard 包下面的 “./bin/index.js”。这时你在命令行执行 conard 则会调用链接到的这个js文件。

发布文件配置

{
  "files": [
    "dist",
    "lib",
    "es"
  ]
}

files 属性用于描述你 npm publish 后推送到 npm 服务器的文件列表,如果指定文件夹,则文件夹内的所有内容都会包含进来。我们可以看到下载后的包是下面的目录结构:

02

man

man 命令是 Linux 下的帮助指令,通过 man 指令可以查看 Linux 中的指令帮助、配置文件帮助和编程帮助等信息。

如果你的 node.js 模块是一个全局的命令行工具,在 package.json 通过 man 属性可以指定 man 命令查找的文档地址。

man 文件必须以数字结尾,或者如果被压缩了,以 .gz 结尾。数字表示文件将被安装到 man 的哪个部分。如果 man 文件名称不是以模块名称开头的,安装的时候会给加上模块名称前缀。

例如下面这段配置:

{
  "man" : [
    "/Users/isaacs/dev/npm/cli/man/man1/npm-access.1",
    "/Users/isaacs/dev/npm/cli/man/man1/npm-audit.1"
  ]
}

在命令行输入 man npm-audit :

03

规范项目目录

一个 node.js 模块是基于 CommonJS 模块化规范实现的,严格按照 CommonJS 规范,模块目录下除了必须包含包描述文件 package.json 以外,还需要包含以下目录:

在模块目录中你可能没有严格按照以上结构组织或命名,你可以通过在 package.json 指定 directories 属性来指定你的目录结构和上述的规范结构的对应情况。除此之外 directories 属性暂时没有其他应用。

{
  "directories": {
    "lib": "src/lib/",
    "bin": "src/bin/",
    "man": "src/man/",
    "doc": "src/doc/",
    "example": "src/example/"
  }
}

脚本配置

script

{
  "scripts": {
    "test": "jest --config .jest.js --no-cache",
    "dist": "antd-tools run dist",
    "compile": "antd-tools run compile",
    "build": "npm run compile && npm run dist"
  }
}

scripts 用于配置一些脚本命令的缩写,各个脚本可以互相组合使用,这些脚本可以覆盖整个项目的生命周期,配置后可使用 npm run command 进行调用。如果是 npm 关键字,则可以直接调用。例如,上面的配置制定了以下几个命令:npm run test、npm run dist、npm run compile、npm run build。

config

config 字段用于配置脚本中使用的环境变量,例如下面的配置,可以在脚本中使用process.env.npm_package_config_port进行获取。

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

发布配置

preferGlobal

如果你的 node.js 模块主要用于安装到全局的命令行工具,那么该值设置为 true ,当用户将该模块安装到本地时,将得到一个警告。这个配置并不会阻止用户安装,而是会提示用户防止错误使用而引发一些问题。

private

如果将 private 属性设置为 true,npm将拒绝发布它,这是为了防止一个私有模块被无意间发布出去。

publishConfig

"publishConfig": {
  "registry": "https://registry.npmjs.org/"
}

发布模块时更详细的配置,例如你可以配置只发布某个 tag、配置发布到的私有 npm 源。更详细的配置可以参考 npm-config

os

假如你开发了一个模块,只能跑在 darwin 系统下,你需要保证 windows 用户不会安装到你的模块,从而避免发生不必要的错误。

使用 os 属性可以帮助你完成以上的需求,你可以指定你的模块只能被安装在某些系统下,或者指定一个不能安装的系统黑名单:

"os" : [ "darwin", "linux" ]
"os" : [ "!win32" ]

例如,我把一个测试模块指定一个系统黑名单:”os” : [ “!darwin” ],当我在此系统下安装它时会爆出错误:

ps.在node环境下可以使用 process.platform 来判断操作系统。

cpu

和上面的 os 类似,我们可以用 cpu 属性更精准的限制用户安装环境:

"cpu" : [ "x64", "ia32" ]
"cpu" : [ "!arm", "!mips" ]

ps.在node环境下可以使用 process.arch 来判断 cpu 架构。

剖析包版本管理机制

npm view package version // 查看某个 package 的最新版本。

npm view conard versions // 查看某个 package 在npm服务器上所有发布过的版本。

npm ls // 查看当前仓库依赖树上所有包的版本信息。

SemVer规范

npm包中的模块版本都需要遵循SemVer规范——由 Github 起草的一个具有指导意义的,统一的版本号表示规则。实际上就是 Semantic Version(语义化版本)的缩写。

标准版本

SemVer规范的标准版本号采用 X.Y.Z 的格式,其中 X、Y 和 Z 为非负的整数,且禁止在数字前方补零。X 是主版本号、Y 是次版本号、而 Z 为修订号。每个元素必须以数值来递增。

先行版本号可以加到“主版本号.次版本号.修订号”的后面,先加上一个连接号再加上一连串以句点分隔的标识符和版本编译信息。

例如:3.1.0-rc1

发布版本

在修改 npm 包某些功能后通常需要发布一个新的版本,我们通常的做法是直接去修改 package.json 到指定版本。如果操作失误,很容易造成版本号混乱,我们可以借助符合 Semver 规范的命令来完成这一操作:

版本工具使用

在开发中肯定少不了对一些版本号的操作,如果这些版本号符合 SemVer规范 ,我们可以借助用于操作版本的npm包semver来帮助我们进行比较版本大小、提取版本信息等操作。

Npm 也使用了该工具来处理版本相关的工作。

npm install semver

比较版本号大小

semver.gt('1.2.3', '9.8.7') // false
semver.lt('1.2.3', '9.8.7') // true

判断版本号是否符合规范,返回解析后符合规范的版本号。

semver.valid('1.2.3') // '1.2.3'
semver.valid('a.b.c') // null

将其他版本号强制转换成semver版本号

semver.valid(semver.coerce('v2')) // '2.0.0'
semver.valid(semver.coerce('42.6.7.9.3-alpha')) // '42.6.7'

一些其他用法

semver.clean('  =v1.2.3   ') // '1.2.3'
semver.satisfies('1.2.3', '1.x || >=2.5.0 || 5.0.0 - 7.2.3') // true
semver.minVersion('>=1.0.0') // '1.0.0'

依赖版本管理

我们经常看到,在 package.json 中各种依赖的不同写法:

"dependencies": {
  "signale": "1.4.0",
  "figlet": "*",
  "react": "16.x",
  "table": "~5.4.6",
  "yargs": "^14.0.0"
}

前面三个很容易理解:

再来看看后面两个,版本号中引用了 ~ 和 ^ 符号:

在 package.json 文件中最常见的应该是 “yargs”: “^14.0.0” 这种格式的 依赖, 因为我们在使用 npm install package 安装包时,npm 默认安装当前最新版本,然后在所安装的版本号前加 ^ 号。

注意,当主版本号为 0 的情况,会被认为是一个不稳定版本,情况与上面不同:

1.0.0 的版本号用于界定公共 API。当你的软件发布到了正式环境,或者有稳定的API时,就可以发布1.0.0版本了。所以,当你决定对外部发布一个正式版本的npm包时,把它的版本标为1.0.0。

锁定依赖版本

lock文件

实际开发中,经常会因为各种依赖不一致而产生奇怪的问题,或者在某些场景下,我们不希望依赖被更新,建议在开发中使用 package-lock.json。

锁定依赖版本意味着在我们不手动执行更新的情况下,每次安装依赖都会安装固定版本。保证整个团队使用版本号一致的依赖。

每次安装固定版本,无需计算依赖版本范围,大部分场景下能大大加速依赖安装时间。

定期更新依赖

我们的目的是保证团队中使用的依赖一致或者稳定,而不是永远不去更新这些依赖。实际开发场景下,我们虽然不需要每次都去安装新的版本,仍然需要定时去升级依赖版本,来让我们享受依赖包升级带来的问题修复、性能提升、新特性更新。

使用 npm outdated 可以帮助我们列出有哪些还没有升级到最新版本的依赖:

执行 npm update 会升级所有的红色依赖。

依赖版本选择的最佳实践

版本发布

依赖范围选择

保持依赖一致

依赖变更

剖析 npm install 原理

04

嵌套结构

我们都知道,执行 npm install 后,依赖包被安装到了 node_modules ,下面我们来具体了解下,npm 将依赖包安装到 node_modules 的具体机制是什么。

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

举个例子,我们的模块 my-app 现在依赖了两个模块:buffer、ignore:

ignore是一个纯 JS 模块,不依赖任何其他模块,而 buffer 又依赖了下面两个模块:base64-js 、 ieee754。

那么,执行 npm install 后,得到的 node_modules 中模块目录结构就是下面这样的:

05

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

但是,试想一下,如果你依赖的模块非常之多,你的 node_modules 将非常庞大,嵌套层级非常之深:

06

扁平结构

为了解决以上问题,NPM 在 3.x 版本做了一次较大更新。其将早期的嵌套结构改为扁平结构。

安装模块时,不管其是直接依赖还是子依赖的依赖,优先将其安装在 node_modules 根目录。

还是上面的依赖结构,我们在执行 npm install 后将得到下面的目录结构:

07

此时我们若在模块中又依赖了 base64-js@1.0.1 版本:

当安装到相同模块时,判断已安装的模块版本是否符合新模块的版本范围,如果符合则跳过,不符合则在当前模块的 node_modules 下安装该模块。

08

对应的,如果我们在项目代码中引用了一个模块,模块查找流程如下:

直到搜索到全局路径中的 node_modules

假设我们又依赖了一个包 buffer2@^5.4.3,而它依赖了包 base64-js@1.0.3,则此时的安装结构是下面这样的:

09

所以 npm 3.x 版本并未完全解决老版本的模块冗余问题,甚至还会带来新的问题。

试想一下,你的APP假设没有依赖 base64-js@1.0.1 版本,而你同时依赖了依赖不同 base64-js 版本的 buffer 和 buffer2。由于在执行 npm install 的时候,按照 package.json 里依赖的顺序依次解析,则 buffer 和 buffer2 在 package.json 的放置顺序则决定了 node_modules 的依赖结构:

先依赖buffer2:

10

先依赖buffer:

11

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

Lock文件

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

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

例如,我们有如下的依赖结构:

{
  "name": "my-app",
  "dependencies": {
    "buffer": "^5.4.3",
    "ignore": "^5.1.4",
    "base64-js": "1.0.1",
  }
}

在执行 npm install 后生成的 package-lock.json 如下:

{
  "name": "my-app",
  "version": "1.0.0",
  "dependencies": {
    "base64-js": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.0.1.tgz",
      "integrity": "sha1-aSbRsZT7xze47tUTdW3i/Np+pAg="
    },
    "buffer": {
      "version": "5.4.3",
      // 包具体的安装来源
      "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.4.3.tgz",
      //  hash 值,基于 Subresource Integrity 来验证已安装的软件包是否被改动过、是否已失效
      "integrity": "sha512-zvj65TkFeIt3i6aj5bIvJDzjjQQGs4o/sNoezg1F1kYap9Nu2jcUdpwzRSJTHMMzG0H7bZkn4rNQpImhuxWX2A==",
      // 对应子依赖的依赖,与子依赖的 package.json  dependencies的依赖项相同。
      "requires": {
        "base64-js": "^1.0.2",
        "ieee754": "^1.1.4"
      },
      // 结构和外层的 dependencies 结构相同,存储安装在子依赖 node_modules 中的依赖包。
      "dependencies": {
        "base64-js": {
          "version": "1.3.1",
          "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz",
          "integrity": "sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g=="
        }
      }
    },
    "ieee754": {
      "version": "1.1.13",
      "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz",
      "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg=="
    },
    "ignore": {
      "version": "5.1.4",
      "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.4.tgz",
      "integrity": "sha512-MzbUSahkTW1u7JpKKjY7LCARd1fU5W2rLdxlM4kdkayuCwZImjkpluF9CM1aLewYJguPDqewLam18Y6AU69A8A=="
    }
  }
}

这里注意,并不是所有的子依赖都有 dependencies 属性,只有子依赖的依赖和当前已安装在根目录的 node_modules 中的依赖冲突之后,才会有这个属性。

例如,回顾下上面的依赖关系:

我们在 my-app 中依赖的 base64-js@1.0.1 版本与 buffer 中依赖的 base64-js@^1.0.2 发生冲突,所以 base64-js@1.0.1 需要安装在 buffer 包的 node_modules 中,对应了 package-lock.json 中 buffer 的 dependencies 属性。这也对应了 npm 对依赖的扁平化处理方式。

所以,根据上面的分析, package-lock.json 文件 和 node_modules 目录结构是一一对应的,即项目目录下存在 package-lock.json 可以让每次安装生成的依赖目录结构保持相同。

另外,项目中使用了 package-lock.json 可以显著加速依赖安装时间。

我们使用 npm i –timing=true –loglevel=verbose 命令可以看到 npm install 的完整过程

package-lock.json 中已经缓存了每个包的具体版本和下载链接,不需要再去远程仓库进行查询,然后直接进入文件完整性校验环节,减少了大量网络请求。

使用建议

开发系统应用时,建议把 package-lock.json 文件提交到代码版本仓库,从而保证所有团队开发者以及 CI 环节可以在执行 npm install 时安装的依赖版本都是一致的。

在开发一个 npm包 时,你的 npm包 是需要被其他仓库依赖的,由于上面我们讲到的扁平安装机制,如果你锁定了依赖包版本,你的依赖包就不能和其他依赖包共享同一 semver 范围内的依赖包,这样会造成不必要的冗余。所以我们不应该把package-lock.json 文件发布出去( npm 默认也不会把 package-lock.json 文件发布出去)。

缓存

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

通过 npm config get cache 命令可以查询到:在 Linux 或 Mac 默认是用户主目录下的 .npm/_cacache 目录。

在这个目录下又存在两个目录:content-v2、index-v5,content-v2 目录用于存储 tar包的缓存,而index-v5目录用于存储tar包的 hash。

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

npm 提供了几个命令来管理缓存数据:

基于缓存数据,npm 提供了离线安装模式,分别有以下几种:

文件完整性

上面我们多次提到了文件完整性,那么什么是文件完整性校验呢?

在下载依赖包之前,我们一般就能拿到 npm 对该依赖包计算的 hash 值,例如我们执行 npm info 命令,紧跟 tarball(下载链接) 的就是 shasum(hash)

用户下载依赖包到本地后,需要确定在下载过程中没有出现错误,所以在下载完成之后需要在本地在计算一次文件的 hash 值,如果两个 hash 值是相同的,则确保下载的依赖是完整的,如果不同,则进行重新下载。

整体流程

好了,我们再来整体总结下上面的流程:

无 lock 文件:

有 lock 文件:

yarn

yarn 是在 2016 年发布的,那时 npm 还处于 V3 时期,那时候还没有 package-lock.json 文件,就像上面我们提到的:不稳定性、安装速度慢等缺点经常会受到广大开发者吐槽。此时,yarn 诞生

yarn 的优点,在那个时候还是非常吸引人的。当然,后来 npm 也意识到了自己的问题,进行了很多次优化,在后面的优化(lock文件、缓存、默认-s…)中,我们多多少少能看到 yarn 的影子,可见 yarn 的设计还是非常优秀的。

yarn 也是采用的是 npm v3 的扁平结构来管理依赖,安装依赖后默认会生成一个 yarn.lock 文件