npm包管理器调研

npm包管理器调研

Posted by SkioFox on August 25, 2022

截止到2022年,目前最流行的包管理工具,有三个

  • npm: node自带
  • yarn: facebook与谷歌等大公司联合出品
  • pnpm: 一个新的高性能包管理工具

公司目前统一使用的是yarn classic版本

为什么围绕着包管理工具一直有不同的方案及工具在产生,原因主要是,旧的工具有问题,而新的工具在解决了旧的问题时,又带来了一些新的问题,所以才会有不同的包管理工具

在选择合适的工具前提下,就是需要深入了解下各个包管理工具,是怎么来的,解决了哪些问题,又带来了哪些问题,最终目标是什么

包管理器历史

npm

最早的包管理器是npm,在2010年1月发布,它解决了手动下载包,及手动管理依赖包的问题

但是它也带来了以下问题

  • npm3.0之前依赖嵌套过深,在window上的路径问题,windows上路径不能超过MAX_PATH,而MAX_PATH 只有 248 字符,所以依赖过深的时候很容易install失败
  • 依赖存在多份,浪费磁盘空间
  • 依赖版本无法锁定及依赖顺序无法确定,导致的一致性问题
  • 性能问题

yarn classic

yarn classic在2016年发布,产生的原因就是为了解决npm当时存在的问题,在解决问题的同时,又带来了一些新的特性,但是也引入了一些新的问题

解决的问题及带来的新特性

  • 依赖提升,扁平化解决window路径过长问题
  • 引入全局缓存,可以依赖复用
  • 引入了lockfile概念,创建yarn.lock文件,锁定依赖版本,保证依赖顺序
  • 重新定义了安装过程,提升了性能
  • 原生支持monorepo能力
  • 提供了离线能力

但是也带来了一些新的问题

  • 依赖提升,导致的幽灵依赖问题
  • 不同版本的依赖都会在全局缓存目录存在一份,且在多个项目之间都会存在相同的一份,导致磁盘消耗过大
  • yarn.lock锁定了registry,无法在CI/CD场景动态变更registry

pnpm

pnpm 2017年由Zoltan Kochan作者发布,产生的原因是yarn classic虽然比npm有性能上的提升,但是在磁盘存储这一块提升效果不大,不同项目之间相同依赖还是会存储多份,且依赖提升导致的幽灵依赖问题,所以作者想通过一种新的方式来解决这些问题

pnpm作者,通过一种内容寻址的存储方式来创建node_modules目录下的结构,该方法会生成一个嵌套的 node_modules 文件夹,该文件夹将包存储在电脑的主文件夹 (~/.pnpm-store/) 上的全局存储中。每个版本的依赖项仅物理存储在该文件夹中一次,构成单一事实来源并节省相当多的磁盘空间

最终的目录如下所示

从pnpm 2021年的年度报告我们可以看出

  • pnpm的下载量已经击败了bower
  • pnpm 2021年的下载量是2020年的3倍
  • yarn在3.1中添加了pnpm链接器,同时Yarn 团队计划实施内容可寻址存储,以提高磁盘空间效率
  • npm 团队还决定采用 pnpm 使用的符号链接节点模块目录结构

yarn berry

yarn berry在2020年1月发布,yarn团队称其为Yarn Berry,是对yarn classic的完全重构,并且二者是不兼容的

yarn berry版本通过一种新的Plug’n’Play (PnP) 方式重新定义了一种新的包存储与寻址方式,即不在项目目录下生成node_modules目录,而是在全局目录下or项目目录下生成.yarn/cache/目录,该目录内存储所有的npm zip包,然后在在项目根目录下创建一个.pnp.cjs文件,在.pnp.cjs文件内包含依赖查找表,这样做的好处

  • 不需要解压tgz包,节省内存空间与去掉了解压时间
  • 大幅度降低I/O操作,因为不需要向项目内创建node_modules,也就不需频繁的读写文件
  • 加快应用启动时间及缩短构建时间,因为通过.pnp.cjs可以直接获取包地址,而不需要遵循node的向上查找模块的方法

