每次克隆下别人的代码后,执行的第一步就是 npm install
安装依赖包,安装成功后所有的包都会放在项目的 node_modules
文件夹下,也会自动生成 package-lock.json
文件。有没有好奇过 node_modules
下的文件都是啥?package-lock.json
文件的作用是啥?
本文主要解决以下几个问题:
package.json
中的dependencies
和devDependencies
的区别是啥,peerDependencies
、bundledDependencies
、optionalDependencies
又是啥?- 为什么有的命令写在
package.json
中的script
中就可以执行,但是通过命令行直接执行就不行? - 为什么需要
package-lock.json
文件? - 一个包在项目中有可能需要不同的版本,最后安装到根目录
node_modules
中的具体是哪个版本?
带着这几个问题,我们先从 package.json
文件说起。
package.json
官方文档中列出了好多属性,感兴趣的可以一个个看一遍。下面只列出其中几个比较常用且重要的属性。
name & version
如果想要发布一个 npm
包,name
和 version
属性是必须的。他们两个组合会形成一个唯一的标识来表名当前包。以后每更新一次包,version
就需要进行相应的更改。如果你不打算发布包,只想在本地使用,这两个字段不是必须的。
name
字段命名的规则如下:
- 长度不能超过214个字符(对于有scoped的包,该限制包括scoped字段)(什么是Scoped packages?)
- 有作用域的包名字可以以.或者_开头,没有作用域限制的不可
- 不能含有大写字母
- 不能含有非URL安全的字符
version
字段
版本号需要符合 semver
(语义化版本号)规则,具体版本格式为:主版本号.次版本号.修订号
, 如1.1.0。
- 主版本号(major):做了不兼容的 API 修改
- 次版本号(minor):做了向下兼容的功能性新增
- 修订号(patch):做了向下兼容的问题修正
当有一些先行版本需要发布时,可以在 主版本号.次版本号.修订号
之后加上一个中划线和标识符如alpha(内部版本)、beta(公测版本)、rc(候选版本)等来表明。
以vue的版本为例:
- 最新的稳定版本:3.0.5
- 最新的rc版本:3.0.0-rc.13
- 最新的beta版本:3.0.0-beta.24
- 最新的alpha版本:3.0.0-alpha.13
可以通过 npm install semver
来检查一个包的命名是否符合 semver
规则。有关 semver
具体的说明可以看这里
dependencies & devDependencies
dependencies
和 devDependencies
大家应该都不陌生,通过 npm install xx --save
安装的包会写入 dependencies
中,通过 npm install xx --save-dev
安装的包会写入 devDependencies
。
dependencies
中的包是生产环境的依赖,属于线上代码的一部分,比如 vue
、axios
、veui
等。devDependencies
中的包是开发环境的依赖,只是在本地开发的时候需要依赖这里的包,比如 vue-loader
、eslint
等。
我们平时用的 npm install
命令既会安装 dependencies
中的包,也会安装 devDependencies
中的包。如果只想安装 dependencies
中包,可以使用 npm install --production
或者将 NODE_ENV
环境变量设置为 production
,通常在生成环境我们会这么用。
需要注意的是,一个模块会不会被打包取决于我们在项目中是否引入了该模块,跟该模块放在 dependencies
中还是 devDependencies
并没有关系。
对于我们的项目来说,把用到的包写在 dependencies
或者 devDependencies
并没有什么区别。但要是做为一个包发到 npm
上时,写在 devDependencies
中的依赖不会被下载。
peerDependencies & bundledDependencies & optionalDependencies
这三个属性在平时我们的项目开发中都用不到。不同于 dependencies
& devDependencies
面向的是包的使用者,peerDependencies
& optionalDependencies
& bundledDependencies
这三个属性是面向包的发布者。
peerDependencies
我们在一些 node_modules
包的 package.json
中可以看到 peerDependencies
,它用来表明如果你想要使用此插件,此插件要求宿主环境所安装的包。比如项目中用到的 veui1.0.0-alpha.24
版本中:
"peerDependencies": {
"vue": "^2.5.16"
}
这表明如果你想要使用 veui
的 1.0.0-alpha.24
版本,所要求的 vue
版本需要满足 >=2.5.16
且 <3.0.0
。
在 npm3.x
以上版本中,如果安装结束后宿主环境没有满足 peerDependencies
中的要求,会在控制台打印出警告信息。
bundledDependencies
当我们想在本地保留一个 npm
完整的包或者想生成一个压缩文件来获取 npm
包的时候,会用到 bundledDependencies
。本地使用 npm pack
打包时会将 bundledDependencies
中依赖的包一同打包,当 npm install
时相应的包会同时被安装。需要注意的是,bundledDependencies
中的包不应该包含具体的版本信息,具体的版本信息需要在 dependencies
中指定。
例如一个 package.json
文件如下:
{
"name": "awesome-web-framework",
"version": "1.0.0",
"bundledDependencies": [
"renderized",
"super-streams"
]
}
当我们执行 npm pack
后会生成 awesome-web-framework-1.0.0.tgz
文件。该文件中包含 renderized
和 super-streams
这两个依赖,当执行 npm install awesome-web-framework-1.0.0.tgz
下载包时,这两个依赖会被安装。
当我们使用 npm publish
来发布包的话,这个属性不会起作用。
optionalDependencies
从名字上就可以看出,这是可选依赖。如果有包写在 optionalDependencies
中,即使 npm
找不到或者安装失败了也不会影响安装过程。需要注意的是, optionalDependencies
中的配置会覆盖 dependencies
中的配置,所以不要将同一个包同时放在这两个里面。
如果使用了 optionalDependencies
,一定记得要在项目中做好异常处理,获取不到的情况下应该怎么办。
scripts
定义在 scripts
中的命令,我们通过 npm run <command>
就可以执行。npm run <command>
是 npm run-script <command>
的简写。如果不加 command
,则会列出当前目录下可执行的所有脚本。
test
、start
、restart
、stop
这几个命令执行时可以不加 run
,直接 npm test
、npm start
、npm restart
、npm stop
调用即可。
env
是一个内置的命令,可以通过 npm run env
可以获取到脚本运行时的所有环境变量。自定义的 env
命令会覆盖内置的 env
命令。
之前开发中遇到一种情况,比如我们想本地通过 http-server
启动一个服务器,如果事先没有全局安装过 http-server
包,只是安装在对应项目的 node_modules
中。在命令行中输入 http-server
会报 command not found
,但是如果我们在 scripts
中增加如下一条命令就可以执行成功。
scripts: {
"server": "http-server",
"eslint": "eslint --ext .js"
}
为什么同样的命令写在 scripts
中就可以成功,但是在命令行中执行就不行呢?这是因为 npm run
命令会将 node_modules/.bin/
加入到 shell
的环境变量 PATH
中,这样即使局部安装的包也可以直接执行而不用加 node_modules/.bin/
前缀。当执行结束后,再将其删除。
是不是还是没明白,下面我们来具体分析一下。
首先要明确什么是环境变量。环境变量就是系统在执行一个程序,但是没有明确表明该程序所在的完整路径时,需要去哪里寻找该程序。
对于局部安装的包,拿 eslint
来说,npm
会在本地项目 ./node_modules/.bin
目录下创建一个指向 ./node_moudles/eslint/bin/eslint.js
名为 eslint
的软链接,即执行 ./node_modules/.bin/eslint
实际上是执行 ./node_moudles/eslint/bin/eslint.js
。而当我们执行 npm run eslint
的时候,node_modules/.bin/
会被加入到环境变量 PATH
中,实际上执行的是 ./node_modules/.bin/eslint
,这样就串起来了。
理论说完之后,我们来实际验证一下。
首先看一下系统的环境变量。直接执行 env
即可。
然后在当前项目目录下通过npm run env
查看脚本运行时的环境变量。
通过对比可以发现,运行时的 PATH
多了两个环境变量。即 npm
指令的路径和项目 /node_modules/.bin
的路径。
以上就是 package.json
中常用 & 重要的几个属性,接下来我们来看一看 package-lock.json
。
package-lock.json
对于 npm
,package.json
文件可以看成它的输入,node_modules
可以做为它的输出。在理想情况下,npm
应该是一个纯函数,无论何时执行相同的 package.json
文件都应该产生完全相同的 node_modules
树。在一些情况下,这确实可以做到。但是在大多情况下,都实现不了。主要有以下几个原因:
- 使用者的
npm
版本有可能不同,不同的npm
版本有着不同的安装算法 - 自上次安装之后,有些符合
semver-range
的包已经有新的版本发布。这样再有别人安装的时候,会安装符合要求的最新版本。比如引入vue
包:vue:^2.6.1
。A小伙伴下载的时候是2.6.1
,过一阵有另一个小伙伴B入职在安装包的时候,vue
已经升级到2.6.2
,这样npm
就会下载2.6.2
的包安装在他的本地 - 针对第二点,一个解决办法是固定自己引入的包的版本,但是通常我们不会这么做。即使这样做了,也只能保证自己引入的包版本固定,也无法保证包的依赖的升级。比如
vue
其中的一个依赖lodash
,lodash:^4.17.4
,A下载的是4.17.4
, B下载的时候有可能已经升级到了4.17.21
为了解决上述问题,npm5.x
开始增加了 package-lock.json
文件。每当 npm install
执行的时候,npm
都会产生或者更新 package-lock.json
文件。package-lock.json
文件的作用就是锁定当前的依赖安装结构,与 node_modules
中下所有包的树状结构一一对应。
有了这个 package-lock.json
文件,就能保证团队每个人安装的包版本都是相同的,不会出现有些包升级造成我这好使别人那不好使的兼容性问题。
下面是 less
的 package-lock.json
文件结构:
"less": {
"version": "3.13.1",
"resolved": "https://registry.npmjs.org/less/-/less-3.13.1.tgz",
"integrity": "sha512-SwA1aQXGUvp+P5XdZslUOhhLnClSLIjWvJhmd+Vgib5BFIr9lMNlQwmwUNOjXThF/A0x+MCYYPeWEfeWiLRnTw==",
"dev": true,
"requires": {
"copy-anything": "^2.0.1",
"errno": "^0.1.1",
"graceful-fs": "^4.1.2",
"image-size": "~0.5.0",
"make-dir": "^2.1.0",
"mime": "^1.4.1",
"native-request": "^1.0.5",
"source-map": "~0.6.0",
"tslib": "^1.10.0"
},
dependencies: {
"copy-anything": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-2.0.3.tgz",
"integrity": "sha512-GK6QUtisv4fNS+XcI7shX0Gx9ORg7QqIznyfho79JTnX1XhLiyZHfftvGiziqzRiEi/Bjhgpi+D2o7HxJFPnDQ==",
"dev": true,
"requires": {
"is-what": "^3.12.0"
}
}
}
}
- version: 包的版本信息
- resoloved: 包的安装源
- integrity:一个
hash
值,用来校验包的完整性 - dev:布尔值,如果为
true
,表明此包如果不是顶层模块的一个开发依赖(写在devDependencies
中),就是一个传递依赖(如上面less
中的copy-anything
)。 - requires: 对应子依赖的依赖,与依赖包的
package.json
中dependencies
的依赖项相同 - dependencies:结构与外层结构相同,存在于包自己的
node_modules
中的依赖(不是所有的包都有,当子依赖的依赖版本与根目录的node_modules
中的依赖冲突时,才会有)
通过分析上面的 package-lock.json
文件,也许会有一个问题。为什么有的包可以被安装在根目录的 node_modules
中,有的包却只能安装在自己包下面的 node_modules
中?这就涉及到 npm
的安装机制。
npm从 3.x
开始,采用了扁平化的方式来安装 node_modules
。在安装时,npm
会遍历整个依赖树,不管是项目的直接依赖还是子依赖的依赖,都会优先安装在根目录的 node_modules
中。遇到相同名称的包,如果发现根目录的 node_modules
中存在但是不符合 semver-range
,会在子依赖的 node_modules
中安装符合条件的包。
具体的安装算法如下:
- 从磁盘加载
node_modules
树 - 克隆
node_modules
树 - 获取
package.json
文件和分类完毕的元数据信息并把元数据信息插入到克隆树中 - 遍历克隆树,检测是否有丢失的依赖。如果有,把他们添加到克隆树中,依赖会尽可能的添加到最高层
- 比较原始树和克隆树,列出将原始树转换为克隆树所要采取的具体步骤
- 执行,包括
install
,update
,remove
andmove
以 npm
官网的例子举例,假设 package{dep}
结构代表包和包的依赖,现有如下结构:A{B,C}
, B{C}
, C{D}
,按照上述算法执行完毕后,生成的 node_modules
结构如下:
A
+-- B
+-- C
+-- D
对于 B
,C
被安装在顶层很好理解,因为是 A
的直接依赖。但是 B
又依赖 C
,安装 C
的时候发现顶层已经有 C
了,所以不会在 B
自己的 node_modules
中再次安装。C
又依赖 D
,安装 D
的时候发现根目录并没有 D
,所以会把 D
提升到顶层。
换成 A{B,C}
, B{C,D@1}
, C{D@2}
这样的依赖关系后,产生的结构如下:
A
+-- B
+-- C
+-- D@2
+-- D@1
B
又依赖了 D@1
,安装时发现根目录的 node_modules
没有,所以会把 D@1
安装在顶层。C
依赖了 D@2
,安装 D@2
时,因为 npm
不允许同层存在两个名字相同的包,这样就与跟目录 node_modules
的 D@1
冲突,所以会把 D@2
安装在 C
自己的 node_modules
中。
模块的安装顺序决定了当有相同的依赖时,哪个版本的包会被安装在顶层。首先项目中主动引入的包肯定会被安装在顶层,然后会按照包名称排序(a-z)进行依次安装,跟包在 package.json
中写入的顺序无关。因此,如果上述将 B{C,D@1}
换成 E{C,D@1}
,那么 D@2
将会被安装在顶层。
有一种情况,当我们项目中所引用的包版本较低,比如 A{B@1,C}
,而 C
所需要的是 C{B@2}
版本,现在的结构应该如下:
A
+-- B@1
+-- C
+-- B@2
有一天我们将项目中的 B
升级到 B@2
,理想情况下的结构应该如下:
A
+-- B@2
+-- C
但是现在 package-lock.json
文件的结构却是这样的:
A
+-- B@2
+-- C
+-- B@2
B@2
不仅存在于根目录的 node_modules
下,C
下也同样存在。这时需要我们手动执行 npm dedupe
进行去重操作,执行完成后会发现 C
下面的 B@2
会消失。大家可以在自己的项目中试一试,优化一下 package-lock.json
文件的结构。
以下是在我的项目中执行 npm dedupe
的结果:
removed 41 packages, moved 15 packages and audited 1994 packages in 18.538s
在 npm5.x
之前,可以手动通过 npm shrinkwrap
生成 npm-shrinkwrap.json
文件,与 package-lock.json
文件的作用相同。当项目中同时存在 npm-shrinkwrap.json
和 package-lock.json
,将以 npm-shrinkwrap.json
为主。
执行 npm dedupe
去重之后的 node_modules
会瘦身一些,但做为一个有追求的程序员怎么能局限于仅仅瘦身呢,我们要紧跟时代的潮流,对一些过时的东西say no。这时, npm-outdated
命令就派上用场了。
npm-outdated
命令是用来检查项目中用到的包版本在当前是否已经过时。如果有过时的包,会在控制台打印出信息。默认情况下,只会列出项目中顶层依赖的过时信息。如果想要更深层的查看,可以加上 depth
参数,如 npm-outdated --depth=1
以下是在我的项目中执行 npm-outdated
的部分结果。从结果中可以看到包的当前版本,符合 semver-range
的最高版本以及当前的最新版本等信息。
Package Current Wanted Latest Location
animate.css 3.7.0 3.7.2 4.1.1 xxx
autoprefixer 9.7.6 9.8.6 10.2.5 xxx
axios 0.19.2 0.19.2 0.21.1 xxx
babel-eslint 7.2.3 7.2.3 10.1.0 xxx
babel-loader 7.1.5 7.1.5 8.2.2 xxx
有需求的小伙伴可以尝试把自己项目中用到的已经过时的包升级一下。
本文只是一些理论基础,之后会介绍一些 npm
源码相关的知识。