npm

结论:npm 5.4 之后 npm install 时根据 lock 文件下载

npm 内部机制和核心原理

npm install 的安装机制

image.png

  1. npm install 执行之后,首先,检查并获取 npm 配置,这里的优先级为:

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

  1. 然后检查项目中是否有 package-lock.json 文件。如果有,则检查 package-lock.json 和 package.json 中声明的依赖是否一致:

    • 一致,直接使用 package-lock.json 中的信息,从缓存或网络资源中加载依赖;
    • 不一致,按照 npm 版本进行处理(不同 npm 版本处理会有不同,具体处理方式如图所示)。
  2. 如果没有,则根据 package.json 递归构建依赖树。然后按照构建好的依赖树下载完整的依赖资源,在下载时就会检查是否存在相关资源缓存:

    • 存在,则将缓存内容解压到 node_modules 中;
    • 否则就先从 npm 远程仓库下载包,校验包的完整性,并添加到缓存,同时解压到 node_modules。
  3. 最后生成 package-lock.json。

构建依赖树时,当前依赖项目不管其是直接依赖还是子依赖的依赖,都应该按照扁平化原则,优先将其放置在 node_modules 根目录(最新版本 npm 规范)。在这个过程中,遇到相同模块就判断已放置在依赖树中的模块版本是否符合新模块的版本范围,如果符合则跳过;不符合则在当前模块的 node_modules 下放置该模块(最新版本 npm 规范)。

npm 缓存机制

对于一个依赖包的同一版本进行本地化缓存,是当代依赖包管理工具的一个常见设计。使用时要先执行以下命令:

  1. npm config get cache

得到配置缓存的根目录在 /Users/cehou/.npm( Mac OS 中,npm 默认的缓存位置) 当中。我们 cd 进入 /Users/cehou/.npm 中可以发现_cacache文件。事实上,在 npm v5 版本之后,缓存数据均放在根目录中的_cacache文件夹中。
image.png

下来打开_cacache文件,看看 npm 缓存了哪些东西,一共有 3 个目录:

  • content-v2
  • index-v5
  • tmp

其中 content-v2 里面基本都是一些二进制文件。为了使这些二进制文件可读,我们把二进制文件的扩展名改为 .tgz,然后进行解压,得到的结果其实就是我们的 npm 包资源。

而 index-v5 文件中,我们采用跟刚刚一样的操作就可以获得一些描述性的文件,事实上这些内容就是 content-v2 里文件的索引。

这些缓存如何被储存并被利用的呢?

这就和 npm install 机制联系在了一起。当 npm install 执行时,通过pacote 把相应的包解压在对应的 node_modules 下面。npm 在下载依赖时,先下载到缓存当中,再解压到项目 node_modules 下。pacote 依赖npm-registry-fetch来下载包,npm-registry-fetch 可以通过设置 cache 属性,在给定的路径下根据IETF RFC 7234生成缓存数据。

接着,在每次安装资源时,根据 package-lock.json 中存储的 integrity、version、name 信息生成一个唯一的 key,这个 key 能够对应到 index-v5 目录下的缓存记录。如果发现有缓存资源,就会找到 tar 包的 hash,根据 hash 再去找缓存的 tar 包,并再次通过pacote把对应的二进制文件解压到相应的项目 node_modules 下面,省去了网络下载资源的开销。

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

npm 不完全指南

自定义 npm init

npm init 命令本身并不复杂,它其实就是调用 shell 脚本输出一个初始化的 package.json 文件。
那么相应地,我们要自定义 npm init 命令,就是写一个 node 脚本而已,它的 module.exports 即为 package.json 配置内容。

  1. const desc = prompt('请输入项目描述', '项目描述...')
  2. module.exports = {
  3. key: 'value',
  4. name: prompt('name?', process.cwd().split('/').pop()),
  5. version: prompt('version?', '0.0.1'),
  6. description: desc,
  7. main: 'index.js',
  8. repository: prompt('github repository url', '', function (url) {
  9. if (url) {
  10. run('touch README.md');
  11. run('git init');
  12. run('git add README.md');
  13. run('git commit -m "first commit"');
  14. run(`git remote add origin ${url}`);
  15. run('git push -u origin master');
  16. }
  17. return url;
  18. })
  19. }

