pnpm 作为当前比较流行的包管理器之一,主要特点是速度快、节省磁盘空间,本文将介绍 pnpm 的底层实现,帮助你理解 pnpm 的原理

# pnpm 简介

pnpm 的含义是 performant npm,意味着高性能 npm,从官网中提供的 benchmarks 也可以看出在 intallupdate 等场景时对于 npmyarnyarn_pnp 有不错的性能优势:

# node_modules 的目录结构

# 嵌套结构

npm@2 的早期版本中,对应 Node.js 4.x 及以前的版本,node_modules 在安装时是嵌套结构

一个简单的例子 (opens new window)demo-foodemo-baz 中均依赖 demo-bar,在同时安装 demo-foodemo-baz 时会生成如下的 node_modules 结构:

node_modules
└─ demo-foo
   ├─ index.js
   ├─ package.json
   └─ node_modules
      └─ demo-bar
         ├─ index.js
         └─ package.json
└─ demo-baz
   ├─ index.js
   ├─ package.json
   └─ node_modules
      └─ demo-bar
         ├─ index.js
         └─ package.json

这个时候的目录结构虽然比较清晰,但是每个依赖包都会有自己的 node_modules,相同的依赖并没有复用,例如上面的相同依赖 demo-bar 就被安装了两次

另外一个问题是 windows最长路径限制 (opens new window),在复杂项目场景依赖层级较深时,依赖的路径往往会超出长度限制

# 扁平结构

为了解决上述问题,yarn 提出了扁平结构的设计,将所有的依赖在 node_modules 中平铺,后来的 npm v3版本的实现也与之类似,因此使用 yarn 或者 npm@3+ 安装上述的例子,将会得到如下扁平式的目录结构:

node_modules
└─ demo-bar
   ├─ index.js
   └─ package.json
└─ demo-baz
   ├─ index.js
   └─ package.json
└─ demo-foo
   ├─ index.js
   └─ package.json

另外这种方式对于相同依赖的不同版本,则只会将其中一个进行提升,剩余的版本则还是嵌套在对应的包中,例如我们上面的 demo-foo 中对于 demo-bar 的依赖升级到 v1.0.1 版本,则会得到下面的结构,具体哪个版本会提升到最顶层则取决于安装时的顺序(示例 (opens new window)):

node_modules
└─ demo-bar
   ├─ index.js
   └─ package.json
└─ demo-baz
   ├─ index.js
   ├─ package.json
   └─ node_modules
      └─ demo-bar
         ├─ index.js
         └─ package.json
└─ demo-foo
   ├─ index.js
   ├─ package.json
   └─ node_modules
      └─ demo-bar
         ├─ index.js
         └─ package.json

# 扁平结构存在的问题

扁平化的方案并不完美,反而引入了一些新的问题:

# 幽灵依赖

幽灵依赖(Phantom dependencies)指的是没有显示声明在 package.json 中的依赖,却可以直接引用到对应的包,这个问题是由扁平化的结构产生的,会将依赖的依赖也至于 node_modules 的顶层,也就可以在项目中直接引用到。当某一天这个子依赖不再是引用包的依赖时,项目中的引用则会出现问题

# 分身问题

NPM 分身(NPM doppelgangers)则指的是对于相同依赖的不同版本,由于 hoist 的机制,只会提升一个,其他版本则可能会被重复安装,还是上面的例子 (opens new window),当依赖的 demo-bar 的依赖升级到 v1.0.1 时,作为 demo-foodemo-baz 依赖的 v1.0.0 版本则以嵌套的形式被重复安装:

node_modules
└─ demo-bar // v1.0.1
   ├─ index.js
   └─ package.json
└─ demo-baz
   ├─ index.js
   ├─ package.json
   └─ node_modules
      └─ demo-bar // v1.0.0
         ├─ index.js
         └─ package.json
└─ demo-foo
   ├─ index.js
   ├─ package.json
   └─ node_modules
      └─ demo-bar // v1.0.0
         ├─ index.js
         └─ package.json

# pnpm 解题思路

pnpm 首先将依赖安装到全局 store,然后通过 symbolic linkhard link 来组织目录结构,将全局的依赖链接到项目中,将项目的直接依赖链接到 node_modules 的顶层,所有的依赖则平铺于 node_modules/.pnpm 目录下,实现了所有项目的依赖共享 store 的全局依赖,解决了幽灵依赖和 NPM 分身的问题

链接是操作系统中文件共享的方式,其中 symbolic link 是符号链接,也称软链接,hard link 是硬链接,从在使用的角度看,二者没有什么区别,都支持读写,如果是可执行文件也可以直接执行,主要区别在于底层原理不太一样:

  • 硬链接不会新建 inode(索引节点),源文件与硬链接指向同一个索引节点
  • 硬链接不支持目录,只支持文件级别,也不支持跨分区
  • 删除源文件和所有硬链接之后,文件才真正被删除
  • 符号链接中存储的是源文件的路径,指向源文件,类似于 Windows 的快捷方式
  • 符号链接支持目录与文件,它与源文件是不同的文件,inode 值不一样,文件类型也不同,因此符号链接可以跨分区访问
  • 删除源文件后,符号链接依然存在,但是无法通过它访问到源文件

