学习前端包管理机制

date
Sep 12, 2022
slug
lkqoxkaa
status
Published
tags
前端
summary
记录前段包管理机制,包含npm和yarn的优劣势
type
Post
现在JavaScript生态,不管是Web还是Node,都借助npm包管理机制全面开花,本文只针围绕npm、yarn来学习包管理,其他如cnpm、pnpm、tnpm后面新开文章。

Npm

node已经内置集成了npm,在node安装好之后输入 npm -v,来测试是否成功安装。
💡
node官网可以看到node对应的npm版本。

npm install 原理

当执行npm install命令后,npm会帮我们下载对应依赖包并解压到本地缓存,然后构造node_modules 目录结构,写入依赖文件。详细流程如下:
notion image
  • 检查 .npmrc 文件配置,读取package.json文件
  • 检查项目中有无 lock 文件。
  • 无 lock 文件:
    • 从 npm 远程仓库获取包信息
    • 根据 package.json 构建依赖树,构建过程:
      • 构建依赖树时,不管其是直接依赖还是子依赖的依赖,优先将其放置在 node_modules 根目录。
      • 当遇到相同模块时,判断已放置在依赖树的模块版本是否符合新模块的版本范围,如果符合则跳过,不符合则在当前模块的 node_modules 下放置该模块。
      • 注意这一步只是确定逻辑上的依赖树,并非真正的安装,后面会根据这个依赖结构去下载或拿到缓存中的依赖包
    • 在缓存中依次查找依赖树中的每个包
      • 不存在缓存:
        • 从 npm 远程仓库下载包
        • 校验包的完整性
        • 校验不通过:
          • 重新下载
        • 校验通过:
          • 将下载的包复制到 npm 缓存目录
          • 将下载的包按照依赖结构解压到 node_modules
      • 存在缓存:将缓存按照依赖结构解压到 node_modules
    • 将包解压到 node_modules
    • 生成 lock 文件
  • 有 lock 文件:
    • 检查 package.json 中的依赖版本是否和 package-lock.json 中的依赖有冲突。
    • 如果没有冲突,直接跳过获取包信息、构建依赖树过程,开始在缓存中查找包信息,后续过程相同

npm v1/v2 依赖地域

npm最早的版本中使用了很简单的嵌套模式进行依赖管理,即我们平时交流的依赖地狱(Dependency Hell)。比如我们在项目中依赖了A模块和C模块,而A模块和C模块依赖了不同版本的B模块,此时生成的node_modules目录如下:
node_modules
├── A@1.0.0
│   └── node_modules
│       └── B@1.0.0
├── C@1.0.0
│   └── node_modules
│       └── B@2.0.0
└── D@1.0.0
    └── node_modules
        └── B@1.0.0
可以看出这很容易造成了重复的空间浪费,所以一些著名的梗图:比黑洞更恐怖
notion image

npm v3 扁平化

npm v3完成重写了依赖安装程序,通过扁平化的方式将子依赖项安装在主依赖项所在的目录中(hoisting提升),以减少依赖嵌套导致的深层树和冗余。
package.json文件
package.json文件
node_modules目录结构
node_modules目录结构
为了确保模块的正确加载,npm也实现了额外的依赖查找算法,核心是递归向上查找node_modules
💡
在安装新的包时,会不停往上级node_modules中查找。如果找到相同版本的包就不会重新安装,在遇到版本冲突时才会在模块下的 node_modules 目录下存放该模块子依赖。
扁平化的模式解决了依赖地狱的问题,减少了包重复安装的问题,依赖的层级也不会太深。但也带来了额外的新问题。
· 幽灵依赖(Phantom dependency)
如上面介绍的依赖情况,项目不直接依赖B,但扁平化之后项目可直接引用B。这可能会导致意想不到的问题:
  • 依赖不兼容:项目中并没有声明依赖B的版本,因此B的major更新对于SemVer体系是完全合法的,这就导致其他用户安装时可能会下载到与当前依赖不兼容的版本。
  • 依赖缺失:我们也可以直接引用项目中devDepdency的子依赖,但其他用户安装时并不会devDepdency,这就可能导致运行时会立刻报错。