假设该脚本名为 .npm-init.js,我们执行下述命令来确保 npm init 所对应的脚本指向正确的文件:

  1. npm config set init-module ~\.npm-init.js

也可以通过配置 npm init 默认字段来自定义 npm init 的内容:

  1. npm config set init.author.name "xxx"
  2. npm config set init.author.email "xxx@gmail.com"
  3. npm config set init.author.url "xxxx.com"
  4. npm config set init.license "MIT"

npx

npx 由 npm v5.2 版本引入,解决了 npm 的一些使用快速开发、调试,以及项目内使用全局模块的痛点。

在运行命令时,npx 可以自动去 node_modules/.bin 路径和环境变量 $PATH 里面检查命令是否存在,而不需要再在 package.json 中定义相关的 script。

npx 另一个更实用的好处是:npx 执行模块时会优先安装依赖,但是在安装执行后便删除此依赖,这就避免了全局安装模块带来的问题。

设置安装源

我们可以通过npm config set命令来设置安装源或者某个 scope 对应的安装源,很多企业也会搭建自己的 npm 源。我们常常会碰到需要使用多个安装源的项目,这时就可以通过 npm-preinstall 的钩子,通过 npm 脚本,在安装公共依赖前自动进行源切换:

  1. 1. "scripts": {
  2. 2. "preinstall": "node ./bin/preinstall.js"
  3. 3. }

其中 preinstall.js 脚本内容,具体逻辑为通过 node.js 执行npm config set命令,代码如下:

  1. 1. require('child_process').exec('npm config get registry', function(error, stdout, stderr) {
  2. 2. if (!stdout.toString().match(/registry\.x\.com/)) {
  3. 3. exec('npm config set @xscope:registry https://xxx.com/npm/')
  4. 4. }
  5. 5. })


  • npm 配置作用优先级

npm 可以通过默认配置帮助我们预设好 npm 对项目的影响动作,但是 npm 的配置优先级需要开发者确认了解。
如下图所示,优先级从左到右依次降低。我们在使用 npm 时需要了解 npm 的设置作用域,排除干扰范围,以免一顿骚操作之后,并没有找到相应的起作用配置。
**包管理工具 - 图3

  • npm 镜像和安装问题


Yarn

  • 确定性:通过 yarn.lock 等机制,保证了确定性。即不管安装顺序如何,相同的依赖关系在任何机器和环境下,都可以以相同的方式被安装。(在 npm v5 之前,没有 package-lock.json 机制,只有默认并不会使用的npm-shrinkwrap.json。)
  • 采用模块扁平安装模式:将依赖包的不同版本,按照一定策略,归结为单个版本,以避免创建多个副本造成冗余(npm 目前也有相同的优化)。
  • 网络性能更好:Yarn 采用了请求排队的理念,类似并发连接池,能够更好地利用网络资源;同时引入了更好的安装失败时的重试机制。
  • 采用缓存机制,实现了离线模式(npm 目前也有类似实现)。

关于 Yarn 缓存,我们可以通过这个命令查看缓存目录,并通过目录查看缓存内容

  1. yarn cache dir

值得一提的是,Yarn 默认使用 prefer-online 模式,即优先使用网络数据。如果网络数据请求失败,再去请求缓存数据。

一个专门的 synp 工具,它可以将 yarn.lock 和package-lock.json转换

破解依赖管理困境
npm 包的安装顺序对于依赖树的影响很大。模块安装顺序可能影响 node_modules 内的文件数量。
image.png

模块 E v2.0 发布了,并且 E v2.0 也依赖了模块 B v2.0
image.png
可以明显看到出现了较多重复的依赖模块 B v2.0。我们可以删除 node_modules,重新安装,利用 npm 的依赖分析能力,得到一个更清爽的结构。
实际上,更优雅的方式是使用npm dedupe 命令

