JavaScript:npm的使用&流行包管理工具比较

1,303 阅读14分钟

1 前言

本文旨在介绍JavaScript体系的包管理工具,将简单介绍什么是包管理工具,再介绍npm的使用方式以及另外两个包管理工具yarn、pnpm的部分使用方式,并介绍它们之间的差异。

2 什么是包管理工具

在nodejs兴起之后,前端终于也有了自己的包管理工具——npm,npm作为node自带的包管理工具,知名度是最广的。所以,在NodeJs中,模块、依赖、库都可以被称为npm包。

在现代软件开发中,我们做项目大多需要依赖于第三方软件包来提供各种功能,我们不可能用复制粘贴这种粗俗的形式,所以包管理工具诞生了,包管理工具的目标是简化项目的依赖管理和版本控制,让开发者更专注于业务逻辑而不是复杂的依赖项细节。

在现代软件开发中,我们做项目大多需要依赖于第三方软件包来提供各种功能,我们不可能用复制粘贴这种粗俗的形式,所以包管理工具诞生了,包管理工具的目标是简化项目的依赖管理和版本控制,让开发者更专注于业务逻辑而不是复杂的依赖项细节。

包管理工具在软件开发中扮演着至关重要的角色,它们的作用和重要性体现在以下几个方面:

  1. 依赖管理:现代项目通常依赖于许多外部软件包,这些包可能相互之间有依赖关系。包管理工具可以自动解析、安装和管理这些复杂的依赖关系,确保项目能够正确运行。
  2. 版本控制:软件包通常会有多个版本,每个版本可能存在着不同的功能、修复和改进。包管理工具能够确保项目使用正确的版本,以免出现不稳定性或安全漏洞。
  3. 安装和卸载:通过包管理工具,开发者可以方便地安装和卸载所需的软件包。这简化了软件包的获取过程,减少了手动下载和管理的繁琐。

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 要注意什么?

我们讲述一些常见的注意事项:

  1. 作为核心操作,install命令可以触发prepare、preinstall、postinstall等npm scripts,如果你并不需要执行脚本(比如线上部署时不再执行husky install),可以添加参数**--ignore-scripts**
  2. 关于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.jsonnpm-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/.binnode_modules/.bin.cmd)并执行指定的命令。所以,我们可以直接运行像webpack这样的工具。

除了主动通过npm run命令触发,scripts本身还有很多有趣的设计,比如生命周期。

生命周期是指,可以使用prepost钩子来在脚本执行前后运行其他脚本(不能套娃)。例如:

  • "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.yaml文件。

packages:
  - 'packages/**'

此时,和yarn逻辑相似,我们需要在packages文件夹下手动初始化项目(pnpm init),接着就是安装依赖。

需要注意的是,pnpm如果需要在根目录安装依赖,比如安装husky,需要携带参数-wpnpm add husky -w ,显式告诉pnpm你不是不知道安装在了根目录。

而如果需要安装在工作区,需要使用-r参数:pnpm add husky -r xxx ,此时会只安装依赖至工作区。同理,其他命令也可以通过-r进行过滤。