从npm 到 yarn 再到 pnpm —— 为什么要使用pnpm?

7,618 阅读19分钟

npm & yarn

npm作为node官方的包管理工具,随着node的诞生一起出现在大家的视野中,随着npm的出现,也带来前端社区的一片繁荣,社区非常活跃得想npm贡献了很多轮子。这里简单说一下,npm分为前端站点、npm数据库和npm-cli,下面讨论npm都指的是npm-cli部分。

早期npm

npm设计理念主要是根据语义化版本 (semver),语义化版本解决的是依赖管理的问题,对于依赖的版本号定义由三部分组成:MAJOR.MINOR.PATCH,其每部分的含义如下:

  • 主版本号(MAJOR):当你做了不兼容的 API 修改。
  • 次版本号(MINOR):当你做了向下兼容的功能性新增,
  • 修订号(PATCH):当你做了向下兼容的问题修正。

例如当我们运行npm i lodash --savelodash将会被当做依赖加入到package.json中:

"dependencies": {

  "loadsh": "^4.17.4"

}

其中^这个符号告诉npm,在安装依赖时,npm会安装最新的主版本为4的lodash依赖,例如lodash@4.25.2npm允许这样安装,是因为相信依赖的作者是完全遵守语义化版本控制规范的,也就是说主版本为4的lodash的最新版本的API是兼容老版本的API的。

所以我们在开发npm包的时候,应该完全遵守协议中的约定。

早期的npm安装并不能保证依赖树的稳定,也并不是所有的 npm包作者都会遵守语义化版本协议。所以很可能同样的pakcage.json,依赖树却不稳定。

虽然早期npm有shrinkwrap特性,保证依赖树的稳定,但是不是默认启动的。

此外,当npm在安装依赖树的时候,由于npm中的依赖会依赖其他依赖,npm早期的策略是会完全按照目录结构安装整个依赖树。这样好处是依赖树比较清晰好懂,但是坏处很多,其中一个就是「依赖黑洞」:

img

因为完全按照依赖的依赖生成依赖树,所以导致很多重复的依赖被重复安装:

img

图中依赖A被其他依赖依赖,同一份依赖理论上只需要安装一次,但是在npm的策略当中相同的依赖A却被安装了三次或者更多次。

除了大量重复的依赖被安装浪费磁盘空间之外,还有其他两个问题:

  1. 依赖层级过深,会存在文件路径过长的问题。尤其在window下,很多程序无法处理超过260个字符。
  2. 无法共享实例,有一些程序要求在运行时只能有一个实例。由于安装了多个依赖,无法共享同一份代码

依赖打平

为了解决依赖重复安装这个问题,npm在v3版本实现了依赖树打平。

原来相同的依赖会重复安装:

img

现在相同的依赖会提升到最上层:

img

为什么能这样做呢?一切要从node的require机制说起,当我们运行require("xxx")引入外部模块时(这里不考虑直接按路径引用文件或者文件夹的情况,详细规则可以查看node官网),有两种情况:

  • 如果xxx是一个node核心模块,例如fshttp等,那么返回node核心模块。
  • 如果不是,那么会判断判断当前node_modules 文件夹是否有此模块,如果有就返回,如果没有就递归往上层的node_modules目录查找,如果找到就返回,如果到根目录都没找到就报错。

img

node_modules 就是npm安装包时,会默认拷贝外部依赖的目录。

所以当依赖B中的代码使用require("A")的时候,找不到A,会往上层的文件夹的node_modules中继续寻找,所以利用node的require机制,我们可以尽可能的把复用的依赖提升到最上层

然后这样貌似解决了我们的问题,事实上并没有而且引入了更大的问题。

先说一说为什么没有完全解决重复安装依赖的问题,假如在上面的基础上依赖B和依赖C同时依赖了A的最新版本A@2.0.0,其实还是会重复安装依赖A@2.0.0两次。

img

其次,这可能会导致依赖树不稳定的情况,假如我们依赖A有两个版本A@1.0.0和A@2.0.0两个版本分别被B和C依赖,

那么npm会提升哪一个依赖呢?

img

事实上,npm也不知道,这取决于他们在package.json中声明的顺序。

最后,这可能导致“幽灵依赖”的问题。在我们的package.json中只声明了依赖B,但是B依赖了A。

