前言
在众多前端工程化步骤中,依赖包的管理最直接影响着每一位开发者。安装、更新依赖包是每一个开发都会接触的事。但是,在排查bug的时候依赖包问题又往往是最容易被忽略的。因此,本文以node官方的包管理器npm为例,讲一下依赖包管理的问题。
npm 的安装机制
npm安装依赖的具体流程如上图所示,需要注意的是,当package.json与package-lock.json中依赖包声明版本不一致时会根据npm版本的不同进行不同安装策略。在npm v5.0.x中会根据package-lock.json下载;在npm v5.1.0 - v5.4.2中会根据package.json版本安装并更新package-lock.json文件;在npm v5.4.2及以上版本中当package.json声明的依赖版本规范与package-lock.json声明版本兼容则根据package-lock.json安装,如果不兼容,则按照package.json安装并更新package-lock.json。
依赖树扁平化
npm在确定首层依赖后会递归构建依赖树,工程本身为依赖树根节点,每个首层依赖模块都为根节点下的子树。每个节点在递归工程中会确定节点模块信息如版本、下载地址、压缩包地址等。在npm v2版本时,安装依赖包仅采用简单的递归安装,在根据 dependencies 和 devDependencies 属性中指定的包确定首层依赖后便递归安装各个包到子依赖的node_modules中,直到子依赖不再依赖其他模块。形成的依赖树如下图所示:
这样的目录结构层次分明、增删简单,但重复依赖会不断增大项目体积,造成大量冗余。为解决此类问题,npm v3的 node_modules 目录改成了更为扁平状的层级结构,尽量把依赖以及依赖的依赖平铺在 node_modules 文件夹下共享使用。npm v3会遍历所有节点,当发现重复模块时直接丢弃,只有遇到依赖版本不兼容时继续采用npm v2的处理方式继续安装。形成的依赖树如下图所示:
但是npm v3会带来一个新的问题,如果package.json中依赖顺序变化会导致依赖树的变化,具体如下图所示:
由此可见,npm v3带来的扁平化管理并未完全解决冗余问题。这类问题最后在npm v5版本引入package-lock.json文件配合依赖树扁平化得以解决。
缓存机制
通过npm config get cache
指令可以查看本地缓存,通常在/.npm文件下的_cacache目录下有三个文件:content-v2 、index-v5 、tmp 。index-v5 中存放的是content-v2 文件的索引,而content-v2 中存放的是依赖包的二进制文件。在安装资源的时候会根据package-lock.json文件中integrity 、version 和name 三个属性生成唯一的key,通过key去匹配index-v5 文件中的缓存记录。如果存在缓存记录,并根据记录中的hash值去寻找在content-v2 中对应的tar包。最后,通过pacote 将二进制文件解压至项目中的node_modules目录中,省去了资源下载的网络开销。其中,pacote 是依赖npm-registry-fetch来下载包的,npm-registry-fetch 可以通过设置 cache 字段进行相关的缓存工作。值得注意的是 ,缓存策略是从npm v5开始的,在npm v5之前每个缓存模块都在~./npmrc文件中以模块名的格式直接存储,存储格式为 {cache}{name}{version}。
npm ci与npm install
根据npm install
的安装机制,package-lock.json文件有可能会因为手动操作而改变。但有时候我们并不想lockfile有任何变化需要确保依赖树的绝对一致。npm ci
指令与npm install
相比,就具备确保依赖树一致的功能。npm ci
会完全按照lockfile去安装依赖,但值得注意的是,当lockfile中的声明版本不满足package.json中声明版本指定的semver规则会报错退出,并不会往下执行或更新lock文件。
package.json
无论使用什么包管理器,package.json文件都是依赖库管理的核心文件。
创建package.json
创建package.json主要分为两种方法:当使用脚手架生成项目时,脚手架会自动生成package.json;使用npm init 或 npm init -y 手动创建package.json。生成的基础内容如下:
常见属性
package.json中有许多属性,用以进行控制版本依赖、发包、校验等工作。具体属性及简要作用如下图所示:
在众多属性中,着重讲一下重要属性name、version、xxxxDependencies、resolutions及脚本配置属性script:
name:表示项目名称,该字段决定了你发布的包在 npm 的名字。
name属性命名规则:
- 名称必须小于或等于 214 个字符。这包括范围包的范围。
- 作用域包的名称可以以点或下划线开头。如果没有范围,这是不允许的。
- 新包的名称中不得包含大写字母。
- 该名称最终成为 URL、命令行参数和文件夹名称的一部分。因此,名称不能包含任何非 URL 安全字符。
version : 表示项目的版本号,version 属性必须采用major.minor.patch 格式。minor 代表主版本号,不可向前兼容的更改,比如系统重构、API重构等。minor 为次版本号,代表功能模块变更这类可兼容的更改,一般为API新增等操作。patch为补丁版本,一般用于bug fix或安全问题。如果计划发包,「name」和「version」字段是必须的,名称和版本号会形成唯一标识。
xxxxDependencies
- dependencies生产环境依赖:线上生产环境的依赖包
- devDependencies开发环境依赖:开发依赖,不会自动被下载,只在开发环境中使用
- peerDependencies兼容依赖:用于声明宿主环境所需依赖兼容版本,属性中声明的包不会被自动检测并安装也不会被打包。如果项目中以来不满足peerDependencies条件会打印警告。
- bundledDependencies 捆绑依赖:
npm pack
打包时将该属性所写依赖项打包到发布包中,方便用户安装时不需要手动安装这些依赖项。 - optionalDependencies 可选依赖:表示安装对应依赖失败也不会影响安装过程,optionalDependencies 会覆盖dependencies中的同名依赖包,不建议使用会造成项目的不确定性和复杂性。
resolutions: 用于解决依赖项冲突的 npm 特殊字段,如果项目依赖于 package-a 和 package-b,而这两个包都依赖于 package-c,且两者依赖的 package-c 版本不同,可以用resolutions 字段来指定应该使用哪个版本。
script: 定义可执行脚本命令,供npm直接调用。
在终端中执行npm run start 相当于执行nodemon index.ts,而npm run是npm run-script的缩写。每当执行npm run,系统会自动新建一个Shell(一般是Bash),并在这个Shell中执行命令,所以只要Shell可执行的命令都可以写在脚本中。与此同时,系统当前目录的node_modules/.bin子目录加入PATH变量,执行结束后,再将PATH变量恢复原样。所以node_modules/.bin子目录里面的所有脚本,都可以直接用脚本名调用,而不必加上路径。
脚本中可以使用Shell通配符和传参:
其中,表示任意文件名, *表示任意一层子目录,用- -标明传入参数,也可以使用命令传入参数。
每个 npm script 有 pre 和 post 两个钩子, pre 钩子在脚本执行前将被触发, post 则是在脚本执行后触发。例如build 脚本命令的钩子就是prebuild 和postbuild 。
在执行npm run build 的时候,会自动执行npm run prebuild && npm run build && npm run postbuild
。因此可以在prebuild 和postbuild 中添加一些操作来优化流程。npm中默认提供的脚本命令如下:
除了默认脚本命令,自定义脚本命令也同样有这两个钩子,但是不支持双重pre 或者双重post 即preprebuild、postpostbuild。 除此以外,还可以通过环境变量process.env
对象与npm_package_
前缀拿到package.json的字段值。
package.json中的版本锁定
在安装依赖时,无论是开发环境还是生产环境,package.json文件在版本号前可能会出现多种标志,
这些标志代表着package.json对于依赖包版本的不同管理控制策略。
- ^ 表示更新【次版本】,例如package.json中版本号是^2.1.0,当发布包更新到2.2.0,哪怕没有重新安装,依赖库也可能自动更新到2.2.0。不过当发布包更新到3.0.0版本时表示为主版本更新,本地依赖库是不会更新到3.0.0版本的。
- ~表示更新【补丁版本】,例如package.json中版本号是^2.1.0,当发布包更新到2.1.1,依赖库会自动更新到2.1.1,但当发布包更新到2.2.0时,依赖库不会更新至2.2.0版本。
- *表示会安装最新版本的依赖包,比如*2.1.0,发布包更新到3.x.x的时候依赖库便会下载3.x.x。
- >: 接受高于指定版本的任何版本。
- >=: 接受等于或高于指定版本的任何版本。
- =<: 接受等于或低于指定版本的任何版本。
- <: 接受低于指定版本的任何版本。
- 无符号: 仅接受指定的特定版本。
- latest: 使用可用的最新版本。
因此,只要去掉package.json版本号前的标志就可以"绝对锁定"依赖版本号,去除依赖版本差异带来的bug。
package-lock.json
在npm install之后,项目希望总是生成完全相同的node_modules 树以确保项目稳定性。但npm3的安装机制会按照package.json里的顺序依次解析,依赖的顺序会影响node_modules 树的生成。此外,package.json文件也只能束缚项目的直接依赖,对于间接依赖没有管理锁定也会导致生成的node_modules 树不完全相同。
为解决上述问题,确保同一项目总是会生成相同的node_modules 树以确保稳定性。npm5中引入了package-lock.json文件来规范node_modules树的生成。
package-lock.json文件构成如上图所示,该文件将node_modules树的生成数据化,使依赖包的依赖关系一目了然。其中,requires
与dependencies字段的功能常令人混淆。简单来说,requires
表示所有需要安装的依赖,而dependencies
表示与根目录node_modules冲突的依赖,冲突的依赖会在dependencies
属性中记录并安装在当前依赖下的node_modules文件中。
package-lock.json什么时候会变
- package-lock.json文件在
npm install
的时候会自动生成 - 修改依赖位置,将部分依赖从开发依赖变成生产依赖,会影响package-lock.json中依赖的
dev
字段 - 切换镜像时,执行
npm install
时也会修改 package-lock.json中的resolved
字段 - 使用
npm install
添加或npm uninstall
移除包的时候,也会修改 package-lock.json - 更新某个包的版本的时候,也会修改 package-lock.json
package-lock.json需要递交到仓库嘛?
npm 官网建议:把 package-lock.json 一起提交到代码库中,不要 ignore,以此确保团队所有开发者及CI环节执行生成的依赖树一致。但是在执行 npm publish进行发包的的时候,应该将其忽略。因为发布的npm包需要被其他仓库所依赖,如果锁定了依赖包的版本,会导致发布的包与项目其他依赖包无法共享依赖造成不必要的冗余。npm默认不会把package-lock.json文件发不出去。
最佳实践建议
借用字节的一个小问题npm 和 yarn不一样吗?(续篇)文章作者的建议,个人认为很合理,仅供参考:
- 优先去使用 npm 官方已经稳定的支持的版本, 以保证 npm 的最基本先进性和稳定性
- 当我们的项目第一次去搭建的时候, 使用
npm install
安装依赖包, 并去提交 package.json、package-lock.json, 至于node_moduled目录是不用提交的。 - 当我们作为项目的新成员的时候,
checkout/clone
项目的时候, 执行一次npm install
去安装依赖包。 - 当我们出现了需要升级依赖的需求的时候:
- 升级小版本的时候, 依靠 npm update
- 升级大版本的时候, 依靠 **npm install@ **
- 当然我们也有一种方法, 直接去修改 package.json 中的版本号, 并去执行 npm install 去升级版本
- 当我们本地升级新版本后确认没有问题之后, 去提交新的 package.json 和 **package-lock.json **文件。
- 对于降级的依赖包的需求: 我们去执行npm install @ 命令后,验证没有问题之后, 是需要提交新的 package.json 和 package-lock.json 文件。
- 删除某些依赖的时候:
- 当我们执行 npm uninstall 命令后, 需要去验证,提交新的 package.json 和 package-lock.json 文件。
- 或者是更加暴力一点, 直接操作
package.json
, 删除对应的依赖, 执行 npm install 命令, 需要去验证,提交新的package.json 和 package-lock.json 文件。
- 当你把更新后的package.json 和 package-lock.json提交到代码仓库的时候, 需要通知你的团队成员, 保证其他的团队成员拉取代码之后, 更新依赖可以有一个更友好的开发环境保障持续性的开发工作。
- 任何时候我们都不要去修改 package-lock.json,这是交过智商税的。
- 如果你的 package-lock.json 出现冲突或问题, 我的建议是将本地的 package-lock.json 文件删掉, 然后去找远端没有冲突的 package.json 和 package-lock.json , 再去执行
npm install
命令。
总结
以前端基础建设与架构30讲中的五个问题作为总结。
- 删除node_modules和lockfiles文件再重新install的操作是否存在风险?答:存在风险,轻易删除lockfiles文件会导致项目安装的依赖在package.json版本控制范围内变动。
- 把所有依赖都安装到dependencies中,不区分devDependencies会有问题嘛?答:具体根据项目性质而定,前端spa应用项目或者ssg项目可以,后端、ssr或公开库不建议那么做。
- 应用依赖了公共库A和公共库B,同时公共库A也依赖了公共库B,那么公共库B会被多次安装或者重复打包嘛?答:应用依赖的公共库B与公共库A依赖的公共库B如果不存在冲突则会共用一个不会重复安装、打包。如果存在版本冲突,会在公共库B的依赖目录下再添加一个公共库B的依赖。
- 一个项目中,可以即有人用npm又有人用yarn嘛?答:lock文件不同,可能会存在冲突导致最终安装版本不一致。
- 是否应该递交lockfiles文件到项目仓库?答:通常应该把 package-lock.json 一起提交到代码库中,不要 ignore,以此确保团队所有开发者及CI环节执行生成的依赖树一致。但是在执行 npm publish进行发包的的时候,应该将其忽略。因为发布的npm包需要被其他仓库所依赖,如果锁定了依赖包的版本,会导致发布的包与项目其他依赖包无法共享依赖造成不必要的冗余。npm默认不会把package-lock.json文件发不出去。
参考资料
阮一峰老师的npm script的使用www.ruanyifeng.com/blog/2016/1...
工程的 package.json 中的 ^~ 该保留吗?juejin.cn/post/724481...
npm 依赖管理中容易被忽略细节blog.csdn.net/weixin_3984...
前端基础建设与架构30讲
字节的一个小问题npm 和 yarn不一样吗?(续篇)juejin.cn/post/707165...