前端包管理工具

其实包管理工具就是一种代码共享方案,目前前端开发的库都是以node包的形式进行管理的,而通过包管理工具我们就可以在项目中下载引入某个库进行使用。

可能有人这个时候就有疑问了,为什么前端开发的库都是以node包的形式进行管理的?

  • 由于以前的ECMA并没有明确的模块化方案,随着NodeJS采用了CommonJS模块化系统,使得代码能够被组织为可复用的模块,但可复用就是可以被多人使用,可以是一个团队,往大点说就是世界范围内。

  • 如果模块文件少的话,通过git服务器就可以操作,但随着模块的越来越多,版本也越来越多,各个模块之间还有依赖关系,这个时候需要一个模块管理工具来管理模块的下载、版本更新和依赖关系。(注意这个下载操作,浏览器是个沙盒环境,在不考虑网络通信的开销,单从文件层面考虑就可以pass了,浏览器是不能直接对文件进行操作的,因为考虑安全性问题。)

  • 由于浏览器没有模块化规范,最初的包管理工具仅仅指的是对服务端的模块进行管理,从npm(Node Package Manager)这个名字就可以看出。

  • 从2013年开始,工程化爆发了,工程化包含代码检测、转换等能力,这同样需要操作文件系统。工程化最终目的是为了获得能兼容各种浏览器运行的代码,node又集成了各种各样的库,前端社区顺势服用其生态,形成了“前端库发布到npm”的惯例。

工程化:工程化是指将系统化、标准化的方法应用于项目或产品的设计、开发、测试、部署、维护各个环节,强调通过科学的方法和工具来提高效率、保证质量、降低风险。

简单来说:工程化就是运用模块化和组件化开发,有代码规范和质量检测功能,具有自动化流程能力,不必过多考虑配置如何实现,只需要专注于业务逻辑的一种开发方式。

包管理工具npm

npm(Node Package Manage):Node包管理器,是NodeJS官方推出的包管理工具,我们在安装node的时候已经会默认安装npm。

前面我们提到了,随着前端的发展,前端社区形成了“前端库发布到npm”的惯例,也就是说,npm已经管理大量的前端库,那么如果我们想查看有哪些库,我们就需要查看npm官网:https://www.npmjs.org/。我们也可以通过官网注册自己的npm账号,可以上传自己编写的前端库,为开源社区做一份贡献。

npm的配置文件

当我们进行工程化开发时,我们需要用到各种各样的工具,如webpack、babel、eslint等,而我们都是通过node包的形式来使用它们,那么我们该如何管理这些包呢?

node采用配置文件的形式对这些包进行管理,这个文件就是package.json,但这个文件并不仅仅对标npm,目前,虽已经出现了多种包管理工具,如yarn、cnpm、pnpm,但配置文件都是package.json

package.json记录着项目的名称、版本号、项目描述等,也记录着项目依赖的库的信息和版本号。

我们可以通过npm init [-y]命令来创建package.json文件,当然,也可以手动创建。

package.json的配置项

给出一个简单的package.json配置内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"name": "test",
"version": "1.0.0",
"private": true,
"main": "1.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
"axios": "^1.8.4"
},
"devDependencies": {
"sass": "^1.86.0",
"sass-loader": "^16.0.5"
},
"author": "",
"license": "ISC",
"description": ""
}
  • name:项目名称,必写。

  • version:当前项目的版本号,必写。

  • private:是否私有项目,默认为false。当设置值为true时,npm不会将项目发布到npm仓库中。可以防止自己的项目错误被发布。

  • author:作者相关信息,发布时用。

  • license:开源协议,发布时用。

  • description:项目描述。

  • main:设置程序的入口。

    • node有一套默认模块导入的文件查询规则,但如果我们想自定义入口,就可以通过main设置,比如默认查询文件是index.js,我们修改main值为1.js,那么之只引入模块名后的查找的文件就是1.js
  • scripts:设置项目的运行脚本。

    • 值为对象的形式,键为脚本名称,值为脚本内容。
    • 配置后可以通过npm run + 键名来执行脚本内容。
    • 对于一些特殊的键名,可以不加上run来运行:如start、test、stop、restart等可以省略run直接通过npm start来运行。
  • dependencies:指定开发环境和生产环境都需要依赖的包。

  • devDependencies: 指定开发环境需要的包。

  • peerDependencies:对等依赖,指定该项目依赖的包,比如element-ui,它依赖vue,在使用elment-ui前,必须要存在vue,我们就需要通过peerDependencies来声明vue。

  • engines:指定Node和npm的版本要求。在通过配置项进行安装的过程中,发现对应的引擎版本于指定的不一致,就会报错。也可以用来指定所在的操作系统。