dependencies: {

	"B":"^1.0.0",

}

但是我们却可以在我们的自己的代码中却引用到A,或许大多数情况下没有问题,但是如果依赖B升级了A的依赖版本,然后A有一些break change的话,在我们代码中使用A可能将会报错。

到此npm v3为止,我们知道的问题有:

  1. 依赖重复安装问题。虽然解决了,但是没有完全解决。
  2. 依赖树不稳定问题。
  3. 无法共享实例问题。
  4. 幽灵依赖问题。

yarn带来了什么

yarn一开始发布,就受到了社区热烈欢迎,大家“苦npm久矣”,这也反向推动npm更新迭代,实现了一些yarn的功能。

下面是yarn刚发布时的宣传一些特性:

  • 扁平模式。
  • 确定性。
  • 离线模式。
  • 网络性能。
  • 下载重试机制

扁平模式与确定性

其中扁平模式,跟npm v3一样的扁平的结构来管理依赖,但是他是如何保证依赖树结构的确定性的呢?

yarn会在安装的时候默认生成一个yarn.lock文件。后续npm也学习yarn,每次安装会默认生成package-lock.json。

yarn.lock和package-lock.json相同的是他们都是用来确定依赖树的结构;不同的是,yarn.lock是用yaml格式,而package-lock.json是json格式。

除此之外,yarn的结构中的子依赖时不锁版本,所以yarn 是需要把yarn.lock 和 package.json 结合一起决定整个依赖树的版本的:

img

而package-lock.json中的依赖版本是指定版本的,也就是说npm能直接根据package-lock.json 构建完整的依赖树,实际上npm一开始就是准备只用package-lock.json 来安装依赖的,毫无疑问,这肯定是有问题的,所以现在npm也会结合package.json来决定的依赖树。

img

离线模式

除此之外,yarn还支持了npm不支持的离线模式,不过后续npm也支持了该特性。所谓离线模式,就是说直接用全局缓存中的依赖,而不使用网络请求。我们在使用npm install安装依赖时,其实npm还会在本地缓存目录缓存一份,运行npm config get cache 可以查看。

这里说说npm现在的支持的下载策略:

  • 默认策略:总是发起网络请求,请求返回304才从本地缓存中读取文件,请求返回200从网络下载,并更新本地缓存。
  • --prefer-offline 本地找不到才会去网络请求。
  • --prefer-online 总是从网络上请求,网络请求失败才会去本地缓存中取。
  • --offline 强制使用本地缓存,本地缓存找不到将直接报错退出。

安全校验

如上面lock文件所示,每一个依赖的tar包都有一个哈希去校验文件完整性。

PnP

yarn带来了很多特性推动着npm的发展,例如会同时下载解析多个包提高下载速度,yarn1.0在做到安装依赖的速度体验比npm快之后,yarn依然没有完全解决上面说的依赖重复安装以及依赖重复安装带来的无法共享实例、幽灵依赖等问题,但是他确实提高了安装速度和使用体验。

于是使用yarn2.0来了——PnP,PnP是一个yarn的future,不要和pnpm搞混了。它的想法很简单,就是因为上面说的node的require机制,所以我们不得不采用嵌套依赖的文件目录结构,所以它要从根本上重写require的机制。

因为yarn在安装的时候,已经知道依赖树的一切,所以在node解析的时候,PnP直接返回对应的模块给node。相比生成一个node_modules的结构给node require自己去查找依赖,不如我告诉你他在哪!他的基本原理就是重写node的文件查找机制。

这样做有一些列好处:

  • 直接节省了生成node_modules 文件结构树会造成很多系统文件IO操作的时间
  • 防止幽灵依赖,因为不在dependency中的依赖永远不会引用到。比如全局的一些依赖,我们可能误引用到。
  • 在运行时,node 也不需要递归的不断地去node_moduels文件夹中去查找依赖。
  • 防止重复依赖的安装,现在所有相同的依赖只会安装一份,然后PnP帮你在运行时去引用。从根本上防止了幽灵依赖和实例无法共享问题。

PnP的问题

就真的这么完美吗?理论上是完美的。

