• home > webfront > ECMAS > npm-node >

    pnpm为什么在npm/cnpm/tnpm/yarn等包管理器中脱颖而出

    Author:zhoulujun Date:

    pnpm 是通过 hardlink 在全局里面搞个 store 目录来存储 node_modules 依赖里面的 hard link 地址,然后在引用依赖的时候则是通过 symlink 去找到对应虚拟磁盘目录下( pnpm 目录)的依赖地址

    着前段时间尤大在 vue3 以及 vite 仓库中切换包管理为 pnpm 的 pr 成功 merge,以及 vue 生态中的一些项目例如 VueUse 也切换使用 pnpm,宣告着 vue 生态中项目仓库完成了从原有的 yarn workspace monorepo 到 pnpm workspace monorepo 的迁移。

    可以看到 vite 核心贡献者以及 vue 团队成员之一的 patak (https://github.com/patak-js) 在 twitter 上对这次项目迁移的生动描述:“项目如同多米诺骨牌一样倒向了 pnpm”。

    pnpm: 最先进的包管理工具

    pnpm,英文里面的意思叫做 performant npm ,意味“高性能的 npm”,官网地址可以参考 https://pnpm.io/

    pnpm 相比较于 yarn/npm 这两个常用的包管理工具在性能上也有了极大的提升,根据目前官方提供的 benchmark 数据可以看出在一些综合场景下比 npm/yarn 快了大概两倍

    preview

    目前 GitHub 已经有 star 9.8k,现在已经相对成熟且稳定了。它由 npm/yarn 衍生而来,但却解决了 npm/yarn 内部潜在的 bug,并且极大了地优化了性能,扩展了使用场景。

    我们也可以回顾一下之前的JavaScript包管理器:《JavaScript 包管理器简史(npm/yarn/pnpm) https://zhuanlan.zhihu.com/p/451025256

    Yarn 的 Plug'n'Play 特性

     Yarn早在 18 年 9 月份就提出PnP 实现了。首先我们可以看一下nodejs的模块加载机制:《再谈Node.js的模块加载方式+机制与运行原理

     Node 在处理依赖引用时的逻辑,这个流程会有如下两种情况:

    1. 如果我们传给 require() 调用的参数是一个核心模块(例如 “fs”、”path”等)或者是一个本地相对路径(例如 ./module-a.js 或 /my-li/module-b.js),那么 Node 会直接使用对应的文件。

    2. 如果不是前面描述的情况,那么 Node 会开始寻找一个名为 node_modules 的目录:

      1. 首先 Node 会在当前目录寻找 node_modules,如果没有则到父目录查找,以此类推直到系统根目录。

      2. 找到 node_modules 目录之后,再在该目录中寻找名为 moduleName.js 的文件或是名为 moduleName 的子目录。


    process1

     Node 在解析依赖时需要进行大量的文件 I/O 操作,效率并不高。

     yarn install 操作会执行以下 4 个步骤:

    1. 将依赖包的版本区间解析为某个具体的版本号

    2. 下载对应版本依赖的 tar 包到本地离线镜像

    3. 将依赖从离线镜像解压到本地缓存

    4. 将依赖从缓存拷贝到当前目录的 node_modules 目录

    其中第 4 步同样涉及大量的文件 I/O,导致安装依赖时效率不高(尤其是在 CI 环境,每次都需要安装全部依赖)。

    Facebook 的工程师受够了这些问题决定寻找一个能彻底解决问题同时还可以与现有生态兼容的解决方案。这便是 Plug’n’Play 特性,简称 PnP。

    PnP 的具体工作原理

    把依赖从缓存拷贝到 node_modules 的替代方案,Yarn 会维护一张静态映射表,该表中包含了以下信息:

    • 当前依赖树中包含了哪些依赖包的哪些版本

    • 这些依赖包是如何互相关联的

    • 这些依赖包在文件系统中的具体位置

    这个映射表在 Yarn 的 PnP 实现中对应项目目录中的 .pnp.js 文件。

    这个 .pnp.js 文件是如何生成,Yarn 又是如何利用它的呢?

    在安装依赖时,在第 3 步完成之后,Yarn 并不会拷贝依赖到 node_modules 目录,而是会在 .pnp.js 中记录下该依赖在缓存中的具体位置。这样就避免了大量的 I/O 操作同时项目目录也不会有 node_modules 目录生成

    同时 .pnp.js 还包含了一个特殊的 resolver,Yarn 会利用这个特殊的 resolver 来处理 require() 请求,该 resolver 会根据 .pnp.js 文件中包含的静态映射表直接确定依赖在文件系统中的具体位置,从而避免了现有实现在处理依赖引用时的 I/O 操作。

    带来了哪些好处

    从 PnP 的实现方案可以看出,同一个系统上不同项目引用的相同依赖的相同版本实际都是指向的缓存中的同一个目录。这带来了几个最直观的好处:

    • 安装依赖的速度得到了空前的提升

    • CI 环境中多个 CI 实例可以共享同一份缓存

    • 同一个系统中的多个项目不再需要占用多份磁盘空间


    pnpm的优势

    pnpm 内部使用基于内容寻址的文件系统来存储磁盘上所有的文件,这个文件系统出色的地方在于:

    • 不会重复安装同一个包。用 npm/yarn 的时候,如果 100 个项目都依赖 lodash,那么 lodash 很可能就被安装了 100 次,磁盘中就有 100 个地方写入了这部分代码。但在使用 pnpm 只会安装一次,磁盘中只有一个地方写入,后面再次使用都会直接使用 hardlink(硬链接,不清楚的同学详见这篇文章)。

    • 即使一个包的不同版本,pnpm 也会极大程度地复用之前版本的代码。举个例子,比如 lodash 有 100 个文件,更新版本之后多了一个文件,那么磁盘当中并不会重新写入 101 个文件,而是保留原来的 100 个文件的 hardlink,仅仅写入那一个新增的文件。

    那么 pnpm 是怎么做到如此大的提升的呢?是因为计算机里面一个叫做 Hard link 的机制,hard link 使得用户可以通过不同的路径引用方式去找到某个文件pnpm 会在全局的 store 目录里存储项目 node_modules 文件的 hard links 。

    hard link 机制

    举个例子,例如项目里面有个 1MB 的依赖 a,在 pnpm 中,看上去这个 a 依赖同时占用了 1MB 的 node_modules 目录以及全局 store 目录 1MB 的空间(加起来是 2MB),但因为 hard link 的机制使得两个目录下相同的 1MB 空间能从两个不同位置进行寻址,因此实际上这个 a 依赖只用占用 1MB 的空间,而不是 2MB。

    Store 目录

    上一节提到 store 目录用于存储依赖的 hard links,这一节简单介绍一下这个 sotre 目录。

    一般 store 目录默认是设置在 ${os.homedir}/.pnpm-store 这个目录下,具体可以参考 @pnpm/store-path 这个 pnpm 子包中的代码:

    const homedir = os.homedir()
    if (await canLinkToSubdir(tempFile, homedir)) {
      await fs.unlink(tempFile)
      // If the project is on the drive on which the OS home directory
      // then the store is placed in the home directory
      return path.join(homedir, relStore, STORE_VERSION)
    }

    当然用户也可以在 .npmrc 设置这个 store 目录位置,不过一般而言 store 目录对于用户来说感知程度是比较小的。

    因为这样一个机制,导致每次安装依赖的时候,如果是个相同的依赖,有好多项目都用到这个依赖,那么这个依赖实际上最优情况(即版本相同)只用安装一次。

    如果是 npm 或 yarn,那么这个依赖在多个项目中使用,在每次安装的时候都会被重新下载一次


    使用 pnpm 对项目安装依赖的时候,如果某个依赖在 sotre 目录中存在了话,那么就会直接从 store 目录里面去 hard-link,避免了二次安装带来的时间消耗,如果依赖在 store 目录里面不存在的话,就会去下载一次


    当然这里你可能也会有问题:

    如果安装了很多很多不同的依赖,那么 store 目录会不会越来越大?

    答案是当然会存在,针对这个问题,pnpm 提供了一个命令来解决这个问题: pnpm store | pnpm。

    同时该命令提供了一个选项,使用方法为 pnpm store prune ,它提供了一种用于删除一些不被全局项目所引用到的 packages 的功能,例如有个包 [email protected] 被一个项目所引用了,但是某次修改使得项目里这个包被更新到了 1.0.1 ,那么 store 里面的 1.0.0 的 axios 就就成了个不被引用的包,执行 pnpm store prune 就可以在 store 里面删掉它了。

    该命令推荐偶尔进行使用,但不要频繁使用,因为可能某天这个不被引用的包又突然被哪个项目引用了,这样就可以不用再去重新下载这个包了。


    node_modules 结构

    在 pnpm 官网有一篇很经典的文章,关于介绍 pnpm 项目的 node_modules 结构: Flat node_modules is not the only way | pnpm

    本质上 pnpm 的 node_modules 结构是个网状 + 平铺的目录结构。这种依赖结构主要基于软连接(即 symlink)的方式来完成。

    pnpm 中一个版本的软件只会有唯一一组依赖

    多数情况,在 pnpm 中一个版本的软件只会有唯一一组依赖,反映到 .pnpm 这个文件夹中就是一个版本的文件夹最多出现一次:https://pnpm.io/how-peers-are-resolved

    pnpm 什么情况下一个版本的依赖会出现两次

    举例而言比如说 x1 依赖于 a1 和 b1,y1 依赖于 a1 和 b1.1, 其中数字是版本号,然后 a1 有一个 peerDeps b^1(1 到 2 的版本号都可以)。

    x1 dep (a1, b1)
    y1 dep (a1, b1.1)
    
    ---
    
    a1 peerDeps b^1

    如果同时要安装 x1 和 y1, 考虑一下如果把 .pnpm 里面的内容完全展平,会得到

    x1
    y1
    a1
    b1
    b1.1

    很明显 x1、y1 各自依赖 b 的两个版本,但是这个时候出现了问题:

    既然 b 也是 a 的依赖(peerDeps),那么导入 a1 的时候应该 resolve 哪一个 b?

    这个时候就出现问题了,显然不能随便挑一个,因为 x1 和 y1 都依赖于 a1,随便挑一个另一边就会出错了。

    所以这种情况下 a1 会在 pnpm 的 .pnpm 里面出现两次,一次链接到 b1,一次链接到 b1.1。

    x1
    y1
    a1+b1
    a1+b1.1
    b1
    b1.1


    symlink 和 hard link 机制

     pnpm 是通过 hardlink 在全局里面搞个 store 目录来存储 node_modules 依赖里面的 hard link 地址,然后在引用依赖的时候则是通过 symlink 去找到对应虚拟磁盘目录下(.pnpm 目录)的依赖地址。

    这两者结合在一起工作之后,假如有一个项目依赖了 [email protected][email protected] ,那么最后的 node_modules 结构呈现出来的依赖结构可能会是这样的:

    node_modules
    └── bar // symlink to .pnpm/[email protected]/node_modules/bar
    └── foo // symlink to .pnpm/[email protected]/node_modules/foo
    └── .pnpm
        ├── [email protected]
        │   └── node_modules
        │       └── bar -> <store>/bar
        │           ├── index.js
        │           └── package.json
        └── [email protected]
            └── node_modules
                └── foo -> <store>/foo
                    ├── index.js
                    └── package.json

    node_modules 中的 bar 和 foo 两个目录会软连接到 .pnpm 这个目录下的真实依赖中,而这些真实依赖则是通过 hard link 存储到全局的 store 目录中。

    兼容问题

    像 hard link 和 symlink 这种方式在所有的系统上都是兼容的吗?

    实际上 hard link 在主流系统上(Unix/Win)使用都是没有问题的,但是 symlink 即软连接的方式可能会在 windows 存在一些兼容的问题,但是针对这个问题,pnpm 也提供了对应的解决方案:

    在 win 系统上使用一个叫做 junctions 的特性来替代软连接,这个方案在 win 上的兼容性要好于 symlink。

    或许你也会好奇为啥 pnpm 要使用 hard links 而不是全都用 symlink 来去实现。

    实际上存在 store 目录里面的依赖也是可以通过软连接去找到的,nodejs 本身有提供一个叫做 --preserve-symlinks 的参数来支持 symlink,但实际上这个参数实际上对于 symlink 的支持并不好导致作者放弃了该方案从而采用 hard links 的方式:

    具体可以参考 https://github.com/nodejs/node-eps/issues/46 该issue 讨论。


    pnpm 利用了 hard link 的原理来设置依赖的存储,通过软连接来兼容现有的 node_modules 结构,虽然会在一些场景下存在兼容问题,但总的来说还是一种很不错的解决思路的。

    cnpm 和 tnpm 的底层大概在一年多前换为 https://github.com/cnpm/npminstall也是受了 pnpm 的启发。

    cnpm

    cnpm 在国内的用户应该还是蛮多的,尤其是对于有搭建私有仓库需求的人来说。cnpm 在安装依赖时使用的是 npminstall,简单来说, cnpm 使用链接 link 的安装方式,最大限度地提高了安装速度,生成的 node_modules 目录采用的是和 npm 不一样的布局。 用 cnpm 装的包都是在 node_modules 文件夹下以 版本号 @包名 命名,然后再做软链接到只以包名命名的文件夹上。同样的例子,使用 cnpm 只安装 redux 依赖时生成的 node_modules 目录结构如下:

     cnpm只安装 redux 依赖时生成的 node_modules 目录结构

    cnpm 和 npm 以及 yarn 之间最大的区别就在于生成的 node_modules 目录结构不同,这在某些场景下可能会引发一些问题。此外也不会生成 lock 文件,这就导致在安装确定性方面会比 npm 和 yarn 稍逊一筹。但是 cnpm 使用的 link 安装方式还是很好的,既节省了磁盘空间,也保持了 node_modules 的目录结构清晰,可以说是在嵌套模式和扁平模式之间找到了一个平衡。

    npm、yarn 和 cnpm 均提供了很好的依赖管理来帮助我们管理项目中使用到的各种依赖以及版本,但是如果依赖出现了循环调用也就是循环依赖应该怎么解决呢?


    pnpm安全

    不知道你发现没有,pnpm 这种依赖管理的方式也很巧妙地规避了非法访问依赖的问题,也就是只要一个包未在 package.json 中声明依赖,那么在项目中是无法访问的。

    但在 npm/yarn 当中是做不到的,那你可能会问了,如果 A 依赖 B, B 依赖 C,那么 A 就算没有声明 C 的依赖,由于有依赖提升的存在,C 被装到了 A 的node_modules里面,那我在 A 里面用 C,跑起来没有问题呀,我上线了之后,也能正常运行啊。不是挺安全的吗?

    还真不是。

    1. 你要知道 B 的版本是可能随时变化的,假如之前依赖的是[email protected],现在发了新版,新版本的 B 依赖 [email protected],那么在项目 A 当中 npm/yarn install 之后,装上的是 2.0.1 版本的 C,而 A 当中用的还是 C 当中旧版的 API,可能就直接报错了。

    2. 如果 B 更新之后,可能不需要 C 了,那么安装依赖的时候,C 都不会装到node_modules里面,A 当中引用 C 的代码直接报错。

    3. 还有一种情况,在 monorepo 项目中,如果 A 依赖 X,B 依赖 X,还有一个 C,它不依赖 X,但它代码里面用到了 X。由于依赖提升的存在,npm/yarn 会把 X 放到根目录的 node_modules 中,这样 C 在本地是能够跑起来的,因为根据 node 的包加载机制,它能够加载到 monorepo 项目根目录下的 node_modules 中的 X。但试想一下,一旦 C 单独发包出去,用户单独安装 C,那么就找不到 X 了,执行到引用 X 的代码时就直接报错了。

    这些,都是依赖提升潜在的 bug。如果是自己的业务代码还好,试想一下如果是给很多开发者用的工具包,那危害就非常严重了。

    npm 也有想过去解决这个问题,指定--global-style参数即可禁止变量提升,但这样做相当于回到了当年嵌套依赖的时代,一夜回到解放前,前面提到的嵌套依赖的缺点仍然暴露无遗。

    npm/yarn 本身去解决依赖提升的问题貌似很难完成,不过社区针对这个问题也已经有特定的解决方案: dependency-check,地址: https://github.com/dependency-check-team/dependency-check

    但不可否认的是,pnpm 做的更加彻底,独创的一套依赖管理方式不仅解决了依赖提升的安全问题,还大大优化了时间和空间上的性能


    从NPM/YARN迁移到PNPM

    1. 安装PNPM为全局包

      1. npm i -g pnpm

    2. 项目中移除NPM依赖项安装库

      1. rm -rf node_modules package-lock.json

      2. ……或移除YARN依赖项安装库

      3. rm -rf node_modules yarn.lock

    3. 使用PNPM安装项目依赖项

      1. pnpm i

    4. 可选)安装多个项目后,清理冗余项

      1. pnpm prune



    参考文章:

    One For All:基于pnpm + lerna + typescript的最佳项目实践 - 理论篇 https://zhuanlan.zhihu.com/p/448058464

    探索 JavaScript 中的依赖管理及循环依赖 https://zhuanlan.zhihu.com/p/33049803

    Pnpm: 最先进的包管理工具 https://zhuanlan.zhihu.com/p/404784010

    为什么 vue 源码以及生态仓库要迁移 pnpm? https://zhuanlan.zhihu.com/p/441547677

    Pnpm: 最先进的包管理工具 https://mp.weixin.qq.com/s/5Zo576QFpdAfwXmhfTwWZQ

    Yarn 的 Plug'n'Play 特性 https://loveky.github.io/2019/02/11/yarn-pnp/

    关于现代包管理器的深度思考——为什么现在我更推荐 pnpm 而不是 npm/yarn? https://zhuanlan.zhihu.com/p/377593512

     JavaScript 包管理器简史(npm/yarn/pnpm) https://zhuanlan.zhihu.com/p/451025256

    3行命令,从NPM/YARN迁移到PNPM https://zhuanlan.zhihu.com/p/462680718

    pnpm 什么情况下一个版本的依赖会出现两次 https://zhuanlan.zhihu.com/p/370243042

    都2022年了,pnpm快到碗里来! https://zhuanlan.zhihu.com/p/457698236

    pnpm 源码结构及调试指南 https://zhuanlan.zhihu.com/p/481948235






    转载本站文章《pnpm为什么在npm/cnpm/tnpm/yarn等包管理器中脱颖而出》,
    请注明出处:https://www.zhoulujun.cn/html/webfront/ECMAScript/nodejs/8782.html