虽然pnp带来了巨大的好处,但是对于javascript社区来说则是一项挑战,因为pnp模式下很多npm包不兼容,比如react-navative生态内的很多包等,并且这种激进的方式一下改变了大家对于npm包放在node_modules下面的心智模型,所以发布yarn berry发布之初,导致社区的吐槽,甚至抵制,yarn berry团队也根据社区的反馈,提取了一系列解决方案,

  • 不支持在项目目录内生成node_modules,现在可以通过通过声明配置来解决
  • pnp模式下不兼容的包会抛错,现在可以选择松散模式,只会给出警告
  • yarn classic被定义为具有完整功能的经典版本,而不是遗弃版本等

更多内容可以参考 tackled many issues


Plug’n’Play (PnP)

PnP是一种创新的node包安装策略,在2018年被提出来,借鉴的是其它语言中的一些类似的实现,比如php中的自动加载

为什么会提出这种模式?原因是最开始设计的node_modules是不合理的,npm or yarn这些包管理工具将包安装并生成到node_modules目录之后,node会通过内置的模块查找算法(向上搜索node_modules)来查找模块,而这种查找方式是低效的,低效的具体原因如下

  • 通常node_module包含很多模块,而生成这些模块需要占用很多的时间
  • 生成node_modules的过程是一个频繁I/O操作的过程,包管理器除了做一些简单的复制之外,没有太多的优化余地,就算用软硬链接的方式也是需要进行一堆系统调用来操作磁盘之前,仍然需要区分文件系统的当前状态
  • node没有包的概念,不能确定文件是否被访问,这种情况可能会出现本地正常,发布之后就出现缺少某个依赖的问题,原因是在package.json内少声明了依赖项
  • 然后node_modules创建之后,每次运行node,node默认的模块解析算法,也需要调用很多stat、readdir这样的方法来确定从哪里加载文件,这是很浪费时间的
  • 最后node_modules的设计是不切实际的,原因是它不允许包管理器正确地对包进行重复数据删除。尽管可以使用一些算法来优化树布局(提升),但仍然无法优化某些特定模式

那么怎么解决这个问题呢?

包管理工具其实已经知道有哪些包,并且包存放的位置也是知道的,所以只要主动告诉node这些包存放的位置,那么完全就不需要通过node内置的模块解析算法去查找模块了,这就是pnp

pnp带来的好处

  • install效率会大幅度提升,install的瓶颈在于项目包的数量而不是磁盘I/O
  • 由于减少了 I/O 操作,安装更加稳定和可靠
  • 完美优化依赖树与可预测的包实例化
  • 可以实现zero install
  • 更快的node程序启动速度,因为模块查找时间减少

更多内容可以查看pnp文档

随着时间的推移pnp这种概念被社区认可,pnpm也在2020年底支持pnp模式


zero install

给项目提供一种不需要二次install的能力,为什么可以做到?

我们在npm or yarn classic中所有的依赖都是解压并copy到项目的node_modules内的,而我们项目通常又具有比较多的依赖包,这就导致了我们的node_modules文件数量过多及过大,通常一般一点的项目都至少有300M以上及10000个文件以上,这就导致我们不太好将项目的node_modules目录纳入到git管理中去,一个是文件过多过大,另一个是容易造成冲突,冲突不好解决

而yarn berry使用了pnp的能力之后,完全生成了一个非node_modules结构的目录,且大部分npm包是以zip包的方式进行存储,这样项目的依赖数量及大小立马降了几个层次,所以可以让我们把依赖直接纳入git管理,这样我们在CI/CD上就不需要install依赖

更多内容查看zero-installs文档

安装

npm

node自带npm包管理工具,可以通过npm update nom -g升级npm版本,或者通过npm install npm@xxx -g 安装指定版本

yarn

yarn classic版本可以通过不同的方式进行安装,比如通过npm安装npm i yarn -g

yarn classic升级到yarn berry的方式

  • 安装或者将yarn classic版本更新到最新的yarn 1.x版本
  • 使用命令行yarn set version berry 更新到v2版本

这里推荐使用yarn set version berry方式更新,这样就可以保证只有该项目用到berry,而其它项目还是正常的可以使用classic

另一种做法是通过corepack方式

# you need to opt-in first
$ corepack enable
# shim installed but concrete version needs to activated
$ corepack prepare yarn@3.1.1 --activate

pnpm

可以使用npm方式安装npm i pnpm -g or corepack方式安装corepack prepare pnpm@6.24.2 --activate