但是,首先他直接改变了node解析模块的规则,node的解析规则可以重写,但是一些外部的包可能需要依赖node_modules的依赖结构,那么他们可能就有问题,例如:webpack、typescript、vscode等有实现了自己的模块解析代码,所以这些工具需要额外的plugin,例如pnp-webpack-plugin来改变webpack解析模块的规则,这里有官方出的兼容性的表格,有兼容的意味着有不兼容的,这里也有官方的不兼容的表格,除此之外还可能有其他的未被官方发现的第三方库的兼容性问题。

pnpm

pnpm完美的解决依赖重复安装的问题,而且实现了所有yarn的优秀的特性。但是相对于PnP激进的解决问题的思路不同,pnpm解决问题的思路是通过软链和硬链 ,并且pnpm的使用npm的使用并无差别,pnpm同样会尽量复用依赖。

连接介绍

硬连接

硬连接指通过索引节点来进行连接。在Linux的文件系统中,保存在磁盘分区中的文件不管是什么类型都给它分配一个编号,称为索引节点号(Inode Index)。在Linux中,多个文件名指向同一索引节点是存在的。一般这种连接就是硬连接。硬连接的作用是允许一个文件拥有多个有效路径名,这样用户就可以建立硬连接到重要文件,以防止“误删”的功能。其原因如上所述,因为对应该目录的索引节点有一个以上的连接。只删除一个连接并不影响索引节点本身和其它的连接,只有当最后一个连接被删除后,文件的数据块及目录的连接才会被释放。也就是说,文件真正删除的条件是与之相关的所有硬连接文件均被删除。

软连接

另外一种连接称之为符号连接(Symbolic Link),也叫软连接。软链接文件有类似于Windows的快捷方式。它实际上是一个特殊的文件。在符号连接中,文件实际上是一个文本文件,其中包含的有另一文件的位置信息。

安装速度

先看看速度对比,参考pnpm给个benchmark:

img

你可能会说「王婆卖瓜,自卖自夸」 ,或许你可以参考yarn2给的benchmark

那么为什么安装会这么快呢?

img

上图是npm & yarn的安装流程:

  • resolving。首先他们会解析依赖树,决定要fetch哪些安装包。
  • fetching。安装去fetch依赖的tar包。这个阶段可以同时下载多个,来增加速度。
  • wrting。然后解压包,根据文件构建出真正的依赖树,这个阶段需要大量文件IO操作。

img

上图是pnpm的安装流程,可以看到针对每个包的三个流程都是平行的,所以速度会快很多。当然pnpm会多一个阶段,就是通过链接组织起真正的依赖树目录结构。

为什么pnpm比PnP安装速度快?

pnpm的目录结构分析

要想明白彻底搞明白pnpm,就得搞清楚pnpm依赖的目录结构。

基本结构

软链接(符号链接) -> 表示,硬链接 --> 表示

假设我们的项目依赖foo@1.0.0,foo@1.0.0依赖bar@1.0.0。

首先pnpm会从全局store(或者指定的store),硬链接到.pnpm对应目录。例如:

node_modules

└── .pnpm

    ├── bar@1.0.0

    │   └── node_modules

    │       └── bar --> <store>/bar

    │           ├── index.js

    │           └── package.json

    └── foo@1.0.0

        └── node_modules

            └── foo --> <store>/foo

                ├── index.js

                └── package.json

其中bar和foo分别都硬链接到<store>里面的文件。这里注意我们实际上把bar放到了bar@1.0.0/node_modules/ 下面,这样有两个好处:

  1. 可以让bar能require到自己。例如require(bar/package.json).
  2. 防止循环软链接。如果把自己的依赖放到node_modules下面,有一些包会读自己node_modules里面的文件,就会导致循环软链。详情见讨论

然后,pnpm会通过软链链接依赖的依赖。foo@1.0.0依赖bar@1.0.0,bar将会被链接到foo@1.0.0/node_modules目录下面:

node_modules

└── .pnpm

    ├── bar@1.0.0

    │   └── node_modules

    │       └── bar --> <store>/bar

    └── foo@1.0.0

        └── node_modules

            ├── foo --> <store>/foo

            └── bar -> ../../bar@1.0.0/node_modules/bar

最后,pnpm会通过软链链接项目直接的依赖。foo将会被链接到node_modules下面:

node_modules

├── foo -> ./.pnpm/foo@1.0.0/node_modules/foo