image.png
实际上,Yarn 在安装依赖时会自动执行 dedupe 命令。整个优化的安装过程,就是扁平化安装模式。

CI 环境上的 npm 优化

合理使用 npm ci 和 npm install
顾名思义,npm ci 就是专门为 CI 环境准备的安装命令,相比 npm install 它的不同之处在于:

  • npm ci 要求项目中必须存在 package-lock.json 或 npm-shrinkwrap.json;
  • npm ci 完全根据 package-lock.json 安装依赖,这可以保证整个开发团队都使用版本完全一致的依赖;
  • 正因为 npm ci 完全根据 package-lock.json 安装依赖,在安装过程中,它不需要计算求解依赖满足问题、构造依赖树,因此安装过程会更加迅速;
  • npm ci 在执行安装时,会先删除项目中现有的 node_modules,然后全新安装;
  • npm ci 只能一次安装整个项目所有依赖包,无法安装单个依赖包;
  • 如果 package-lock.json 和 package.json 冲突,那么 npm ci 会直接报错,并非更新 lockfiles;
  • npm ci 永远不会改变 package.json 和 package-lock.json。

基于以上特性,我们在 CI 环境使用 npm ci 代替 npm install,一般会获得更加稳定、一致和迅速的安装体验。

package-lock.json

对于应用项目,建议上传package-lock.json 到仓库中,以保证依赖安装的一致性。事实上,如果项目中使用了package-lock.json 一般还可以显着加速依赖安装时间。

  1. "@babel/core": {
  2. "version": "7.2.0",
  3. "resolved": "http://www.npm.com/@babel%2fcore/-/core-7.2.0.tgz",
  4. "integrity": "sha1-pN04FJAZmOkzQPAIbphn/voWOto=",
  5. "dev": true,
  6. "requires": {
  7. "@babel/code-frame": "^7.0.0",
  8. // ...
  9. },
  10. "dependencies": {
  11. "@babel/generator": {
  12. "version": "7.2.0",
  13. "resolved": "http://www.npm.com/@babel%2fgenerator/-/generator-7.2.0.tgz",
  14. "integrity": "sha1-6vOCH6AwHZ1K74jmPUvMGbc7oWw=",
  15. "dev": true,
  16. "requires": {
  17. "@babel/types": "^7.2.0",
  18. "jsesc": "^2.5.1",
  19. "lodash": "^4.17.10",
  20. "source-map": "^0.5.0",
  21. "trim-right": "^1.0.1"
  22. }
  23. },
  24. // ...
  25. }
  26. },
  27. // ...
  28. }

通过上述代码示例,我们看到:一个 package-lock.json 的 dependency 主要由以下部分构成。

  • Version:依赖包的版本号
  • Resolved:依赖包安装源(可简单理解为下载地址)
  • Integrity:表明包完整性的 Hash 值
  • Dev:表示该模块是否为顶级模块的开发依赖或者是一个的传递依赖关系
  • requires:依赖包所需要的所有依赖项,对应依赖包 package.json 里 dependencies 中的依赖项
  • dependencies:依赖包 node_modules 中依赖的包(特殊情况下才存在)

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

如果你的目标是开发一个给外部使用的库,那就要谨慎考虑了,因为库项目一般是被其他项目依赖的,在不使用 package-lock.json 的情况下,就可以复用主项目已经加载过的包,减少依赖重复和体积。

如果我们开发的库依赖了一个精确版本号的模块,那么提交 lockfiles 到仓库可能会造成同一个依赖不同版本都被下载的情况。如果作为库开发者,真的有使用某个特定版本依赖的需要,一个更好的方式是定义 peerDependencies

因此,一个推荐的做法是:把 package-lock.json 一起提交到代码库中,不需要 ignore。但是执行 npm publish 命令,发布一个库的时候,它应该被忽略而不是直接发布出去。

xxxDependencies

npm 设计了以下几种依赖类型声明:

  • dependencies 项目依赖
  • devDependencies 开发依赖
  • peerDependencies 同版本依赖
  • bundledDependencies 捆绑依赖
  • optionalDependencies 可选依赖