· 多重依赖(doppelgangers)
notion image
在项目中继续引入的依赖2.0版本B的模块D、依赖1.0版本B的模块E,此时无论是把B 2.0还是1.0提升放在顶层,都会导致另一个版本存在重复的问题,比如这里重复的2.0。此时就会存在以下问题:
  • 破坏单例模式:模块C、D中引入了模块B中导出的一个单例对象,即使代码里看起来加载的是同一模块的同一版本,但实际解析加载的是不同的module,引入的也是不同的对象。如果同时对该对象进行副作用操作,就会产生问题。
  • types冲突:虽然各个package的代码不会相互污染,但是他们的types仍然可以相互影响,因此版本重复可能会导致全局的types命名冲突。
· 不确定性(Non-Determinism)
npm v3的不确定性的它node_modules目录以及依赖树结构取决于用户安装的顺序。结合上面多重依赖的示例:
  • 当将模块A升级到v2.0后,其依赖B v2.0。
  • 项目完成开发后,将项目部署至服务器,重新执行npm install,此时产生的node_modules目录结构将会与用户本地开发产生的结构不同。
上面情况都存在子依赖B版本发生了变化,然后可能会出现下面情况,导致不确定的情况发生。
notion image
notion image

npm v5 扁平化+lock

npm v5中新增了package-lock.json。当项目有package.json文件并首次执行npm install安装后,会自动生成一个package-lock.json文件,该文件里面记录了:
  • package.json依赖的模块,以及模块的子依赖
  • 给每个依赖标明了版本、获取地址和验证模块完整性哈希值。
通过package-lock.json,保障了依赖包安装的确定性与兼容性,使得每次安装都会出现相同的结果。
{
  "name": "my-app",
  "version": "1.0.0",
  "dependencies": {
    "base64-js": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.0.1.tgz",
      "integrity": "sha1-aSbRsZT7xze47tUTdW3i/Np+pAg="
    },
    "buffer": {
      "version": "5.4.3",
      "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.4.3.tgz",
      "integrity": "sha512-zvj65TkFeIt3i6aj5bIvJDzjjQQGs4o/sNoezg1F1kYap9Nu2jcUdpwzRSJTHMMzG0H7bZkn4rNQpImhuxWX2A==",
      "requires": {
        "base64-js": "^1.0.2",
        "ieee754": "^1.1.4"
      },
      "dependencies": {
        "base64-js": {
          "version": "1.3.1",
          "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz",
          "integrity": "sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g=="
        }
      }
    },
    "ieee754": {
      "version": "1.1.13",
      "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz",
      "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg=="
    },
    "ignore": {
      "version": "5.1.4",
      "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.4.tgz",
      "integrity": "sha512-MzbUSahkTW1u7JpKKjY7LCARd1fU5W2rLdxlM4kdkayuCwZImjkpluF9CM1aLewYJguPDqewLam18Y6AU69A8A=="
    }
  }
}
· 一致性
lock文件修复了扁平化的不确定性,子依赖版本变化导致重新生成的node_modules目录结构将不会发生变化。
notion image
notion image
· 兼容性
根据依赖的语义化版本(Semantic Versioning),在使用第三方依赖时,我们通常会在package.json中指定依赖的版本范围。避免一些依赖模块子依赖不经意的升级,导致可能产生不兼容的问题。

深入package-lock.json

根据前面的示例,其包含有以下字段:
notion image
1、最外面的两个属性 name 、version 同 package.json 中的 name 和 version ,用于描述当前包名称和版本。
2、dependencies 是一个对象,对象和 node_modules 中的包结构一一对应,对象的 key 为包名称,值为包的一些描述信息:
  • version:包版本 —— 这个包当前安装在 node_modules 中的版本
  • resolved:包具体的安装来源
  • integrity:包 hash 值,基于 Subresource Integrity 来验证已安装的软件包是否被改动过、是否已失效
  • requires:对应子依赖的依赖,与子依赖的 package.json 中 dependencies的依赖项相同。
  • dependencies:结构和外层的 dependencies 结构相同,存储安装在子依赖 node_modules 中的依赖包。