└── .pnpm

    ├── bar@1.0.0

    │   └── node_modules

    │       └── bar --> <store>/bar

    └── foo@1.0.0

        └── node_modules

            ├── foo --> <store>/foo

            └── bar -> ../../bar@1.0.0/node_modules/bar

如果依赖bar和foo还需要依赖qar@2.0.0,那么目录结构如下:

node_modules

├── foo -> ./.pnpm/foo@1.0.0/node_modules/foo

└── .pnpm

    ├── bar@1.0.0

    │   └── node_modules

    │       ├── bar --> <store>/bar@1.0.0

    │       └── qar -> ../../qar@2.0.0/node_modules/qar

    ├── foo@1.0.0

    │   └── node_modules

    │       ├── foo --> <store>/foo

    │       ├── bar -> ../../bar@1.0.0/node_modules/bar

    │       └── qar -> ../../qar@2.0.0/node_modules/qar

    └── qar@2.0.0

        └── node_modules

            └── qar --> <store>/qar

我们发现,虽然现在依赖树的层级更深了,但是整个目录结构的层级依然是扁平的。pnpm通过软链接,把扁平的数据结构变成了树的数据结构。

在node运行时,如果node require到的目录是软链,那么他会去找到真正文件所在的位置。

这样的结构,就不会有依赖打平所带来的依赖问题。

peerDependency的处理

首先解释一下何为peerDependency?当我们在package.json的peerDependency字段里面声明依赖,意味着这个包期望运行时,能require到相关peer的依赖,一般是作为plugin的包有这样的需求。

因为npm & yarn 采用的是依赖打平的策略,所以整个依赖树是不可预测的,也就不能保证peerDependency里面声明的依赖的结构,所以npmv3 到 v6 及yarn都没有自动安装(v7会自动安装)peeryDependency,只是会在版本不匹配的时候产生warning。

peerDependencyMeta里面可以指定哪些依赖是optional的。

假设项目依赖foo-parent-1和foo-parent-2,其中foo-parent-1和foo-parent-2都依赖了foo、bar、baz。foo有两个peer为bar@^1和baz@^1。但是foo-parent-1和foo-parent-2依赖了不同的baz。

- foo-parent-1

  - bar@1.0.0

  - baz@1.0.0

  - foo@1.0.0

- foo-parent-2

  - bar@1.0.0

  - baz@1.1.0

  - foo@1.0.0

如果没不考虑peerDepnecy,就和上面的基本结构相差不大:

node_modules

└── .pnpm

    ├── foo@1.0.0

    │   └── node_modules

    │       ├── foo

    │       ├── qux   -> ../../qux@1.0.0/node_modules/qux

    │       └── plugh -> ../../plugh@1.0.0/node_modules/plugh

    ├── qux@1.0.0

    ├── plugh@1.0.0

如果考虑peerDepency,其目录结构为:

node_modules

└── .pnpm

    ├── foo@1.0.0_bar@1.0.0+baz@1.0.0

    │   └── node_modules

    │       ├── foo   --> <store>/foo@1.0.0

    │       ├── bar   -> ../../bar@1.0.0/node_modules/bar

    │       ├── baz   -> ../../baz@1.0.0/node_modules/baz

    │       ├── qux   -> ../../qux@1.0.0/node_modules/qux

    │       └── plugh -> ../../plugh@1.0.0/node_modules/plugh

    ├── foo@1.0.0_bar@1.0.0+baz@1.1.0

    │   └── node_modules

    │       ├── foo   --> <store>/foo@1.0.0

    │       ├── bar   -> ../../bar@1.0.0/node_modules/bar

    │       ├── baz   -> ../../baz@1.1.0/node_modules/baz

    │       ├── qux   -> ../../qux@1.0.0/node_modules/qux

    │       └── plugh -> ../../plugh@1.0.0/node_modules/plugh

    ├── bar@1.0.0

    ├── baz@1.0.0

    ├── baz@1.1.0

    ├── qux@1.0.0

    ├── plugh@1.0.0

根据peerDependency里面依赖的版本的不同,排列组合出不同的目录结构,这些结构里面,其不同的依赖版本会在其node_modules目录里。在这个例子中,因为依赖foo的依赖(即foo-parent-1和foo-parent-2)的baz依赖有两个版本,所以会组成两个不同的目录:foo@1.0.0_bar@1.0.0+baz@1.0.0foo@1.0.0_bar@1.0.0+baz@1.1.0。同理,如果baz也有两个版本,将会组成四个目录。这些在这些目录下的node_modules目录下的foo(例如foo@1.0.0_bar@1.0.0+baz@1.0.0/node_moudles/foo )都是硬链接到了/foo。

