开发中遇到过的 npm 疑惑解答 淡淡的烟草味﹌ 2023-01-17 14:59 85阅读 0赞 点击上方 前端瓶子君,关注公众号 回复算法,加入前端编程面试算法每日一题群 ![94f484de9097dd50a59c66fe529bc71a.png][] # 来源:阳呀呀 # https://segmentfault.com/a/1190000039289332 每次克隆下别人的代码后,执行的第一步就是`npm install`安装依赖包,安装成功后所有的包都会放在项目的`node_modules`文件夹下,也会自动生成`package-lock.json`文件。有没有好奇过`node_modules`下的文件都是啥?`package-lock.json`文件的作用是啥? 本文主要解决以下几个问题: 1. `package.json`中的`dependencies`和`devDependencies`的区别是啥,`peerDependencies`、`bundledDependencies`、`optionalDependencies`又是啥? 2. 为什么有的命令写在`package.json`中的`script`中就可以执行,但是通过命令行直接执行就不行? 3. 为什么需要`package-lock.json`文件? 4. 一个包在项目中有可能需要不同的版本,最后安装到根目录`node_modules`中的具体是哪个版本? 带着这几个问题,我们先从`package.json`文件说起。 ## package.json ## 最靠谱的官方文档请点这里:https://docs.npmjs.com/cli/v6/configuring-npm/package-json\#people-fields-author-contributors 官方文档中列出了好多属性,感兴趣的可以一个个看一遍。下面只列出其中几个比较常用且重要的属性。 ### 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具体的说明可以看这里:https://docs.npmjs.com/cli/v6/using-npm/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`中的要求,会在控制台打印出警告信息。 ![442b28a619b91da58bea13ab83529e7a.png][] *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`即可。![f466eece712081b558dac24f9f38840f.png][] 然后在当前项目目录下通过`npm run env`查看脚本运行时的环境变量。 ![5f1018e1e29a499474b50e48efccdd1d.png][] 通过对比可以发现,运行时的`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`,表明此包如果不是顶层模块的一个开发依赖(写在`devDependencie`s中),就是一个传递依赖(如上面`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` and `move` 以`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`源码相关的知识。 ## 参考文章 ## 1. npm官网 2. 前端工程化 - 剖析npm的包管理机制 3. 前端工程化(5):你所需要的npm知识储备都在这了 4. semver ## 最后 ## 欢迎关注【前端瓶子君】✿✿ヽ(°▽°)ノ✿ 回复「算法」,加入前端编程源码算法群,每日一道面试题(工作日),第二天瓶子君都会很认真的解答哟! 回复「交流」,吹吹水、聊聊技术、吐吐槽! 回复「阅读」,每日刷刷高质量好文! 如果这篇文章对你有帮助,「在看」是最大的支持 》》面试官也在看的算法资料《《 “在看和转发”就是最大的支持 [94f484de9097dd50a59c66fe529bc71a.png]: /images/20221021/9501930c60284d77815ef3fca10e36a3.png [442b28a619b91da58bea13ab83529e7a.png]: /images/20221021/778830b846724dd6bdef7f40c6981dc8.png [f466eece712081b558dac24f9f38840f.png]: /images/20221021/4c8636793e5748d6864316a153f06ef0.png [5f1018e1e29a499474b50e48efccdd1d.png]: /images/20221021/1ca3ce5992d14fac91b6aeca3bcd99b7.png
还没有评论,来说两句吧...