JavaScript 包管理器

292 阅读11分钟

简史

  • 包管理器 出现之前:JavaScript 的依赖包需要手动下载管理
  • 2010:npm 发布并支持 nodejs
  • 2012:由于 Browserifysnpm 的使用量急剧增加
  • 2012:npm 有了一个竞争对手 bower
  • 2012-2016:前端项目的依赖项数量成倍增加
  • 2012-2016:构建和安装前端应用变得越来越慢
  • 2012-2016:大量(重复的)依赖项存储在 node_modules 内的嵌套文件夹中
  • 2012-2016:rm -rf node_modules 成为前端开发人员最常用的命令
  • 2015:bower 输给了 npm
  • 2015:node_modules 被修改为扁平化的文件结构
  • 2016:npm 发布 shrinkwrap 尝试处理依赖项锁定,不幸的是,一些错误和超出其管理能力的承诺导致该工具的声誉下降
  • 2016:yarn 发布
    • yarn 支持 npmbower 仓库
    • yarnyarn.lock 能够锁定安装的版本并提供确定性的依赖关系。不再 rm -rf node_modules
    • yarn install 花费的时间是 npm install 的一半(不使用缓存的前提下)
    • 缓存和脱机模式使构建过程几乎不花费时间
  • 2017:npm 5 发布
    • package-lock.json 是他们的新工具,shrinkwrap 被放在一边
    • package-lock.json 开始与 yarn.lock 竞争
  • 2018:npm ci 发布,直接用 package-lock.json 构建代码,没有代价高昂的依赖项安全性分析和版本分析,大大减少了在构建服务器上的构建时间!
  • 2018:npm 6 发布,npm 检查要安装的依赖项中的安全漏洞,yarn 和 npm 的构建时间不再有显差异
  • 2019:tink 开始进入 beta 模式,避免使用 node_modules,而是为项目中的每个依赖项创建一个带有哈希值的文件,但是尚未做好投入生产环境的准备

yarn 发布后,npm 受到启发(并被迫?)开发了许多好的工具和机制。yarn 因为解决了与 npm 相关的一些重要问题而倍受赞誉,并在 2016 年开始向竞争对手施加压力。包的处理速度、安全性和确定性是必不可少的功能,它们使当今的开发人员能够专注于创造价值,而且并不为这两种工具进行争吵。

包管理器出现之前

JavaScript 的依赖包需要开发者到包对应的提供源处手动下载,开发者往往需要到多个不同到网站上,才能下载完所有所需的依赖包,并且版本需要自行管理。

npm 发布

npm 全称 node package manager 从名字可以看出,最开始 npm 只 node.js 的包管理器,当然现在不单单只是作为 node.js 的包管理器了,整个 JavaScript、TypeScript 生态都在使用它。

自动下载依赖包

npm 发布之后,开发者再也不用去各个网站上下载依赖了,只需要执行一条命令 npm install 依赖包名@版本 npm 就会访问 npm 的中央仓库,下载指定版本的依赖包。下载完成之后 npm 默认会将 赖包放在当前目录下的 node_modules 目录下,并修改当前目录下 package.json 文件追加一条该依赖包的配置

  • node_modules : 是 npm 默认存放依赖包的目录
  • package.json : 类似于 maven 中的 pom.xml 是 npm 项目依赖的配置文件。

语义版本控制 (semver)

除了能够方便的下载到依赖包外,npm 还提供了版本管理功能。npm 的版本管理是围绕着语义版本控制 (semver) 的思想而设计的,核心思想为版本号由主版本号、次版本号、补丁版本号组成,并且规定以下三种情况需要增加相应的版本号

  • 主版本号:当 API 发生改变,并与之前的版本不兼容的时候
  • 次版本号:当增加了功能,但是向后兼容的时候
  • 补丁版本号:当做了向后兼容的缺陷修复的时候

npm 使用一个名为 package.json 的文件,保存项目里所有的依赖项。

例如,运行 npm install --save lodash 会将以下几行添加到 package.json 文件中。

"dependencies": {
    "lodash": "^4.17.4"
}