最后.pnpm/parent-foo-1/node_modules/foo 其实是链接到了foo@1.0.0_bar@1.0.0+baz@1.0.0目录(因为其baz的依赖和bar的依赖都是1.0.0)。

如果依赖的依赖有peerDependency,那么会怎么处理呢?

例如:a依赖b,a并没有peerDependency,但是b的依赖有peerDependency c,其依赖树上游依赖不同版本的c(例如a-parent-1依赖c@1.0.0,a-parent-2依赖c@1.0.1)。那么结构如下:

node_modules

└── .pnpm

    ├── a@1.0.0_c@1.0.0

    │   └── node_modules

    │       ├── a

    │       └── b -> ../../b@1.0.0_c@1.0.0/node_modules/b

    ├── a@1.0.0_c@1.1.0

    │   └── node_modules

    │       ├── a

    │       └── b -> ../../b@1.0.0_c@1.1.0/node_modules/b

    ├── b@1.0.0_c@1.0.0

    │   └── node_modules

    │       ├── b

    │       └── c -> ../../c@1.0.0/node_modules/c

    ├── b@1.0.0_c@1.1.0

    │   └── node_modules

    │       ├── b

    │       └── c -> ../../c@1.1.0/node_modules/c

    ├── c@1.0.0

    ├── c@1.1.0

总之,pnpm会调整依赖树的结构,来保证peerDependency的准确。

monorepo

其实monorepo下的结构跟普通项目的结构大同小异,但是monorepo下面有一些需要特别注意的点。

a和b分别为monorepo下的一个项目,项目a依赖了lodash和@types/lodash,项目b依赖了项目a以及react。结构的如下:

.

├── node_modules

│   ├──@types

│   │ └── lodash -> ../.pnpm/@types+lodash@4.14.177/node_modules/@types/lodash

     └── .pnpm

├── package.json

├── packages

│   ├── a

│   │   ├── node_modules

│   │   │   ├── @types

│               └── lodash

│   │   │   └── lodash -> ../../../node_modules/.pnpm/lodash@4.17.21/node_modules/lodash

│   │   └── package.json

│   └── b

│       ├── node_modules

│       │   ├── a -> ../../a

│       │   └── react -> ../../../node_modules/.pnpm/react@17.0.2/node_modules/react

│       └── package.json

├── pnpm-lock.yaml

└── pnpm-workspace.yaml

可以看到:

  • 所有依赖都平铺在最外层的node_modules/.pnpm这个目录。
  • 项目b依赖的react,和项目a依赖的lodash和@types/lodash都指向了上面这个目录对应的文件夹。
  • 通过worksapce协议,项目b依赖指定其依赖a为本地项目的a,直接把node_modules/a软链到了项目a的路径。

这里简单说一下worksapce协议,worksapce协议用于在pnpm worksapce下指定依赖为本地文件。在发布的时候,pnpm会自动把使用worksapce协议的依赖的版本改为对应本地项目的版本,这替代了我们在项目里面频繁link的操作。作为对比,yarn的workspace不支持worksapce协议,如果在一个项目中声明的依赖在本地能找到符合的项目,就会使用本地的,如果没有找到,就会使用线上的,这样当然会很容易出错,相比之下workspace我们可以手动控制为线上的依赖还是本地的依赖。

依赖a只是简单的link到了b的node_modules下面,假设a有peerDependency的声明(例如a声明react为其peerDependency),那么在b项目下运行a会有问题,因为a没办法找到react(如果严格设置hoist设置为false的话)。可以设置dependenciesMeta.*.injected为true,那么pnpm会把a当成一个正常的依赖来处理其peerDepency。

相关配置

在上一小节的例子,会发现有个严重的问题,@types/lodash 被提升到了最外层的node_modules目录。这样会造成幽灵依赖问题。(例如项目b没有用到@types/lodash,但是tsc可能并不会报错)

那么这是为什么呢? pnpm不是说会解决幽灵依赖问题吗?

为了彻底搞懂,我们要先搞清为什么pnpm会有这样的行为。先看看pnpm相关的默认配置:

hoist=true 