1
2
3
"engines": {
"node": ">=0.10.0"
}
  • browserslist:指定浏览器的兼容性。

依赖的版本管理

我们发现,在dependencies和devDependencies中,我们配置的包版本号都有一个^

这其实是因为npm的包遵循semver版本规范:https://semver.org/lang/zh-CN/

semver的版本规范是X.Y.Z

  • X:主版本号(major),当你做了不可兼容的API修改(可能不兼容之前的版本)。

  • Y:次版本号(minor),当你做了向下兼容的功能性新增(新功能增加,但是兼容之前的版本,比如添加了新的语法)。

  • Z:修订号(patch),当你做了向下兼容的问题修正(比如说功能出现了bug,修复了bug,Z进行更新)。

semver其中有一些在版本号前加特殊符号的形式,比如^~,当然,也可以不加。

  • 当我们什么都不加,只是x.y.z的形式,这代表明确指定了这个版本号,当我们通过配置文件进行下载时,下载的就是这个包的明确指定的版本。

  • 如果版本号是^x.y.z的形式,代表x版本是不变的,y和z永远安装最新的版本,即当我们通过配置文件下载时,会下载该包的x级别的最新版本。

  • 如果版本号时~x.y.z的形式,代表x和y不变,z永远安装最新的版本。

npm insall

npm install也可以简写为npm i

我们可以通过npm install [包名]安装包,安装的包会自动添加到node_modules中,同时会在package.json的dependencies和devDependencies中记录该包和其版本号。

通过npm install命令,我们可以进行全局和局部安装。

  • 全局安装:npm install -g [包名],该包并不记录在package.json中,并不能被项目内部所使用,但可以在命令行中使用,通常用于具有命令行执行包的安装。如yarn,rimraf,nrm等。

  • 局部安装,分为两种情况:

    • 在生产环境和开发环境都可以使用:npm install [包名] [-S/--save]
    • 仅在开发环境使用:npm install [包名] [-D/--save-dev]

当我们克隆别人项目时,我们会发现并没有node_modules文件夹,但存在package.json文件,这时,我们可以通过npm install来安装项目所需要的包,它会根据package.json的配置的包名和它们的版本号进行安装。

package-lock.json

我们发现,我们通过npm install安装包后,会在项目中产生一个package-lock.json文件,我们看一个简单的文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"name": "test",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"dependencies": {
"node_modules/axios": {
"version": "1.8.4",
"resolved": "https://registry.npmmirror.com/axios/-/axios-1.8.4.tgz",
"integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
}
}
}
  • name:项目名称。

  • version:当前项目的版本号。

  • lockfileVersion:lock文件的版本号。

  • requires:使用requires来跟踪模块的状态。

  • dependencies:项目的依赖。

    • version:实际安装的包的版本号。
    • resolved:安装的包的地址。
    • requires/dependencies:安装的包的依赖。
    • integrity:安装的包的校验码,用于从缓存中获取索引,再通过索引去获取压缩包文件。

npm install原理