dependencies 表示项目依赖,这些依赖都会成为线上生产环境中的代码组成部分。当它关联的 npm 包被下载时,dependencies 下的模块也会作为依赖,一起被下载。

devDependencies 表示开发依赖,不会被自动下载,因为 devDependencies 一般只在开发阶段起作用或只是在开发环境中需要用到。比如 Webpack,预处理器 babel-loader、scss-loader,测试工具 E2E、Chai 等,这些都是辅助开发的工具包,无须在生产环境使用。

这里需要特别说明的是:并不是只有在 dependencies 中的模块才会被一起打包,而在 devDependencies 中的依赖一定不会被打包。实际上,依赖是否被打包,完全取决于项目里是否被引入了该模块。

dependencies 和devDependencies 在业务中更多的只是一个规范作用,我们自己的应用项目中,使用 npm install 命令安装依赖时,dependencies 和 devDependencies 内容都会被下载。

peerDependencies 表示同版本依赖,简单来说就是:如果你安装我,那么你最好也安装我对应的依赖。举个例子,假设 react-ui@1.2.2 只提供一套基于 React 的 UI 组件库,它需要宿主环境提供指定的 React 版本来搭配使用,因此我们需要在 React-ui 的 package.json 中配置:

  1. "peerDependencies": {
  2. "React": "^17.0.0"
  3. }

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

bundledDependencies 和 npm pack 打包命令有关。假设 package.json 中有如下配置:

  1. {
  2. "name": "test",
  3. "version": "1.0.0",
  4. "dependencies": {
  5. "dep": "^0.0.2",
  6. ...
  7. },
  8. "devDependencies": {
  9. ...
  10. "devD1": "^1.0.0"
  11. },
  12. "bundledDependencies": [
  13. "bundleD1",
  14. "bundleD2"
  15. ]
  16. }

在执行 npm pack 时,就会产出一个 test-1.0.0.tgz 压缩包,且该压缩包中包含了 bundle D1 和 bundle D2 两个安装包。业务方使用 npm install test-1.0.0.tgz 命令时,也会安装 bundle D1 和 bundle D2。

这里你需要注意的是:在 bundledDependencies 中指定的依赖包,必须先在 dependencies 和 devDependencies 声明过,否则在 npm pack 阶段会进行报错。

最佳实践

  • 优先使用 npm v5.4.2 以上的 npm 版本,以保证 npm 的最基本先进性和稳定性。
  • 项目的第一次搭建使用 npm install 安装依赖包,并提交 package.json、package-lock.json
  • 对于升级依赖包的需求:
    • 依靠 npm update 命令升级到新的小版本;
    • 依靠 npm install @升级大版本;
    • 也可以手动修改 package.json 中版本号,并执行 npm install 来升级版本;
    • 本地验证升级后新版本无问题,提交新的 package.json、package-lock.json 文件。
  • 对于降级依赖包的需求:执行 npm install @xx 命令,验证没问题后,提交新的 package.json、package-lock.json 文件。
  • npm uninstall命令,验证没问题后,提交新的 package.json、package-lock.json 文件,或者手动操作 package.json,删除依赖,执行 npm install 命令,验证没问题后,提交新的 package.json、package-lock.json 文件。
  • 任何时候都不要修改 package-lock.json。如果 package-lock.json 出现冲突或问题,建议将本地的 package-lock.json 文件删除,引入远程的 package-lock.json 文件和 package.json,再执行 npm install 命令。

一句话:改package.json,执行npm install

在 package.json 中,可以使用 semver 表示法设置要升级到的版本(补丁版本或次版本),例如:

  • 如果写入的是 ~0.13.0,则只更新补丁版本:即 0.13.1 可以,但 0.14.0 不可以。
  • 如果写入的是 ^0.13.0,则要更新补丁版本和次版本:即 0.13.1、0.14.0、依此类推。
  • 如果写入的是 0.13.0,则始终使用确切的版本。


参考资料