💡
注意,并不是所有的子依赖都有 dependencies 属性,只有子依赖的依赖和当前已安装在根目录的 node_modules 中的依赖冲突之后,才会有这个属性。
回顾上面示例的依赖关系:
notion image
我们在 my-app 中依赖的 base64-js@1.0.1 版本与 buffer 中依赖的 base64-js@^1.0.2 发生冲突,所以 base64-js@1.0.1 需要安装在 buffer 包的 node_modules 中,对应了 package-lock.json 中 buffer 的 dependencies 属性。这也对应了 npm 对依赖的扁平化处理方式。
根据上面的分析, package-lock.json 文件 和 node_modules 目录结构是一一对应的,即项目目录下存在 package-lock.json 可以让每次安装生成的依赖目录结构保持相同。即lock文件解决了不确定性。
我们使用 npm i --timing=true --loglevel=verbose 命令可以看到 npm install 的完整过程,下面对比下的差别,对比前先清理下npm 缓存。
使用lock文件
使用lock文件
不使用lock文件
不使用lock文件
可以看出项目中使用了 package-lock.json 可以显著加速依赖安装时间。

Yarn

Yarn 是在2016年开源的,yarn 的出现是为了解决 npm v3 中的存在的一些问题,那时 npm v5 还没发布。Yarn 被定义为快速、安全、可靠的依赖管理。
notion image
💡
上面是官网提到的 yarn 的优点,在那个时候还是非常吸引人的。当然,后来 npm 也意识到了自己的问题,进行了很多次优化,在后面的优化(lock文件、缓存、默认-s...)中,我们多多少少能看到 yarn 的影子,可见 yarn 的设计还是非常优秀的。
yarn.lock使用自定义格式而不是JSON,并将所有依赖都放在顶层,给出的理由是便于阅读和审查,减少合并冲突。
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1


base64-js@1.0.1:
  version "1.0.1"
  resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.0.1.tgz#6926d1b194fbc737b8eed513756de2fcda7ea408"
  integrity sha1-aSbRsZT7xze47tUTdW3i/Np+pAg=

base64-js@^1.0.2:
  version "1.3.1"
  resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.1.tgz#58ece8cb75dd07e71ed08c736abc5fac4dbf8df1"
  integrity sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g==

buffer@^5.4.3:
  version "5.4.3"
  resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.4.3.tgz#3fbc9c69eb713d323e3fc1a895eee0710c072115"
  integrity sha512-zvj65TkFeIt3i6aj5bIvJDzjjQQGs4o/sNoezg1F1kYap9Nu2jcUdpwzRSJTHMMzG0H7bZkn4rNQpImhuxWX2A==
  dependencies:
    base64-js "^1.0.2"
    ieee754 "^1.1.4"

ieee754@^1.1.4:
  version "1.1.13"
  resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84"
  integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==

ignore@^5.1.4:
  version "5.1.4"
  resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.1.4.tgz#84b7b3dbe64552b6ef0eca99f6743dbec6d97adf"
  integrity sha512-MzbUSahkTW1u7JpKKjY7LCARd1fU5W2rLdxlM4kdkayuCwZImjkpluF9CM1aLewYJguPDqewLam18Y6AU69A8A==

其他

lock文件的区别

  1. 文件格式不同:package-lock.json 使用的是 json 格式,yarn.lock 使用的是自定义格式。
  1. package-lock.json 文件里记录的依赖的版本都是确定的,不会出现语义化版本范围符号(~ ^ *),而 yarn.lock 文件里仍然会出现语义化版本范围符号。
  1. package-lock.json 文件内容更丰富,实现了更密集的锁文件,包括子依赖的提升信息。