在以前,npm存在很多弊端,其中有一点很麻烦的就是对下载的包没有缓存策略,每次下载都需要重新通过链接下载,而npm的服务器在国外,就容易造成下载速度很慢或网络连接不上的情况,后来不是出现了cnpm吗,就是国内对npm的一个镜像仓库,但cnpm同样没有缓存策略,后来就出现了yarn,yarn采用了缓存策略,在当时,yarn成为了非常流行的包管理工具,后来npm迫于yarn的压力,在npm5也采用了缓存策略,但yarn在当前仍然是很多人使用的。

下载我们聊聊,npm有了缓存之后,npm install的下载流程。

首先,npm会检测有没有package-lock.json文件

  • 如果没有

    • 分析依赖关系。
    • 从npm register中下载压缩包,如果设置了镜像,会通过镜像服务器下载压缩包。
    • 对获取的压缩包进行缓存。
    • 将压缩包解压到项目的node_modules中。
  • 如果有package-lock.json文件

    • 检查lock文件中包的版本和package.json文件中是否一致,按semver版本规范检测。
      • 不一致,那么会重新构建依赖关系,重构流程按上面没有package-lock.json文件的流程进行。
      • 一致,会从缓存中进行查找。
        • 没有找到,按上面没有package-lock.json文件的流程进行。
        • 查找到,会获取缓存中的压缩文件,解压到当前项目的node_modules中。

npm其他常见命令

  • npm uninstall/uni:删除包。

  • npm rebuild:重新构建包。

  • npm cache clean: 清除缓存。

  • npm list: 列出当前项目依赖的包。

  • npm list -g: 列出全局安装的包。

  • npm config list: 列出npm配置信息。

  • npm view/info [包名]: 查看包的信息。

  • npm info [包名] version: 查看包的版本信息。

npm命令文档:https://docs.npmjs.com/cli/v11/commands

npm发布

首先,我们需要去官网注册一个账号。

然后在命令输入npm login进行登录,期间会跳转浏览器进行一个信息校验操作。

然后通过npm publish进行包的发布。

其他操作:

  • 删除发的包:npm unpublish

  • 让发布的包过期:npm deprecate

  • 退出登录:npm logout