配置文件

包管理工具 配置文件 读取规则
npm .npmrc 项目目录.npmrc>用户目录.npmrc
yarn classic .yarnrc 项目目录.npmrc>用户目录.npmrc>项目目录.yarnrc>用户目录.yarnrc
yarn berry .yarnrc.yml 项目目录.yarnrc.yml,没有全局配置文件,也不会读取.npmrc or .yarnrc
pnpm .npmrc 沿用npm配置文件及读取规则

install流程

yarn classic

install的时候如果没有lockfile文件,会从package.json内的dependencies与devDependencies内按照包的顺序开始遍历,如果获取到包的metadata内有dependencies则会继续递归获取,知道所有包的版本信息确定完之后,才会进行下一步获取tarball

yarn berry install

pnpm install

其实整个install所做的事情是差不多的,差异大的是实现细节

做的事情差不多,那么pnpm为什么会比yarn、npm都快,除了第三步link阶段实现有差异之外,整体架构也是有差异的

yarn、npm 串行执行每一步

pnpm是每个包并行执行这三个步骤,也就是说不会由于第一步或者第二步某个包卡住而影响后面的进度

lockfile与依赖存储方式

lockfile

包管理工具 配置文件 备注
npm package-lock.json 只包含依赖版本,可以通过install时的registry去拉去依赖
yarn classic yarn.lock install的时候,不能读取全局registry或者–registry,而是resolved值是什么就是什么
yarn berry yarn.lock 读取.yarnrc.yml内的npmRegistryServer参数
pnpm pnpm-lock.yaml 与npm一致

依赖存储目录结构

npm与yarn classic的就不说了,说下yarn berry及pnpm的

yarn berry pnp模式依赖存储结构

yarn berry pnp 模式,会在项目目录下创建一个.yarn目录,目录结构如下所示

.
├── cache # 存放大多数npm包
│   ├── .gitignore
│   ├── @ampproject-remapping-npm-2.1.2-d1536e36d6-e023f92cdd.zip
│   ├── @ant-design-colors-npm-6.0.0-8629027ebe-55110ac8a3.zip
├── install-state.gz
├── releases # yarn脚本
│   └── yarn-3.2.0.cjs
├── sdks # 用来执行eslint、prettier等命令的脚本,如果没有初始化这些脚本,eslint、prettier无法正常使用
│   ├── eslint
│   ├── integrations.yml
│   ├── prettier
│   └── typescript
└── unplugged # 包含postinstall or 有平台区分的依赖解压之后放到这里
├── @yunke-yunke-setting-plugin-npm-2.5.4-39176c8529
├── clipboardy-npm-2.3.0-9566d5e797
├── core-js-npm-2.6.12-0b93d77d31
    ├── core-js-npm-3.21.1-4b064616b4
# .pnp.cjs内的依赖位置表
["@ant-design/icons-svg", [\
        ["npm:4.2.1", {\
          "packageLocation": "./.yarn/cache/@ant-design-icons-svg-npm-4.2.1-c7038236c5-c1fa1bbeb0.zip/node_modules/@ant-design/icons-svg/",\
          "packageDependencies": [\
            ["@ant-design/icons-svg", "npm:4.2.1"]\
          ],\
          "linkType": "HARD"\
        }]\
]],\

yarn berry pnp模式下生成的依赖完成没有按照传统的node_modules目录来生成,而是yarn berry自己重新设计的一种存储方式,然后在通过pnp的方式记录了依赖与路径的映射关系,所以使用没有在依赖内声明的依赖,会直接报错,因为找不到映射关系,也不会像传统方式那样向上级node_modules目录查找

同时安装的依赖除非是包含postinstall or 有平台区分的依赖才会被解压安装在unplugged目录,其它包都会通过zip的方式来进行存储,这里就必须依赖yarn的特殊处理,才能保证node fs系统可以读取zip内的js文件

pnpm 依赖存储结构
{
  "dependencies": {
    "react": "^17.0.2",
    "react-dom": "^17.0.2"
  }
}

还是传统的node_modules目录,目录结构如下所示