# 如何创建链接

# symbolic ink
ln -s myfile mysymlink
# hard link
ln myfile myhardlink

# pnpm 实现

在 pnpm 中,会将依赖安装到当前分区的 <home dir>/.pnpm-store 位置中,可以通过以下命令获得当前的 store 位置:

pnpm store path

然后利用 hard link 将所需的包从 node_modules/.pnpm 硬链接到 store 中,最后通过 symbolic linknode_modules 中的顶层依赖以及依赖的依赖符号链接到 node_modules/.pnpm 中,一个依赖 demo-foo@1.0.1demo-baz@1.0.0例子 (opens new window)node_modules 结构如下:

node_modules
└─ .pnpm
   └─ demo-bar@1.0.0
      └─ node_modules
         └─ demo-bar -> <store>/demo-bar
   └─ demo-bar@1.0.1
      └─ node_modules
         └─ demo-bar -> <store>/demo-bar
   └─ demo-baz@1.0.0
      └─ node_modules
         ├─ demo-bar -> ../../demo-bar@1.0.0/node_modules/demo-bar
         └─ demo-baz -> <store>/demo-baz
   └─ demo-foo@1.0.1
      └─ node_modules
         ├─ demo-bar -> ../../demo-bar@1.0.1/node_modules/demo-bar
         └─ demo-foo -> <store>/demo-foo
└─ demo-baz -> ./pnpm/demo-baz@1.0.0/node_modules/demo-baz
└─ demo-foo -> ./pnpm/demo-baz@1.0.1/node_modules/demo-foo

这里引用了官网的截图帮助你更好地理解 symbolic inkhard link 在项目结构中是如何组织的:

pnpm 对于链接的实际应用,以下是相关源码 (opens new window)

function createImportPackage (packageImportMethod?: 'auto' | 'hardlink' | 'copy' | 'clone' | 'clone-or-copy') {
  // this works in the following way:
  // - hardlink: hardlink the packages, no fallback
  // - clone: clone the packages, no fallback
  // - auto: try to clone or hardlink the packages, if it fails, fallback to copy
  // - copy: copy the packages, do not try to link them first
  switch (packageImportMethod ?? 'auto') {
  case 'clone':
    packageImportMethodLogger.debug({ method: 'clone' })
    return clonePkg
  case 'hardlink':
    packageImportMethodLogger.debug({ method: 'hardlink' })
    return hardlinkPkg.bind(null, linkOrCopy)
  case 'auto': {
    return createAutoImporter()
  }
  case 'clone-or-copy':
    return createCloneOrCopyImporter()
  case 'copy':
    packageImportMethodLogger.debug({ method: 'copy' })
    return copyPkg
  default:
    throw new Error(`Unknown package import method ${packageImportMethod as string}`)
  }
}

# 其他能力

pnpm 目前可以脱离 Node.jsruntime 去安装使用,还可以通过 pnpm env 来对 Node.js 版本进行管理,类似 nvm,与 npm/yarn 完整的功能比较详见:feature-comparison (opens new window)

# pnpm 的局限性

  1. 由于 symbolic link 在一些场景下有兼容性问题,目前 Eletron 以及 labmda 部署的应用上无法使用 pnpm,详见:discussion (opens new window)

可以通过在 .npmrcnode-linker=hoisted 可以创建一个没有符号链接的扁平的 node_modules,此时 pnpm 创建的目录结构将与 npm/yarn 类似

  1. 由于全局共用同一份 store,因此当需要修改 node_modules 内的内容时,会直接影响全局 store 中对应的内容,对其他项目也会造成影响

关于这个问题,其实最推荐的方式是 clone(copy-on-write (opens new window)),使用写入时复制,默认多个引用指向同一个文件,只有当用户需要修改的时候才进行复制,这样就不会影响其他引用对于源文件内容的读取

但是并不是所有的操作系统都支持,pnpm 默认会尝试使用 clone,如果不支持,则会退回至使用 hard link,你也可以通过在 npmrc 中指定 package-import-method (opens new window) 来手动设置包的引用方式

# 其他工具

  • bun: https://github.com/oven-sh/bun

    • 采用 Zig 写的一个 JS runtimebun 也提供了包管理工具,但是 bun 会有一些兼容性问题
  • Volt:https://github.com/dimensionhq/volt

    • 利用 Rust 写的 Node.js 包管理器,速度极快,目前仍在 beta 阶段
  • tnpm: https://github.com/cnpm/npminstall

# 未来展望

pnpm 很快,但是并不是所有的 pnpm 命令都很快,例如 pnpm run 比较慢,未来可能会使用 Rust 来写一些子命令的 cli wrapper,参见这个 discussion (opens new window)

# 参考