注意版本号前面的 ^ 字符,这个字符告诉 npm,安装主版本等于 4 的最新版本即可。

npm 通过 semver 语法来声明依赖包的版本,详细请参考官网,这里只介绍最常用的几个

  • ^1.1.1 : 安装 1.x.x 中的最新版本
  • ~1.1.1 : 安装 1.1.x 中的最新版本
  • @1.1.1 : 安装 1.1.1 版本

由于 semver 的这种特性,导致不同的开发人员即使使用了相同的 package.json 文件,在他们自己的机器上也可能会安装同一个库的不同种版本。但是按照 semver 的设计,理论上次版本号的变化并不会影响向后兼容性。因此,安装最新版的依赖库应该是能正常工作的,而且能引入重要错误和安全方面的修复。

当然这一切的前提是软件包的开发者严格遵守这一约定,并保证最新版的依赖库不会引入新的bug。然而现实是残酷的,开发者没有严格遵守或者是或者是修复 bug 时,埋藏了新 bug,这样就会存在潜在的难以调试的错误和 “在我的电脑上能运行…” 的情形。

除了上述问题之外,大多数 npm 依赖包都依赖于其他 npm 依赖包,这会导致嵌套依赖关系,并增加无法匹配相应版本的几率。

虽然可以通过 npm config set save-exact true 命令关闭在版本号前面使用 ^ 的默认行为,但这个只会影响顶级依赖关系。由于每个依赖的库都有自己的 package.json 文件,而在它们自己的依赖关系前面可能会有 ^ 等符号,所以无法通过 package.json 文件为嵌套依赖的内容提供保证。

树状结构的 node_modules

node_modules 是存放依赖包的地方,里面的依赖包是以树装结构来存储的,什么意思。

假设 A {B,C}, B {C}, C {D} ,即 A 依赖 B、C,B 依赖 C,C依赖D 则文件夹结构如下

node_modules(root)
├─ A
├─── node_modules
├───── B
├─────── node_modules
├───────── C
├─────────── node_modules
├───────────── D
├─────────────── node_modules
├───── C
├─────── node_modules
├───────── D
└─────────── node_modules

如上,每一个包都有自己的依赖包,每个包自己的依赖都安装在了自己的 node_modules 中。 npm 会递归安装所有的依赖包,依赖关系层层递进,构成了一整个依赖树,这个依赖树与文件系统中的文件结构树刚好层层对应。

优点

这样的目录结构优点在于层级结构明显,便于进行傻瓜式的管理:

例如

  • 新装一个依赖包,可以立即在第一层 node_modules 中看到子目录
  • 在已知所需包名和版本号时,甚至可以从别的文件夹手动拷贝需要的包到 node_modules 文件夹中,再手动修改 package.json 中的依赖配置
  • 要删除这个包,也可以简单地手动删除这个包的子目录,并删除 package.json 文件中相应的一行即可

实际上,很多人在 npm 2 时代也的确都这么实践过,的确也都可以安装和删除成功,并不会导致什么差错。

缺点

但这样的文件结构也有很明显的问题:

  • 对复杂的工程,node_modules 内目录结构可能会太深,导致深层的文件路径过长而触发 windows 文件系统中,文件路径不能超过 260 个字符长的错误
  • 部分被多个包所依赖的包,很可能在应用 node_modules 目录中的很多地方被重复安装。随着工程规模越来越大,依赖树越来越复杂,这样的包情况会越来越多,造成大量的冗余。

在我们的示例中就有这个问题, A 和 B 都依赖 C 这个依赖,所以在文件系统中,A 和 B 的 node_modules 子目录中都安装了相同的 C 依赖,并且是相同的版本。

npm 3 带来扁平化的 node_modules

npm 发布后为 node.js 带来了包管理器,解决了包的下载问题,但是依赖包的版本管理问题解决的并不够好,比如依赖包的存储。

由于 node_modules 树状结构的存储设计,导致目录结构太深、依赖大量冗余。好在 npm 3 发布时,带来了全新的扁平化的 node_modules

还是以上面 A {B,C}, B {C}, C {D} 的依赖结构为例子,在扁平化的设计之后 node_modules 的结构变成了下面这个样子

