1 前言
本文旨在介绍JavaScript体系的包管理工具,将简单介绍什么是包管理工具,再介绍npm的使用方式以及另外两个包管理工具yarn、pnpm的部分使用方式,并介绍它们之间的差异。
2 什么是包管理工具
在nodejs兴起之后,前端终于也有了自己的包管理工具——npm,npm作为node自带的包管理工具,知名度是最广的。所以,在NodeJs中,模块、依赖、库都可以被称为npm包。
在现代软件开发中,我们做项目大多需要依赖于第三方软件包来提供各种功能,我们不可能用复制粘贴这种粗俗的形式,所以包管理工具诞生了,包管理工具的目标是简化项目的依赖管理和版本控制,让开发者更专注于业务逻辑而不是复杂的依赖项细节。
在现代软件开发中,我们做项目大多需要依赖于第三方软件包来提供各种功能,我们不可能用复制粘贴这种粗俗的形式,所以包管理工具诞生了,包管理工具的目标是简化项目的依赖管理和版本控制,让开发者更专注于业务逻辑而不是复杂的依赖项细节。
包管理工具在软件开发中扮演着至关重要的角色,它们的作用和重要性体现在以下几个方面:
- 依赖管理:现代项目通常依赖于许多外部软件包,这些包可能相互之间有依赖关系。包管理工具可以自动解析、安装和管理这些复杂的依赖关系,确保项目能够正确运行。
- 版本控制:软件包通常会有多个版本,每个版本可能存在着不同的功能、修复和改进。包管理工具能够确保项目使用正确的版本,以免出现不稳定性或安全漏洞。
- 安装和卸载:通过包管理工具,开发者可以方便地安装和卸载所需的软件包。这简化了软件包的获取过程,减少了手动下载和管理的繁琐。
3 npm的使用
npm作为最为流行的包管理工具,pnpm、yarn等包管理工具均是基于npm进行拓展,我们可以通过学习npm,掌握大部分cli的使用,而在需要使用pnpm、yarn时,只需要学习特殊cli就行。
我们从完整的一次工程流角度,去学习npm的使用,请注意,这意味着我们不会也不可能把每个命令的所有选项讲解清楚。
3.1 初始化项目
npm help init打开文档
任何的项目,都离不开初始化这一步。通过输入下边的代码,npm会提供一些选项用于生成package.json文件以完成初始化。这些选项不需要记忆,无关紧要,一般选择默认值就行。
$ npm init
This utility will walk you through creating a package.json file.
It only covers the most common items, and tries to guess sensible defaults.
See `npm help init` for definitive documentation on these fields
and exactly what they do.
Use `npm install <pkg>` afterwards to install a package and
save it as a dependency in the package.json file.
Press ^C at any time to quit.
package name: (npm-demo)
version:
一般来讲,我们不会关注init命令的其他选项,不过我下边还是列举了一些有用的操作:
- npm init -y
通过-y
或--yes
选项,npm在初始化时不会再询问要求补充各字段信息,而是直接使用默认值——所以我说了,我们其实不用关心各字段,这一部分在讲解package.json时会讲到。
- npm init -w
通过-w
选项,我们可以初始化一个子包,并将当前目录作为父工作区,将输入的目录作为子工作区,如果目录不存在,还会自动创建并设置工作区。
- npm init react-app ./my-app
嗯,这是干什么?接触过react的同学大抵都用过听过create-react-app
,官方是怎么教你初始化的?
npx create-react-app ./my-app #官方的命令
那我这里再教一种方式,通过这种方式完成初始化的CRA项目和原本的并无差别。
npm init react-app ./my-app
原因在于npm init存在和npm exec(npx)的特殊映射机制,任何其他选项都将直接传递给命令,比如npm init foo -- --hello
将映射到npm exec -- create-foo --hello
。
为了更好地说明如何转发选项,这里有一个更完善的示例,显示传递给npm cli和 create package 的选项,以下两个命令是等效的:
npm init foo -y --registry=<url> -- --hello -a
npm exec -y --registry=<url> -- create-foo --hello -a
3.2 安装依赖包
当我们init了一个node项目,准备开始开发,那第一步即是安装依赖。这一步我们并不陌生,但是很多同学只知道install,实际上就安装而言,有很多地方是要学习的。
npm install # 缩写:npm i
最基本的一点,npm将依赖做了类型区分,包括开发依赖(devDependencies)、生产依赖(dependencies)、可选依赖(optionalDependencies)、捆绑依赖(bundleDependencies)、对等依赖(peerDependencies),这些依赖的区别我这边不讲,我们要知道的是其在npm cli中的执行逻辑。
- -P:使用-P选项,将包安装至dependencies。(这是默认值,即默认所有包安装至生产依赖)
- -D:使用-D选项,将包安装至devDependencies。
- -O:使用-O选项,将包安装至optionalDependencies。
- -B:使用-B选项,将包同步安装至bundleDependencies列表。
- -E:使用-E选项,保存的依赖项将配置为精确的版本,而不是使用 npm 的默认 semver 范围运算符。至于这块所谓的精确的版本是什么,我们后边会讲。
- --no-save:使用--no-save选项,可以在安装时只安装,不将安装内容写入package.json和package-lock.json文件。
- -g:使用-g选项,将包安装至全局。
3.2.1 可以安装什么?
首先我们需要知道,我们平常使用npm各种安装指令,默认指向的是npm官方提供的托管仓库。但是,面对npm包未在任何npm仓库上线的情况,应该怎么做?
对于这些需求,npm提供了很多解决方案。
命令 | 场景 |
---|---|
npm install | 安装位于本机上的软件包。如npm install ./package.tgz |
npm install | 远程获取并安装软件包,参数必须以“http://”或“https://”开头。如npm install github.com/indexzero/f… |
npm install | 安装文件夹内依赖项,通过该方法安装,folder(需要包含package.json文件)将作为根目录的依赖包被安装。(该能力大部分功能可以被npm link替代,非特定场景用不上) |
除了上边这些安装未上传到npm仓库的需求,针对npm仓库内的安装同样有一定说法。
npm install [<@scope>/]<name>[@<tag>]
npm包被分成三部分,以此来确定安装的包。
- @scope:@scope用于给npm分级,在npm的规定中,npm包可以有作用域,作用域必须以“@”开头。举例说明,
@reduxjs/toolkit
就是以@reduxjs作为作用域。 - name:安装包的名字。
- @tag:版本等信息。
其中的tag是重点内容,一般来讲,tag是版本号或者版本范围或者特定的标签。
npm install sax@latest #约定标签,安装最新版本
npm install sax@0.1.1 #安装0.1.1版本
npm install sax@">=0.1.0 <0.2.0" #安装0.1.0~0.2.0的版本
要注意的是,安装版本范围时,在存入package.json时会以^等特定格式存储,这种格式被称为语义版本控制约束,而不是直接存储版本范围。当然如果你了解这种约束的意义,也可以直接写,这一块的规则在下边更新部分讲。
还有一种比较特殊的安装方式,即从托管git提供商安装包。
npm i <protocol>://[<user>[:<password>]@]<hostname>[:<port>][:][/]<path>[#<commit-ish> | #semver:<semver>]
这一大串看起来真是太麻烦了,下边是一些例子:
npm install git+ssh://git@github.com:npm/cli.git#v1.0.27
npm install git+ssh://git@github.com:npm/cli#pull/273
npm install git+ssh://git@github.com:npm/cli#semver:^5.0
npm install git+https://isaacs@github.com/npm/cli.git
npm install git://github.com/npm/cli.git#v1.0.27
GIT_SSH_COMMAND='ssh -i ~/.ssh/custom_ident' npm install git+ssh://git@github.com:npm/cli.git
这一部分和git的关联会比较深,npm也很自然地提供了一些对于git环境变量的支持,下边这些git 环境变量可以被 npm 识别,并在运行 git 时添加到环境中:
GIT_ASKPASS
GIT_EXEC_PATH
GIT_PROXY_COMMAND
GIT_SSH
GIT_SSH_COMMAND
GIT_SSL_CAINFO
GIT_SSL_NO_VERIFY
除了通过git+ssh这种指定头的方式安装,git还内置了对于常见git托管商的简化安装方式
npm install gist:101a11beef #Github
npm install bitbucket:mybitbucketuser/myproject #Bitbucket
npm install gitlab:mygitlabuser/myproject #Gitlab
针对这部分“可以安装什么”的部分,npm还提供了以下参数的额外支持:
- --tag:应用于所有指定的安装目标。如果存在给定名称的标记,则标记的版本优先于较新的版本。
- --dry-run:以通常的方式报告安装在没有实际安装任何东西的情况下会做什么。
- --package-lock-only:只会更新
package-lock.json
, 而不会检查node_modules
和下载依赖项。 - -f/--force:强制 npm 获取远程资源重新安装,即使磁盘上存在本地副本也是如此。
3.2.2 要注意什么?
我们讲述一些常见的注意事项:
- 作为核心操作,install命令可以触发prepare、preinstall、postinstall等npm scripts,如果你并不需要执行脚本(比如线上部署时不再执行husky install),可以添加参数**
--ignore-scripts
** - 关于peerDependencies,我们说过这是对等依赖。对等依赖指的是,假设你在本项目使用了react v15版本,所以依赖于react v16以及更高版本的第三方库你的项目是用不了的,这个时候第三方库应该将react安装为对等依赖,标明范围。一方面,对于存在冲突的包,会提示安装失败。另一方面,也是提示只需要安装一个本项目版本的依赖包。这个能力在选项中通过**
strict-peer-deps
** 开启**,** 开启之后严格执行冲突则失败的策略。
3.3 审阅依赖包
理论上,安装完一个包,我们就应该检测包的安全性,虽然我想大部分同学也不会太考虑这些,一般都是依赖于流水线自动测试。
首先,我们可以通过npm list
查看所有安装的包,注意存在箭头的依赖指的是软链接,通过该方式可以图形化查看所有安装的包,通过本步骤,可以查看到依赖包具体的安装版本。
$ npm list
npm-demo@1.0.0 D:\project\npm-demo
├── npm1@1.0.0 -> .\npm1
├── npm2@1.0.0 -> .\npm2
└── sax@0.1.5
通过下方命令,可以进行依赖安全审阅。添加fix参数代表自动修复,添加signatures
代表进行包完整性签名验证。
npm audit [fix|signatures]
由于内网环境使用不了audit命令,这一块我们忽略过去。
3.4 卸载依赖包
卸载依赖包是比较简单的操作,我们可以通过以下命令移除依赖包。注意,不管是通过npm link安装的本地依赖,还是通过npm i 安装的普通依赖,其卸载依赖的过程统一通过uninstall命令。
npm uninstall [<@scope>/]<pkg>...
aliases: unlink, remove, rm, r, un
移除的过程,npm同样提供了以下可选参数:
- --no-save: 使得npm 不要从
package.json
、npm-shrinkwrap.json
、 或package-lock.json
文件中删除该包 - -g:全局选项
3.5 更新依赖包
更新依赖包,做的是依据semver做依赖包更新。
npm update [--save] [-g] [packageName]
执行命令,可以指定更新多个或所有包,更新会参考semver的规则,默认情况下,semver更新不会修改package.json的依赖版本字段,但是可以通过--save
参数使其生效。
3.5.1 依赖的安装规则
下边对更新规则做一个示例,假设这是我们预计要安装的包,其最新版本是1.2.2,包含0.x和1.x两个大版本。
{
"dist-tags": { "latest": "1.2.2" },
"versions": [
"1.2.2",
"1.2.1",
"1.2.0",
"1.1.2",
"1.1.1",
"1.0.0",
"0.4.1",
"0.4.0",
"0.2.0"
]
}
- ^依赖
如果依赖文件中包含^,即下图所示,那么在安装时会遵循^的安装规则,即安装大版本最新的版本。对于1.1.1来说,大版本是1,大版本最新版本是1.2.2,所以实际上会安装1.2.2版本的包。
"dependencies": {
"dep1": "^1.1.1"
}
- ~依赖
如果依赖文件中包含~,那么在安装时会遵循安装小版本的最新版本,即对于~1.1.1来说,实际安装范围为“1.1.1≤ version <1.2.0 ”,也就是安装1.1.2版本。
"dependencies": {
"dep1": "~1.1.1"
}
需要注意的是,对于大版本为0的依赖包,即便是^依赖,实际安装时也会按照~依赖的方式进行安装。
除却这些单一包的依赖规则,还有的就是多个包的依赖规则。假设我们的应用有这样的依赖关系:
{
"name": "my-app",
"dependencies": {
"dep1": "^1.0.0",
"dep2": "1.0.0"
}
}
而dep2本身就依赖于dep1
{
"name": "dep2",
"dependencies": {
"dep1": "~1.1.1"
}
}
这个时候不管是安装还是更新,都会安装dep1@1.1.2
,这里实际上是取了子依赖和根依赖的并集。当一个版本可以满足你的树中多个依赖项的semver要求,npm会优先考虑安装一个版本,而不是两个版本。在这种情况下,如果真的需要使用一个更新的版本,需要手动执行install命令。
3.6 运行脚本命令
npm scripts是npm提供的一种简单而强大的功能,它允许开发者在package.json文件中定义和运行自定义的脚本命令。这些脚本命令可以用于执行各种任务,如构建项目、运行测试、启动开发服务器等。
3.6.1 定义npm scripts
在package.json文件中的"scripts"字段下,可以定义各种脚本命令。每个脚本命令都由一个自定义的名称和要执行的命令组成,格式如下:
{
"name": "my-project",
"version": "1.0.0",
"scripts": {
"build": "web[pack --mode production](<https://www.notion.so/JavaScript-npm-d23e62fb2faf4584a287130d2bae1aef?pvs=21>)",
"start": "node server.js",
"test": "echo "
}
}
上述示例中,我们定义了三个npm scripts:
- "build":用于构建项目,执行Webpack并将模式设置为"production"。
- "start":用于启动项目,运行Node.js服务器文件server.js。
- "test":用于运行测试,执行Jest测试。.
3.6.2 运行npm scripts
要运行定义的npm script,可以使用以下命令:
npm run <script-name>
例如,要运行"build"脚本,可以执行:
npm run build
需要注意,npm会自动查找并运行package.json中定义的对应命令,查找规则是,当我们在项目的根目录或子目录中运行此命令时,npm还会查找本地安装的**.**bin
目录(在Unix系统下通常是node_modules/.bin
,在Windows系统下通常是node_modules/.bin
或node_modules/.bin.cmd
)并执行指定的命令。所以,我们可以直接运行像webpack
这样的工具。
除了主动通过npm run
命令触发,scripts本身还有很多有趣的设计,比如生命周期。
生命周期是指,可以使用pre
和post
钩子来在脚本执行前后运行其他脚本(不能套娃)。例如:
- "prebuild": "echo 'Starting build...'",
- "build": "echo 'Building...'"
- "postbuild": "echo 'Build completed!'”
此时执行npm run build
会输出:
Starting build...
Building
Build completed!
除了可以通过pre和post配置钩子外,npm还内置了两个特殊生命周期字段:
- prepare
- prepublishOnly
这两个生命周期较为特殊,和其他生命周期的执行时间略有不同。
当运行npm install
时,会执行以下的链条:
preinstall -> install -> postinstall -> prepare
当运行npm publish
时,执行的钩子函数如下:
prepare -> prepublishOnly -> publish -> postpublish
之所以没有prepublish,是因为npm 4将“prepublish”脚本拆分为“prepublishOnly”和“prepare”。
npm scripts还提供了并发和多命令支持,在scripts
中,你可以同时运行多个命令,通过在命令之间使用&&
或&
来分隔。例如:"build": "webpack && sass main.scss dist/main.css"
。&&
表示前一个命令成功后再运行后一个命令,而&
表示同时运行多个命令。
3.7 配置
通过npm config list
,可以查看npm配置;通过npm config xxx [value]
,则可以设置npm配置项。
需要注意的是,npm的配置项又分全局配置和工作区配置(.npmrc),工作区配置优先级高于全局配置。
以下是一些常见的选项列表:
选项 | 描述 | 默认值 |
---|---|---|
registry | 指定npm包的注册表地址 | registry.npmjs.org/ |
scope | 指定npm包的作用域 | 无 |
proxy | 指定HTTP代理 | 无 |
https-proxy | 指定HTTPS代理 | 无 |
user-agent | 设置HTTP请求的User-Agent标头 | npm/{npm-version} node/{node-version} {platform} {arch} |
loglevel | 设置日志输出级别 | warn |
prefix | 设置全局安装包的路径前缀 | (UNIX) /usr/local (Windows) %APPDATA%\npm |
cache | 设置npm包的缓存路径 | (UNIX) ~/.npm (Windows) %APPDATA%\npm-cache |
progress | 设置是否显示下载进度条 | true |
color | 设置是否使用彩色输出 | true |
strict-ssl | 设置是否启用严格的SSL验证 | true |
ca | 设置自定义CA证书路径 | 无 |
always-auth | 设置是否在每次请求时都发送身份验证凭据 | false |
save-exact | 设置在安装包时是否要保存精确的版本号 | false |
ignore-scripts | 设置是否在安装过程中忽略运行包的安装脚本 | false |
audit | 设置在npm install时是否进行漏洞审计 | true |
package-lock | 设置是否生成package-lock.json文件 | true |
engine-strict | 设置是否启用严格的引擎版本检查 | false |
offline | 设置是否离线模式,禁止网络请求 | false |
legacy-bundling | 设置是否使用旧版捆绑算法 | false |
3.8 npm exec
npm exec
是npm的一个内置命令,简称npx,用于在项目中执行指定的命令。它的原理相对简单,但非常有用。
使用npm exec
命令的基本语法如下:
npm exec <command>
其中,**<**command**>**
是要执行的任意命令,运行规则和npm scripts类似。
例如,假设你的项目依赖了一个开发时使用的工具包(比如webpack),并且该工具包是作为开发依赖安装的,你可以使用npm exec
来运行该工具包的命令,而不必显式指定它的完整路径。
示例:
npx webpack -- --mode development
上述命令将在项目中查找webpack
命令,并使用--mode development
选项运行它。
npm exec
命令的原理是利用了npm的运行时环境和PATH
变量的设置。运行npm exec
时,npm将当前项目的node_modules/.bin
目录添加到PATH
中,这样系统就可以在执行命令时在这个目录下查找可执行文件。
需要注意的是,由于npm exec
是在项目的**node_modules/.bin
**目录下查找命令,如果你在全局安装了某个命令,而且项目中没有该命令的本地安装,npm exec
将无法找到该全局命令。在这种情况下,你可以使用绝对路径或者在package.json
中的scripts
字段中定义脚本命令来执行全局命令。
4 其他主流包管理工具
市面上,node体系的包管理工具是非常多的,目前最广为流行的不过npm、pnpm、yarn。
-
npm
- npm 是 Node.js 官方提供的默认包管理工具,也是最常用的包管理工具。
- 它允许开发者在项目中管理依赖项,并提供了丰富的功能,如包安装、卸载、版本管理、脚本运行和安全性检查等。
-
pnpm
- pnpm 是一个快速、高效的包管理工具,允许多个项目共享依赖项,节省磁盘空间和安装时间。
- 它使用符号链接来共享依赖项,而不是像 npm 那样将依赖项复制到每个项目。
-
yarn
- yarn 是由 Facebook 开发的包管理工具,旨在解决 npm 的一些性能和安全问题。
- 它支持离线安装,有锁文件功能,可以确保依赖项的版本在不同环境中保持一致。
4.1 性能和磁盘效率
npm**:** 与yarn和pnpm相比,有点慢。
yarn**:** yarn使用相同的flat node modules目录,但在速度方面与npm相当,支持并行安装包。
pnpm**:** pnpm比npm快3倍,效率更高。同时使用冷缓存和热缓存,显然,pnpm也比yarn快。
pnpm只从全局存储中链接文件,而yarn从缓存中复制文件。软件包版本永远不会在磁盘上多次保存。
pnpm的算法不使用扁平依赖树,这使得它更容易实现,维护,并且需要更少的计算。
下边这是npm 3和更早版本中使用的方法,但是嵌套是有问题的,因为必须为依赖它们的每个包都复制依赖包。
node_modules
└─ foo
├─ index.js
├─ package.json
└─ node_modules
└─ bar
├─ index.js
└─ package.jso
与npm相比,pnpm通过硬链接和软链接解决了上述问题。pnpm通过符号链接对所有依赖项进行分组,但保留了所有依赖项。
node_modules
├─ foo -> .registry.npmjs.org/foo/1.0.0/node_modules/foo
└─ .registry.npmjs.org
├─ foo/1.0.0/node_modules
| ├─ bar -> ../../bar/2.0.0/node_modules/bar
| └─ foo
| ├─ index.js
| └─ package.json
└─ bar/2.0.0/node_modules
└─ bar
├─ index.js
└─ package.json
所以相对来说,pnpm的性能和磁盘效率都是最高的。不过,这也是有代价的。
试想一个场景,当你完成了一个项目,将其删除归档,此时原本的依赖并不是删除——甚至如果你升级了依赖,原本的依赖也不会删除,因为这是全局安装的,被删除的是软链接。
(当然pnpm官方提供了解决方案,pnpm store prune
指令,会清理仓库中没有被使用的安装包)
4.2 安全性
在早期,node体系的包不稳定性真的一直是个槽点——为什么我这台机器能跑那台不行,直到后来推出了lock机制,出现了各种node多版本切换工具,再加上主流版本管理工具们纷纷出手,现在npm的安全性和稳定性都有了稳定增长。
目前来讲,基本安全性大体不差,不过pnpm仍略胜一筹。这点体现如下:
假设有以下依赖关系的三个包:C包依赖B包,B包依赖A包。假如我们在npm和yarn的项目中只安装C包,由于node_modules下是扁平化结构,所以其实我们在项目中可以正常引用A包,这个就是幽灵依赖。
虽然说,代码是可以跑的,但是其实是存在潜在风险的,如果有一天B包更新了,把A包换成了D包(举个例子,Antd从moment转到了dayjs),那我们代码就裂开了。
而在pnpm的项目中,由于其node_modules结构特殊,在只装C包的情况下无法直接引用A包,直接引用会报错,只有真正在依赖项中的包才能访问,一定程度上保障了安全性。
4.3 Monorepo支持
随着单仓库模式在node中的流行,各个包管理工具均升级了自己对于monorepo的支持。
4.3.1 npm
npm管理器虽然提供了monorepo支持,但是相对来说,功能较少,并不推荐大家使用npm来管理单仓库。npm通过工作区机制来实现monorepo。
执行npm init -w ./packages/a
,可以看到package.json文件中会更新一份workspaces字段。
"workspaces": [
"packages\\a"
]
如果我们需要为工作区安装依赖,可以通过npm install xxx -w a
命令定向安装同理,我们在单工作区中的任意操作,均可以通过-w选择工作区。
4.3.2 yarn
yarn管理器通过yarn init -w
初始化项目,yarn同样使用工作区机制,默认情况下,yarn通过在package.json中设置workspaces字段做工作区管理,默认packages下的文件夹均为工作区。不过与npm不同的是,yarn需要你自己在工作区内自己初始化子包。
{
"name": "npm-demo",
"packageManager": "yarn@4.0.0-rc.47",
"workspaces": [
"packages/*"
]
}
安装依赖时,通过yarn workspace [workspaceName] add [package]
添加工作区,当然也可以添加多个工作区。yarn workspace [workspaceName-1] [workspaceName-2] add [package]
,同理,其他指令也可以通过workspace选择工作区。
4.3.3 pnpm
要使用pnpm创建monorepo项目,首先需要手动在根目录创建pnpm-worksapce.yam
l文件。
packages:
- 'packages/**'
此时,和yarn逻辑相似,我们需要在packages文件夹下手动初始化项目(pnpm init),接着就是安装依赖。
需要注意的是,pnpm如果需要在根目录安装依赖,比如安装husky,需要携带参数-w
:pnpm add husky -w
,显式告诉pnpm你不是不知道安装在了根目录。
而如果需要安装在工作区,需要使用-r
参数:pnpm add husky -r xxx
,此时会只安装依赖至工作区。同理,其他命令也可以通过-r进行过滤。