一、导读
npm 发展到现在仍然是世界上最大的包管理系统,这主要得益于活跃的 nodejs 社区,作为前端开发的同学当然不能仅仅只停留在基本的使用阶段,我们还需要去深入学习与之相关的配置、原理以及新特性,这篇文章将会从package.json、依赖安装原理、版本管理、现存的问题、最新的特性、未来展望这几个方面去介绍 npm。
二、package.json
我们知道对于每一个 nodejs 项目,都有它对应的描述文件、即 package.json,里面包含了项目的名称、版本、依赖等基本信息,比如当我们运行 npm install 时就会根据描述文件中的 dependencies 和 devDependencies 配置去下载对应的包。
1、name
name 是每个 package 必需的字段,他和 version 一起组成了一个 package 的唯一标识,我们可以通过 npm view packageName 去查看包名是否被占用,比如查看下 react:
2、version
package 每发布一次就需要一个新的 version,version 一般的格式是 主版本号.次版本号.修订号,通常情况下当我们做了比较大的功能性改动时就需要更新主版本号,当新增了一些功能时就修改次版本号,当只是修复了一些问题时就修改修订号。
如果某次改动比较大而且在不是很稳定的情况下,就需要先发布先行版,比如 react17 -> react18,通常会在版本后通过 - 这个连接符去设置版本信息,alpha 表示内测版、beta 表示公测版、rc 表示候选版。
3、 地址
"homepage": "https://reactjs.org/",
"bugs": "https://github.com/facebook/react/issues",
"repository": {
"type": "git",
"url": "https://github.com/facebook/react.git",
"directory": "packages/react"
},
homepage 表示项目的主页面地址,上面表示 react 的官方网址。
repository 表示项目的仓库地址。
4、dependencies
dependencies 字段中申明的包是项目生产环境中需要的,这里最需要注重的是依赖包的版本控制,一般我们可以通过三种方式去控制依赖的版本号,下面看一个例子:
"dependencies": {
"react": "16.0.0", // 16.0.0
"react": "^17.0.2", // >=17.0.2 < 18.0.0
"react": "^0.0.1", // >=0.0.1 <0.0.2
"react": "^0.2.0", // >=0.2.0 <0.3.0
"react": "^16.0.x", // >=16.0.0 <17.0.0
"react": "^0.0.x", // >=0.0.0 <0.1.0
"react": "^0.x", // >=0.0.0 <0.1.0
"react": "^1.x" // >=1.0.0 <2.0.0
"react": "~15.5.0", // >=15.5.0 < 15.6.0
"react": "~15.5", // >=15.5.0 < 15.6.0
"react": "~15", // >=15.0.0 < 16.0.0
"react": "~0.2.0", // >=0.2.0 <0.3.0
"react": "~0.2", // >=0.2.0 <0.3.0
"react": "~0" // >=0.0.0 <1.0.0-0
},
大家先不要看答案自己想想其对应的版本是什么,然后可以在这个网站进行验证。
-
固定版本号
"react": "16.0.0"就是固定版本,安装时就会安装这个指定的版本。 -
波浪号 (~)
一般情况下(存在主版本号和次版本号)只会改变修订号,主版本号和次版本号是不会发生变化的,比如
~15.5.0,但是当缺失次版本号时就会改变次版本号,比如~0、~15。 -
插入号 (^)
插入号一般情况下(主版本号大于1)只会改变次版本号和修订号,比如
^17.0.2,但是一旦主版本号等于0时,事情就不是我们想的那样了,下面分情况讨论:-
主版本号和次版本号都为0,修订号不缺失
比如
^0.0.1,他就只会在0.0.1和0.0.2之间寻找合适的版本。 -
主版本号为0,次版本号不为0,且次版本号和修订号都不缺失
比如
^0.2.0,他就会改变修订号,即在0.2.0和0.3.0之间寻找合适的版本。 -
主版本号和次版本号为0,修订号缺失,
比如
^0.0.x,此时修订号会降为0,同时会在0.0.0和0.1.0之间寻找合适的包。 -
主版本号为0,次版本号和修订号缺失
比如
^0.x,此时修订号和次版本号都会被降为0,同时会在0.0.0和0.1.0之间寻找包 -
主版本号不为0,次版本号或者修订号缺失
比如
^16.0.x、^1.x,此时的效果和一般情况就没区别。
-
注意点:
1.0.0 的版本号用于界定公共 API。当你的软件发布到了正式环境,或者有稳定的API时,就可以发布1.0.0版本了。所以,当你决定对外部发布一个正式版本的npm包时,把它的版本标为1.0.0。
5、devDependencies
devDependencies 中保存的是一些只有在开发环境下才会用到的依赖,比如 eslint、babel-loader、webpack等,我们生产环境的代码是已经经过编译压缩后的代码,不需要这些依赖也能正常运行,如果装上这些依赖反而会增加包的体积。
6、optionalDependencies
有些场景下,对于一些依赖包不是强依赖的,当这个包无法被获取到时我希望 npm install 能够继续运行而不被阻断,此时我们就可以将依赖放到这里,需要注意的是 optionalDependencies 的配置会覆盖 dependencies 中的配置,所以我们只需在这里配置就行。
7、main
main 字段表示包的入口,比如 antd 的入口为 lib/index.js,我们通过 import
引入的时候其实就是引入这个入口文件中暴露出来的组件。
8、bin
当我们开发了一个命令行工具时,我们需要为命令行工具指定一个入口,即指定我这个命令对应的本地文件,如果我们是全局安装,npm 会使用符号连接把可执行文件链接到 /user/local/bin 下面,如果是本地安装,就会链接到 。/node_modules/.bin下,下面看下 babel/cli 这个命令行工具包。
"bin": {
"babel": "./bin/babel.js",
"babel-external-helpers": "./bin/babel-external-helpers.js"
},
我们执行 babel 其实就是运行 ./bin/babel.js 这个文件。
9、files
files 属性用于描述你 npm publish 后推送到 npm 服务器的文件列表,如果指定文件夹,则文件夹内的所有内容都会包含进来。下面看下 antd 是怎么做的:
我们看到我们 npm install antd 后下下来的包里面的文件就是 files 中定义的文件。
10、private
private 属性决定是否将该项目发布到 npm 上,用来放置将私有的包发布出去。
11、publishConfig
publishConfig 包含我们发布模块时的一些配置,比如设置发布的目的 npm 源,更详细的配置看参考 npm-config
12、os
os 可以指定我们的包只能被安装在特定的环境下,比如说我只想让linux、window用户安装,我可以这样配置:
"os" : [ "win32", "linux" ]
三、npm 是怎么管理包的?
npm 的包管理主要分为两个阶段,一个是 npm2 及之前的管理方式,由于当时这种管理方式存在很多不足,所以在在 npm3 就对管理方式进行了优化。
1、 npm2的依赖管理
npm2 安装以来的方式比较简单粗暴,他会按照依赖的树形结构依次安装对应的依赖,比如我现在项目中依赖了 A@3.0.0,B@2.0.0,C@2.0.0,而B又依赖了 A@3.0.0、D@2.0.1,C 依赖了 A@3.0.0,D@2.0.0,那么对应的树形结构图如下:
那么 npm 就会更具这个树形结构图一次在对应包的 node_modules 中安装依赖,即使 D@2.0.0 这个包具有相同的版本。
这种包管理方式会带来什么问题呢?
-
依赖的层级太深,这就会导致文件的路径过长,在 window 环境下就会出现问题。
-
大量包被重复的安装,这不仅会导致整个项目的体积会变大,同时安装依赖的时间也会变得非常的长。
2、npm3的依赖管理
从 npm3 开始就采用了扁平化管理的方式管理依赖,比如说我们去安装一个 express 这个包,按 npm2 的管理方式全局 node_mosules 中只会出现 express 这个包,而 express 本身的依赖会安装到他自己的 node_mosules 中,但是在 npm3 中采用了扁平化的方式,他会把所有的依赖全部打平放在顶层的 node_modules 中,结果如下:
所有的依赖都被拍平到node_modules目录下,不再有很深层次的嵌套关系。这样在安装新的包时,根据 node module resolution algorithm 机制,会不停往上级的node_modules当中去找,如果找到相同版本的包就不会重新安装,解决了大量包重复安装的问题,而且依赖层级也不会太深。
但是我们仔细想想大量重复安装包的问题真的被解决了吗?扁平化操作就没有其他的问题吗?我们一个一个来介绍。
1、重复安装包的问题真的被解决了吗?
答案是没有,我们来看一个例子:
项目对应的 package.json 如下:
"dependencies": {
"xl-package2": "1.0.0",
"xl-package4": "1.0.0",
"xl-package5": "1.0.0",
"xl-package1": "1.1.0"
}
执行 npm install 后我们来看下 node_modules 的结构,
也就是说 npm 只把 xl-package3@1.0.0 提升到了全局,当我去安装 xl-package3@1.1.0 时会去向上找这个版本有没有被安装,显然是没有被安装的,所以 xl-package3@1.1.0 会被重复安装三次,因为 npm 最终会通过内部的一个 compare 方法对依赖进行一次排序,所以字典序在前面的包会被优先提出来。
2、Phantom dependencies
一个库使用了不属于其 dependencies 里的 Package 称之为 Phantom dependencies,这主要是由于包的查找规则,这种现象在 monorepo 中会被放大,比如我们所熟悉的 yarn workspace + lerna,它会将所有package 的依赖提升到顶层的 node_modules,这样就会导致 package 之间相互使用自身没有申明的依赖。
比如上面的例子,我们可以在项目中直接 import / require xl-package3 这个包,但是由于无法保证幻影依赖的版本正确性,给程序运行带来了不可控的风险,我们能否引用到 xl-package3,以及引用到什么版本的 xl-package3 完全取决于 xl-package1、xl-package2 等包的开发者。
3、npm5的改进点
npm5 在包管理上其实和 npm3 没啥差别,npm5 主要是在一个功能上做了一些改进,毕竟作为官方的包管理工具不能被 yarn 给取代,下面列举下 npm5 的主要改进点:
1、新增 package-lock.json 来记录依赖树信息,进行依赖锁定
在我们首次去安装依赖时,npm 会为我们生成一个 package-lock.json 文件,这个文件记录了我们本次安装的依赖的一些信息,当下次再去执行 install 时最终的依赖版本和树形结构回合第一次一样,这样就保证了多人开发的环境一致性。
其实锁定依赖信息的功能其实之前就有-npm-shrinkwrap.json,他的文件格式和 package-lock.json 是一致的,当我们安装完依赖之后再运行 npm shrinkwrap 命令,会发现就是把 package-lock.json 重命名为 npm-shrinkwrap.json 而已。
那这两种文件既然都一样,那为什么还要多增加一个文件干啥?其实这两个文件在使用场景上还是有区别的:
-
package-lock.json 用于开发时锁定版本使用,应该提交到 git 仓库,但是不应该跟随发布。
-
npm-shrinkwrap.json 可以作为库的依赖锁进行发布,当依赖包有此文件时,将按照此文件安装对应的依赖。
-
当两个文件同时存在时,npm-shrinkwrap.json 有高优先级,package-lock.json 文件将被忽略。
2、缓存优化
新版本重写了整个缓存系统,缓存将由 npm 来全局维护不用用户操心,--cache-min and --cache-max也已经被弃用,同时在离线情况下, npm 不会持续的发送请求,而是会寻找包的缓存或者结束安装。
3、文件依赖的优化
npm5 中新增了 npm install file: ./xxx/file 这种方式去引用本地文件,在之前的版本中我们想要实现这种效果就得 copy 一份代码到 node_mosules 中,这种方式也是费力不讨好做不到事实时同步更新,使用 npm install file: xxx 会直接通过软链接链接到本地的文件夹,同时能实现实时的同步更新。那这种方式和 npm link 有什么区别呢?
详细解释可以查看这里,其实概括下来就是这两点:
npm link后的包在 package.json 中是没有记录的,而使用npm install file: xxx将会在我们项目的 package.json 中留下记录方便查找:
- npm link 需要先全局建立一个软连接文件,然后本地如果需要引用就直接指向这个全局的文件,而
npm install file: xxx是直接软链接到对应的文件,如下图所示:
4、npm6的改进点
npm6在功能上倒是没太多改动,它将侧重于性能、稳定性和安全性,与先前版本的 npm 相比,性能提高 1700%,详细信息可查看这里
5、npm7令人兴奋的新功能
-
支持自动安装
peerDependencies -
支持workspaces
npm 为了跟随 monorepo 的步伐,在版本7中推出了 workspaces 功能,这个功能可以让我们在一个目录下维护多个项目,项目之间支持相互引用而不用我们手动去通过 npm link 去进行链接。同时提高了依赖的安装效率,如果多个项目依赖了同一个包,那这个包也只会在全局安装一次,详细介绍可查看官网。
四、npm install 做了啥?
1、package.lock.json
在介绍 npm5 特性的时候介绍了 package.lock.json 这个文件的作用,他就是用来锁定依赖的信息,同时它的整体结构是和 node_modules 是一样的,这样就能保证每次安装依赖时都能得到结构和版本信息都相同的 node_mosules 树,我们可以先看一下这个文件的结构,我们以 express 为例:
"express": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/express/-/express-4.18.0.tgz",
"integrity": "sha512-EJEXxiTQJS3lIPrU1AE2vRuT7X7E+0KBbpm5GSoK524yl0K8X+er8zS2P14E64eqsVNoWbMCT7MpmQ+ErAhgRg==",
"requires": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "1.20.0",
"content-disposition": "0.5.4",
"content-type": "~1.0.4",
// ...
}
},
"send": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz",
"integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==",
"requires": {
// ...
},
"dependencies": {
"ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
}
}
},
每个包中都有存在一些字段用来描述这个包的信息:
-
version
当前已安装的包的版本,这个包会被放在顶层的 node_mosules 中。
-
resolved
安装包的来源
-
integrity
包对应的 hash 值,用来验证包的完整性
-
requires
当前包的依赖,和这个包的 package.json 中
dependencies的依赖项相同。 -
dependencies
表示安装在当前包的 node_mosules 中的依赖,当然这个属性不是所有的包都有的,只有当版本存在冲突时才会在子包的 node_mosules 中进行安装。
为了更好的了解 package.lock.json 的作用,我们还是拿 express 来说:
我们先在命令行执行以下命令:
npm cache clean --force
这条命令就是清空本地缓存,再执行 npm i --timing=true --loglevel=verbose 去安装 express,我们会在控制台看到这些信息:
我们看到 npm 会去 npm 源la qu dui ying de bao
拉去对应的包,好,现在我们删除 node_mosules 文件夹,然后再执行一遍安装命令,就会出现下面这种情况:
我们看到 npm 并没有去远程拉去资源,因为package-lock.json 中已经缓存了每个包的具体版本和下载链接,不需要再去远程仓库进行查询,然后直接进入文件完整性校验环节,减少了大量网络请求。
2、缓存
首先我们先查看下缓存的位置,我们使用 npm config get cache 就行, mac 上一般在我们的 /User/username/.npm下,我们进入到这个文件夹下:
这个文件夹下有一个
_cacache 文件夹,里面就保存了我们的缓存信息,进入发现会有三个文件夹:
- content-v2 content-v2 中存放的是压缩后的包文件,我们进入到其中一个包的文件夹下:
发现最后有一串字符,其实这个就是这个包的压缩包,我们解压一下:
先是是不是很熟悉了,解压后果然是这个包对应的文件,我们可以使用 vim 来查看 index.js 文件。
- index-v5
index-v5 中存放的其实是 content-v2 文件的索引,当我们通过 npm install 去安装依赖时,如果有 lock 文件,就会将这个依赖对应的 integrity、version、name 取出来生成一个唯一的 key, 这个 key 就能对应上 index-v5下的缓存记录,如果 index-v5 种存在这个 key,就会取到它对应的 value,即包的 hash 值,然后根据这个 hash 值去 context-v2 中寻找对应的压缩包,然后就可以通过 pacote 将这个压缩包解压到项目的 node_mosules 中,这样就可以省去网络请求和包校验的时间。
我们可以用 vim 查看下 index-v5 下文件中的内容:
五、为什么我们能直接运行 npm?
npm 其实就是一个命令行工具,我们在安装 node 环境的的时候会自动将这个工具下下来并安装到全局,我们在终端执行 npm 的时候就会找到 npm 对应的 bin 文件并执行里面的代码。
一般我们通过 npm install -g xxx 安装的包都会保存在全局的 node_modules 中,那这个 node_mosules 在哪呢?我们可以执行下面这个命令进行查看:
npm config get prefix
// 地址
/Users/username/.nvm/versions/node/v16.14.2
因为我使用了 nvm 来管理 node 的版本,所以每个版本的 node 都有对应的 node_mosules,我们进入对应版本的 lib 文件夹下我们就能看到了。
我们进入 npm 对应的文件夹,使用 vim 打开他的 package.json 就能看到 npm 对 bin 属性的配置:
我们执行 npm/npx 都是在执行对应文件的代码。
看到这里有同学就要问了,全局安装我知道了,但是有的时候我们也可以本地安装对应的命令行工具,比如 @babel/cli,我们也可以通过在 package.json 中的 script 配置运行命令:
{
scripts: {
babel: babel ./xxx
}
}
// npm run babel 运行
那此时为什么能运行呢?它和直接在命令行运行 babel 有什么区别呢?
当我们执行 npm run babel 时 npm 首先会去项目的 node_mosules 文件中寻找 .bin 文件,然后在 .bin 文件中寻找 babel 这个命令对应的文件,如下所示:
我们看到这个文件其实是个软链接文件,他链接的是 @babel/cli 这个包的 bin 文件中的 babel.js 文件,找到这个文件之后就会执行这个文件中的代码,这就完成了一次命令的执行。
往期好文