; All packages are hoisted to node_modules/.pnpm/node_modules

hoist-pattern[]=*



; All types are hoisted to the root in order to make TypeScript happy

public-hoist-pattern[]=*types*



; All ESLint-related packages are hoisted to the root as well

public-hoist-pattern[]=*eslint*



shamefully-hoist=false
  • hoist
    • 默认为true,相当于hoist-pattern[]=*。
    • 所有的依赖会从node_modules/.pnpm链接一份node_modules/.pnpm/node_modules里面。也就是说所有你项目里面依赖的依赖都可以引用到项目里面所有的依赖。
  • hoist-pattern
    • 默认为hoist-pattern[]=*。
    • 只有命中正则的依赖才会从node_modules/.pnpm链接一份node_modules/.pnpm/node_modules里面。
  • shamefully-hoist
    • 默认为false;如果为true,相当于public-hoist-pattern[]=*。
    • 所有依赖都会链接一份到最外层的node_modules目录里面。那么很可能造成幽灵依赖的问题,不建议这么做。
  • public-hoist-pattern 。
    • 默认为 ['types', 'eslint', '@prettier/plugin-*', 'prettier-plugin-']。
    • 命中正则的会提升到最外层的node_modules里面。

使用默认publish-hoist-pattern配置,正则命中了ts类型和eslint相关的包,他们会被提升到最外层的node_modules目录,这会方便ts和eslint的引擎解析相关依赖。所以pnpm为了正常项目安装依赖,保证ts和eslint能够正常使用,所以默认的配置允许这种行为,。但是这种行为在monorepo的场景下不符合预期,可以在.npmrc里面配置shamefully-hoist为false,或者修改public-hoist-pattern配置。参考issuse,pnpm作者准备在下一个大版本关闭这个默认提升的特性(至少在monorepo的场景下)。

如果你使用的是rush,不用考虑这个问题,因为rush项目的根目录里没有node_modules。此外,你也在rush项目中找不到npm和pnpm相关的配置文件(包括package.json, .npmrc, pnpm-lock.yaml, pnpm-workspace.yaml),因为他们全部都被移到了common/temp目录下面。在pnpm worksapce组织下面,你或许会想要在根目录的node_modules下面添加一些公用的依赖(工具包,例如jest),但是理论上,只要你能在根目录的node_modules添加依赖,就会造成幽灵,所以rush很严格,因为你根本没机会添加!oh !该死的 node require机制!

除了上面这个例子,还有我们真实遇到的例子:项目在monorepo下面运行不会报错,但是在编译阶段报错。经过排查是因为tcs-templates声明了lodash-es为peerDependency,但是项目内没有安装lodash-es,本地能运行起来,是因为默认hoist为true把所有依赖都链接了一份到.pnpm/node_moudles目录下面,所以tcs-templates自己的node_modules没找到lodash-es,会去.pnpm/node_modules下面找,因为其他项目安装了lodash-es,所以当然就require到了。而编译时require不到是因为使用了--filter特性,只会安装使用到的依赖。

根本原因有两个:

  1. 没有开启strict-peer-dependencies,没有严格检测缺失或者无效的r依赖。
  2. hoist默认为true,那么会默认依赖有幽灵依赖

这种行为不太好界定,因为有的依赖就是会用到这种特性,例如eslint,babel,又或许我们应该相信包作者不会有幽灵依赖这种失误。总之,解决办法就是把hoist设置为false,然后手动设置hoist-pattern开白名单。

与pnp结合

pnpm支持与pnp结合,通过配置node-linker:

node-linker=pnp

symlink=false

注意这会变得非常严格,任何幽灵依赖将不会运行。

总结

pnpm无论是速度、安全性、兼容性都比npm和yarn有显著的优势,所以如果你的项目还在使用npm、yarn,强烈建议你尝试一下pnpm,你会爱上他的。如果你的项目是monorepo,那我强烈建议你迁移到pnpm,因为monorepo下幽灵依赖会更加突出,这里推荐推荐changeset或者rush作为包发布和管理工具,rush可作为大型仓库多人协作的业务场景使用。

参考文档

pnpm 作者博客

pnpm官网

npm install 原理分析

关于现代包管理器的深度思考——为什么现在我更推荐 pnpm 而不是 npm/yarn?

linux软链接和硬链接

PnP