💡
1)package.lock 文件就可以确定 node_modules 目录结构。 2)yarn.lock 无法确定顶层依赖,需要 package.json 和 yarn.lock 两个文件才能确定 node_modules 目录结构。 3)node_modules 目录中 package 的位置是在 yarn 的内部计算出来的,在使用不同版本的 yarn 时可能会引起不确定性。

缓存机制

1、npm缓存
在执行 npm install 或 npm update 命令下载依赖后,除了将依赖包安装在node_modules 目录下外,还会在本地的缓存目录缓存一份。
通过 npm config get cache 命令可以查询到,在 Linux 或 Mac 默认是用户主目录下的 .npm/_cacache 目录。在这个目录下又存在两个目录:
  • content-v2 目录用于存储 tar包的缓存
  • index-v5目录用于存储tar包的 hash
所以npm 在执行安装时,使用缓存的步骤是:
  1. 根据 package-lock.json 中存储的 integrity、version、name 生成一个唯一的 key 
    1. npm缓存现在是怎么做的?可以知道key的结算方式为:pacote:range-manifest:{url}:{integrity}通过SHA256得到的hash
  1. 通过该key到 index-v5 目录下的缓存记录,从而找到 tar包的 hash
  1. 再根据 hash 再去找缓存的 tar包直接使用
我们可以找一个包在缓存目录下搜索测试一下,在 index-v5 搜索一下包路径:
$ npm config get cache
/Users/lei/.npm

$ cd /Users/lei/.npm/_cache

$ grep "https://registry.npmjs.org/base64-js/-/base64-js-1.0.1.tgz" -r index-v5
notion image
然后我们将json格式化:
{
  "key": "pacote:version-manifest:https://registry.npmjs.org/base64-js/-/base64-js-1.0.1.tgz:sha1-aSbRsZT7xze47tUTdW3i/Np+pAg=",
  "integrity": "sha512-C2EkHXwXvLsbrucJTRS3xFHv7Mf/y9klmKDxPTE8yevCoH5h8Ae69Y+/lP+ahpW91crnzgO78elOk2E6APJfIQ==",
  "time": 1575554308857,
  "size": 1,
  "metadata": {
    "id": "base64-js@1.0.1",
    "manifest": {
      "name": "base64-js",
      "version": "1.0.1",
      "engines": {
        "node": ">= 0.4"
      },
      "dependencies": {},
      "optionalDependencies": {},
      "devDependencies": {
        "standard": "^5.2.2",
        "tape": "4.x"
      },
      "bundleDependencies": false,
      "peerDependencies": {},
      "deprecated": false,
      "_resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.0.1.tgz",
      "_integrity": "sha1-aSbRsZT7xze47tUTdW3i/Np+pAg=",
      "_shasum": "6926d1b194fbc737b8eed513756de2fcda7ea408",
      "_shrinkwrap": null,
      "bin": null,
      "_id": "base64-js@1.0.1"
    },
    "type": "finalized-manifest"
  }
}
上面的 _shasum 属性 6926d1b194fbc737b8eed513756de2fcda7ea408为 tar包的 hash,前几位 6926即为缓存的前两层目录,我们进去这个目录果然找到的压缩后的依赖包:
notion image
💡
笔者注,可能是我本地npm(v6)缓存不够,按照上述方法没有找到对应的缓存。
npm 提供了几个命令来管理缓存数据:
  • npm cache add:官方解释说这个命令主要是 npm 内部使用,但是也可以用来手动给一个指定的 package 添加缓存。
  • npm cache clean:删除缓存目录下的所有数据,为了保证缓存数据的完整性,需要加上 -force 参数。
  • npm cache verify:验证缓存数据的有效性和完整性,清理垃圾数据。