.
├── .modules.yaml
├── .pnpm
│   ├── js-tokens@4.0.0
│   │   └── node_modules
│   │       └── js-tokens
│   ├── lock.yaml
│   ├── loose-envify@1.4.0
│   │   └── node_modules
│   │       ├── js-tokens -> ../../js-tokens@4.0.0/node_modules/js-tokens
│   │       └── loose-envify
│   ├── node_modules
│   │   ├── .bin
│   │   │   └── loose-envify
│   │   ├── js-tokens -> ../js-tokens@4.0.0/node_modules/js-tokens
│   │   ├── loose-envify -> ../loose-envify@1.4.0/node_modules/loose-envify
│   │   ├── object-assign -> ../object-assign@4.1.1/node_modules/object-assign
│   │   └── scheduler -> ../scheduler@0.20.2/node_modules/scheduler
│   ├── object-assign@4.1.1
│   │   └── node_modules
│   │       └── object-assign
│   ├── react-dom@17.0.2_react@17.0.2
│   │   └── node_modules
│   │       ├── loose-envify -> ../../loose-envify@1.4.0/node_modules/loose-envify
│   │       ├── object-assign -> ../../object-assign@4.1.1/node_modules/object-assign
│   │       ├── react -> ../../react@17.0.2/node_modules/react
│   │       ├── react-dom
│   │       └── scheduler -> ../../scheduler@0.20.2/node_modules/scheduler
│   ├── react@17.0.2
│   │   └── node_modules
│   │       ├── loose-envify -> ../../loose-envify@1.4.0/node_modules/loose-envify
│   │       ├── object-assign -> ../../object-assign@4.1.1/node_modules/object-assign
│   │       └── react
│   └── scheduler@0.20.2
│       └── node_modules
│           ├── loose-envify -> ../../loose-envify@1.4.0/node_modules/loose-envify
│           ├── object-assign -> ../../object-assign@4.1.1/node_modules/object-assign
│           └── scheduler
├── react -> .pnpm/react@17.0.2/node_modules/react
└── react-dom -> .pnpm/react-dom@17.0.2_react@17.0.2/node_modules/react-dom
  • node_modules一级子目录只有dependencies与devDependencies,这样做的目录的保证,项目内只能引用dependencies与devDependencies,而不能引用依赖的依赖,避免出现幽灵依赖
  • node_modules一级目录对应的依赖是通过软链的方式链接了.pnpm/react@17.0.2/node_modules/react,这样做的目的,保证了依赖自己内部可以直接引用自身
  • .pnpm/react@17.0.2/node_modules/react依赖又是通过硬链的方式,从pnpm全局缓存目录中硬链出来,这样可以保证一个包只会在电脑上站用一份包的存储空间
  • .pnpm目录下的依赖都是package@version/node_modules/package、dependenceA、dependenceB…,这样设计的目的是保证每个子依赖只能只能引用声明了的依赖,避免出现幽灵依赖
  • pnpm目录下的依赖的依赖都是通过软链的形式链接到.pnpm目录对应的依赖,比如.pnpm/react@17.0.2/node_modules/loose-envify软链的就是.pnpm/loose-envify@1.4.0/node_modules/loose-envify 这样通过软链打平子级依赖的目的是为了保证,依赖层级不会嵌套过深,出现路径过长问题,同时更方便查看依赖

perdependencies处理大体类似,稍微有点不同,可以参考文档如何处理 peers

yarn berry与pnpm分别用两种不同的方式来解决包的存储与速度问题,都值得借鉴与学习

monorepo支持

yarn berry workspace相对yarn classic做了一些调整

  • 在berry版本里面,private”: true 可以不需要声明了
  • 新增了workspace:协议,来实现monorepo包的依赖关系

pnpm workspace与yarn classic使用的差不多的

  • 需要单独的pnpm-workspace.yaml定义工具区间
  • 支持workspace:协议
  • 没有workspace命令,但是有–filter参数

其实本质上包管理工具实现的工作区间功能过于基础,不能实现比如依赖版本联动,changelog生成等高级功能,这样才有了lernachangesetsrush等配套的基于工具包workspace的工具

但是lerna目前处于无人维护的状态 ,导致不支持yarn berry、pnpm等包管理工具

changesetsrush都是比较新的包管理工具,待验证

性能

  • P1: 无全局缓存or项目缓存, 无锁文件, 无 node_modules
  • P2: 无全局缓存or项目缓存, 有锁文件, 无 node_modules