早期的npm存在的问题

  • 依赖版本的不确定性

    • 比如当前项目依赖的包的版本为^3.1.2,当时下载的实际版本可能是3.1.2,但后来别人拿到项目后依赖的包进行了更新,通过npm i下在的包是最新的版本,二者版本不一致,容易造成在本地能跑但在别人环境报错了。
  • 安装速度慢

    • npm早期串行安装依赖,逐个下载包,效率太低。
  • 网络鲁棒性差

    • npm严重依赖官网registry(https://registry.npmjs.org/),若网络不稳定或registry出现故障,则安装失败。
    • 鲁棒性(robustness ):一个系统在面临着内部结构或外部环境的改变时也能够维持其功能稳定运行的能力。可以理解为一个系统的一些组成部分失效或故障了,但该系统仍能维持基本功能。
    • 网络鲁棒性:指的是网络在遭遇攻击、故障或异常输入时,仍能保持其功能和性能稳定的能力。
  • 缺乏离线模式

    • 早期的npm没有离线安装能力,断网时无法工作。
  • 依赖扁平化与重复问题

    • npm v3之前采用嵌套依赖结构,可能导致重复依赖,比如两个不同的包依赖同一个包,那么会在两个包的目录下都安装这个所以来的包,做不到相同包的复用,同时嵌套依赖的结构容易导致依赖层级过深,可能会操作系统路径长度限制。
  • 安全性问题

    • npm 安装依赖时允许包执行脚本(如 postinstall),存在恶意脚本注入风险。

关于上述的postinstall,其实就是包的生命周期执行命令,可以在package.json的script中声明生命周期执行命令,以便进行特定时期的操作。由此可以看出,这样容易造成恶意脚本注入风险。

1
2
3
4
5
{
"scripts": {
"postinstall": "node build.js"
}
}

包的声明周期

脚本名称 声明周期 场景
preinstall 在包安装前执行 检查环境和权限
install 包安装的过程中执行
postinstall 包安装完成后执行 编译代码和生成资源
preuninstall 包卸载前执行 清理临时文件
postuninstall 包卸载后执行

包管理工具yarn

yarn的出现是为了解决早期npm的一系列问题。

  • 解决依赖版本的不确定性

    • yarn引入了yarn.lock锁文件,精确记录每个依赖及其子依赖的版本,确保所有环境安装的依赖树一致。(后来npm5也引入了package-lock.json锁文件)。。
  • 解决安装速度慢

    • yarn采用并行安装依赖,同时采用缓存策略,大大加快下载速度。
  • 增强网络鲁棒性

    • 采用请求队列重试机制:自动重试失败的请求,提高网络容错率。
    • 可配置镜像源或自由仓库,降低单点故障风险。
  • 解决了离线下载问题。

    • 其实就是利用缓存进行离线下载,安装的时候指定yarn add --offline
    • 注意:--offline是必备的。npm的离线安装倒是不需要特别说明。
  • 扁平化依赖树

    • 将依赖尽可能提升到顶层node_modules,减少嵌套深度和重复。
  • 禁止安装期间的自动执行脚本,提高安全性。

    • 需显示启用yarn add --ignore-scripts

npx工具

举个例子,有一个scss文件,我们需要通过sass命令将其转化为css文件,我们可以将sass进行全局安装,因为全局安装的包是配置在环境变量的,这意味着在任何地方都可以使用sass命令。

1
sass test.scss test.css

但我不想进行全局安装,因为我的项目里安装过sass,我想用自己安装的sass来实现,那我们该怎么实现呢?

在node_modules中有一个.bin文件夹,这里面存放着我们安装包的可执行命令,我们可以直接利用可执行命令来达到编译的效果。

1
./node_modules/.bin/sass test.scss test.css

但我们还要跳到node_modules下,这样太麻烦了,有没有更好的办法?

这个时候,npx就登场了,npx本质上就是到当前目录的node_modules/.bin目录下寻找可执行命令,那么上述操作就可以写为

1
npx sass test.scss test.css

但npx与直接通过目录访问不同的是,当在当前项目的node_modules下找不到可执行命令文件时,就会往全局里面找。

包管理工具pnpm

虽然当前的npm和yarn相比之前都好了很多,但它们还存在一些令人头疼的问题

  • 由于它们采用扁平化处理的方式,我们可以在项目里引用我们没有安装过但是是安装的包所依赖的包,这也成为幻影依赖(非法依赖),一旦某个包由于升级而不依赖我们非法依赖的包时,项目就会出错。

  • 虽然它们都实现了缓存,但每一个项目都中都需要重新下载包,或者是通过网络下载,也或许是从缓存中解压到当前目录,这样会导致占用磁盘空间的比重变大,也是一种磁盘空间浪费,同时,从缓存中复制、解压也是一个耗时操作。

pnpm是怎么解决这些问题的呢?

pnpm采用硬链接和软链接的方式来处理包的依赖,通过pnpm管理的包会全局存储在.pnpm-store中,在windows系统中默认为当前项目的根目录,我们通过pnpm下载的包都会存储.pnpm-store中,然后在当前项目中的node_modules中,通过软链接指向.pnpm-store中的包,这样就能保证不论我们有多少个项目,通过pnpm管理的包都会指向同一个位置,这样,就节省了磁盘空间,同时,我们项目的使用不必再像以前一样复制解压,只需要建立软链接指向存储位置即可。这样,也加快了下载速度。

同时,在当前项目的node_modules中,顶级只会存在我们所下载的包,而我们所下载的包的依赖包则存在当前项目的.pnpm文件夹中,这样就避免了我们直接引用我们没有安装过的包所依赖的包,从而避免了幻影依赖。

关于硬链接和软链接,可以看这篇文章软链接和硬链接

关于pnpm管理的node_modules中包与包之间的关系,我画了个图。

我们还可以通过pnpm命令对.pnpm-store进行管理。

pnpm store prune: 删除.pnpm-store中未被引用的包。pnpm store path:获取当前活跃的store目录。