基于缓存数据,npm 提供了离线安装模式,分别有以下几种:
  • -prefer-offline: 优先使用缓存数据,如果没有匹配的缓存数据,则从远程仓库下载。
  • -prefer-online: 优先使用网络数据,如果网络数据请求失败,再去请求缓存数据,这种模式可以及时获取最新的模块。
  • -offline: 不请求网络,直接使用缓存数据,一旦缓存数据不存在,则安装失败。
💡
TODO:我在npm官网看到上述3种离线安装模式都为false,那到底默认是哪种模式呢?!
2、yarn缓存
使用命令 yarn cache dir 可以查看缓存数据的目录,yarn的缓存模块被存放在独立的文件夹,文件夹名称包含了模块名称、版本号等信息。
yarn 默认使用 prefer-online 模式,即优先使用网络数据,如果网络数据请求失败,再去请求缓存数据。

锁定依赖版本

1、package.json中固定版本
在package.json中去掉版本号前面的~或者^,使其版本固定下来,或安装的时候加上--save-exact参数。但这样只能锁定最外一层的依赖,也就是这个依赖本身的其他依赖版本是不受控制的。
推荐星:⭐️⭐️⭐️
2、跟踪package-lock.json或yarn-lock.json
在前一种固定版本基础上,通过git跟踪lock文件来实现锁定版本,即利用无需生成新的lock文件来避免版本更新。
推荐星:⭐️⭐️⭐️⭐️
3、npm + npm-shrinkwrap.json
该文件是通过运行npm shrinkwrap命令产生的,在npm 5之前(不包括5),主要是用来精准控制安装指定版本的npm包。
推荐星:⭐️
4、npm ci
npm ci 类似于 npm install,常在用于自动化环境,如测试平台、持续集成和部署。区别是:
  • 该项目必须有一个 package-lock.json,且package-lock.json 中的依赖项与 package.json 的依赖项必须匹配。如果不满足时 npm ci 则将退出并显示错误,而不会更新 package-lock.json。
  • npm ci 只能一次安装整个项目,无法添加单个依赖项,不会写入 package.json 或任何锁包,因为安装功能基本上是冻结的。
  • 如果 node_modules 已经存在,它将在 npm ci 开始安装之前自动删除。
因此可强制开发者在持续集成前先在本地解决依赖版本的一致性问题。
推荐星:⭐️⭐️⭐️⭐️
5、yarn --frozen-lockfile
npm ci类似,需要存在yarn-lock.json文件。
推荐星:⭐️⭐️⭐️⭐️
💡
但锁版本可能会带来的问题,诸如旧版本依赖存在安全隐患无法解决,或者无法引入新功能等一系列问题。这个要视具体情况来解决。

最佳实践

根据经验来看,主要集中在下面几点:
1、统一包管理器
推荐yarn,不混用管理器。npm可能存在版本不一致的情况,也暂不接受pnpm等之类的新管理器,还有很多旧工程,稳定是第一要求。
2、lock文件需要git版本跟踪
  • 生产项目:把 package-lock.json 文件提交到代码版本仓库,从而保证所有团队开发者以及 CI 环节可以在执行 npm install 时安装的依赖版本都是一致的。
  • 第三方包:这时包是需要被其他仓库依赖的,由于扁平安装机制,如果你锁定了依赖包版本,你的依赖包就不能和其他依赖包共享同一 semver 范围内的依赖包,这样会造成不必要的冗余。所以我们不应该把package-lock.json 文件发布出去( npm 默认也不会把 package-lock.json 文件发布出去)。
3、锁定版本号
如果项目是生产环境,建议大家锁定部分核心组件的版本号,避免本地可以,但其他电脑或发布后就报错。
4、增加yarn --frozen-lockfile
在生产环境中的打包命令前,增加执行yarn --frozen-lockfile用来检验依赖一致。
💡
笔者一般是使用yarn且跟踪lock文件,同时锁定全部组件版本号,包括devDependencies。如果锁定部分版本号,也会导致已跟踪的lock文件发生变更。
 

参考资料:
 

© 刘德华 2020 - 2023