Method Yarn v1 pnpm pnpm pn g yarn v2 pnp strict yarn v2 pnp loose yarn v2 node-modules yarn v2 pnpm
P1 39.75s 19.99s 16.30s 74.50s 58.77s 86.16s 72.50s
P2 29.10s 14.06s 11.92s 51.11s 50.84s 56.32s 53.59s

从数据来看pnpm效果最佳,其次是yarn classic,最后才是yarn berry

当然还可以从另外几个维度来看,比如有全局缓存、node_modules之后install的时间是怎么样的

当把这个维度都补全的会,会发现项目内有node_modules>全局node_modules>lockfile>no cache + no lockfile

所以我们要想达到项目最佳的一个install效果,肯定是项目联同node_module or .yarn之类的缓存目录可以一起提交到git;其次是有全局缓存,最后是有lockfile;

从我们实践的效果来看,保留lockfile是成本最低的,而保留全局缓存次之,最后才是node_module or .yarn之类的缓存目录一起存储

常见问题

yarn classic error Your lockfile needs to be updated, but yarn was run with –frozen-lockfile

原因:手动改动了package.json内的依赖,然后没有重新install

解决:在本地重新yarn install 之后,查看yarn.lock文件是否有变更,然后重新提交代码

npm or yarn 安装失败,出现404 @yunke/cli is not in the npm register

原因:没有配置云客源,因为云客有自己的私仓

解决:两种方式

  1. 直接将源设置成云客代理源 npm config set registry https://registry-npm.myscrm.cn/repository/pkg/
  2. 设置单独的针对云客scope的源 npm config set @yunke:registry https://registry-npm.myscrm.cn/repository/pkg/

error https://registry.npmjs.org/esbuild-linux-64/download/esbuild-linux-64-0.13.12.tgz: Request failed “400 Bad Request”

原因:标准的metadata内tarball链接地址是/-/,而不是/download/,/download/是淘宝源才能识别的写法,其它源都不能识别

解决:将/download/换成/-/

pnpm Error: The following dependencies are imported but could not be resolved

pnpm默认是没有依赖提升的,当我们在项目中使用了依赖的依赖之后就会提示这样的错误,两种解决方式

  1. 在package.json中将这些依赖添加到dependencies内
  2. 将依赖进行提升,项目下创建.npmrc,然后配置如下
public-hoist-pattern[]='lodash'
public-hoist-pattern[]='react-router'
public-hoist-pattern[]='@yunke/yunke-back-business-logic'

yarn classic 清除缓存不生效

yarn cache clean # 不生效

可以通过yarn cache dir获取缓存目录,然后rm -rf 缓存目录即可

查看install耗时具体步骤

可以通过参数来看下install的具体过程,从而知道具体的耗时

yarn classic yarn --verbose

npm npm --verbose

yarn berry logFilters: level:info

pnpm pnpm --reporter=ndjson

结论

yarn berry 与 pnpm的异同点

相同点

  • 支持pnp
  • 没有幽灵依赖问题
  • 支持workspace
  • 解决yarn classic 与 npm中存在的大部分问题

差异点

  • pnpm采用总体并发方式执行流程,而yarn berry总体还是采用串行方式执行流程,首次install速度相对pnpm慢
  • yarn berry采用的zip包方法存储依赖,可以实现zero install,而pnpm没有这个能力
  • yarn berry采用zip包方式,不利于调试包内的代码
  • yarn berry pnp方式过于严格,在很多项目下使用会导致报错,虽然可以通过packageExtensions参数来解决依赖的问题,但是还是会出现必须要依赖同一个包实例而不好处理的场景,而yarn berry pnp又没有提升依赖包的能力,所以一些特殊的场景不好处理
  • pnpm通过node_modules目录结构的设计避免了幽灵依赖,同时又提供了public-hoist-pattern参数来提升依赖,可以解决一些第三方依赖的问题,相对于yarn berry pnp的使用成本更低

结论1: 鉴于yarn berry的node_module模式与pnpm模式对pnpm没有速度优势,而pnp模式下的zero install要求使用人员有一定的排错与调试能力,故对于非menorepo项目最终推荐使用pnpm来取代yarn classic


结论2: 鉴于pnpm与yarn berry都不能与lerna配合使用,所以对于monorepo场景,暂时还是使用yarn classic