node_modules(root)
├─── A
├─── B
├─── C
└─── D

得益于 node 的模块加载机制,除了根 node_modules 外,依赖包不在嵌套 node_modules, 它们都可以在上一级 根 node_modules 目录中找到子依赖

以上只是最简单的例子,在实际的工程项目中,依赖树不可避免地会有很多层级,很多依赖包,其中会有很多同名但版本不同的包存在于不同的依赖层级,对这些复杂的情况,npm 3 都会在安装时遍历整个依赖树,计算出最合理的文件夹安装方式,使得所有被重复依赖的包都可以去重安装。

假设依赖关系为 A {B,C}, B {C,D@1}, C {D@2},那么 node_modules 的结构为

node_modules(root)
├─── A
├─── B
├─── C
├───── D@2
└─── D@1

shrinkwrap

扁平化的 node_modules 只解决了版本管理的问题,为了解决版本管理中由于语义版本控制 (semver)导致依赖不明确的问题,npm 开发 shrinkwrap

使用 shrinkwrap 命令将生成一个 npm-shrinkwrap.json 文件,为所有库和所有嵌套依赖的库记录确切的版本。

然而,即使存在 npm-shrinkwrap.json 这个文件,npm 也只会锁定库的版本,而不是库的内容。即便 npm 现在也能阻止用户多次重复发布库的同一版本,但是 npm 管理员仍然具有强制更新某些库的权力。

2016 年 yarn 发布

Yarn 发布于 2016 年 10 月,一开始的主要目标是解决 npm 中由于语义版本控制而导致的 npm 安装的不确定性问题。虽然可以使用 npm shrinkwrap 来实现可预测的依赖关系树,但它并不是默认选项,而是取决于所有的开发人员知道并且启用这个选项。

Yarn 采取了不同的做法。每个 yarn 安装都会生成一个类似于 npm-shrinkwrap.json 的 yarn.lock 文件,而且它是默认创建的。除了常规信息之外,yarn.lock 文件还包含要安装的内容的校验和,以确保使用的库的版本相同。

由于 yarn 是崭新的经过重新设计的 npm 客户端,它能让开发人员并行化处理所有必须的操作,并添加了一些其他改进,这使得运行速度得到了显著的提升,整个安装时间也变得更少这也是 yarn 受欢迎的主要原因。

像 npm 一样,yarn 使用本地缓存。与 npm 不同的是,yarn 无需互联网连接就能安装本地缓存的依赖项,它提供了离线模式。这个功能在 2012 年的 npm 项目中就被提出来过,但一直没有实现。

yarn 还提供了一些其他改进,例如,它允许合并项目中使用到的所有的包的许可证等

npm 5 带来 package-lock 文件

受到 yarn 的 yarn.lock 功能的冲击,npm 在 2017年发布的 npm5 中提供了package-lock.json ,功能与 yarn.lock 类似

pnpm

pnpm 的运行起来非常的快,甚至超过了 npm 和 yarn

为什么这么快呢? 因为它采用了一种巧妙的方法,利用硬链接和符号链接来避免复制所有本地缓存源文件,这是 yarn 的最大的性能弱点之一,使用链接并不容易,会带来一堆问题需要考虑,yarn 的开发人员也曾考虑过这种方式,最后由于种种原因放弃了。

截至 2017 年 3 月,它继承了 yarn 的所有优点,包括离线模式和确定性安装。

结论

为了方便起见,我建议大多数团队选择最简单的选项 —— npm。它随 node 一起提供,目前能以足够好的方式处理包管理。

例外
当使用 monorepo 时,yarn workspaces 是一种流行的替代方案,而 npm 则没有提供等效的替代方法。lerna 是一个软件包,它还支持 monorepos 的使用,并且可以与 npm 和 yarn(带有 workspaces)一起使用。

pnpm
pnpm 是包管理器的第三种选择。如果 pnpm 的卖点是如果包已经下载到本地的一个存储库中,则它就不会再次下载了 —— 这类似于 Java 中的 maven 依赖管理。目前 pnpm 还不如 yarn 或 npm 成熟