从pnpm到npm的核心管理机制

1,376 阅读11分钟

背景

今年在github前端领域star上升速度比较主要有以下几个:

  • Svelte, 一个将MVVM架构放到构建应用程序的编译阶段实现的框架,让你开发更少的代码实现更多的功能
  • Typescript, 几乎所有的前端框架不约而同的支持了Typescript,一个JavaScript的超集,支持变量类型声明
  • pnpm,一个现代化的npm包管理工具,采用link方式去全局管理包,这是本文介绍的重点。

因此,选一个和我们项目中开发相关的作为一个知识扩展点,应该就是pnpm了。

其实从事前端这么多年,自从npm包出现以后,前端工程化就一路顺畅,但是随之也带来很多问题,包括但不限于:

  • 版本管理难
  • 安装速度慢
  • 多个项目安装依赖占用空间大
  • ...

那么其实我们应该了解完整的npm包管理生态,然后才可以得知为什么pnpm打击了哪些痛点,以及如何应用到我们的实际项目中。

npm

之前已经在《从npm版本依赖到Monorepo大仓项目》已经介绍过npm是什么,定义如下:

npm,Node Package Manager的缩写,也就是“Node的包管理器”。 npm(“Node 包管理器”)是 JavaScript 运行时 Node.js 的默认程序包管理器。

也了解它的版本是如何管理的,但是我们还缺乏对它的核心功能需要了解,具体了解以下几个方面:

  • npm install xxx, npm如何将远程的npm包下载到我们的本地node_modules
  • npm ls, npm如何如何维护npm包在本地的关系的
  • npm run xxx,npm如何执行我们的脚本命令

首先,我们需要知道npm由三部分组成:

  • npm包管理网站
  • npm-cli,命令行工具
  • npm包注册中心,npm registry

接下来就是注册账号,以及包的发布,其中版本管理这块,是遵循APR版本规范(major.minor.patch版本模型规范),具体可看这里npm版本管理

npm包管理

目前npm包下载方式分为两种,一种是全局,一种是当前项目,其都会放在node_modules目录下。下面通过以下几方面去讲述npm是如何管理npm包的:

  • 循环依赖
  • 版本冲突
  • 同个版本包收敛

我们以这个下面这个包管理树为例:

foo
+-- blerg@1.2.5
+-- bar@1.2.3
|   +-- blerg@1.x (latest=1.3.7)
|   +-- baz@2.x
|   |   `-- quux@3.x
|   |       `-- bar@1.2.3 (cycle)
|   `-- asdf@*
`-- baz@1.2.3
    `-- quux@3.x
        `-- bar

我们从上面可以看出:

  • blerg 有两个版本分别是 1.2.51.x ,这是同个次版本的包放在不同地方
  • bar 版本是1.2.3,但是依赖 blergbaz,但是依赖baz包的版本是2.x,这是版本冲突
  • baz 版本是1.2.3,,但是所依赖的包中有个依赖bar 这就是循环依赖

那么npm是如何解决这类问题的呢?npm会将上述包管理树优化成如下所示:

foo
+-- node_modules
    +-- blerg (1.2.5) <---[A]
    +-- bar (1.2.3) <---[B]
    |   +-- node_modules
    |       +-- baz (2.0.2) <---[C]
    +-- asdf (2.3.4)
    +-- baz (1.2.3) <---[D]
    +-- quux (3.2.0) <---[E]

那么npm的遵循策略是什么呢?其实还是APR版本规范(major.minor.patch版本模型规范)。

  • 第一,会将依赖树打平,从树结构变成一级结构,解决了循环依赖问题
  • 第二,会将主项目依赖的npm包提到一级,然后对于相同次版本minor中的包统一版本,这就是同个版本包收敛,节省空间
  • 第三,接着将同一个包,但是不同版本的包一次放到对应依赖包内,形成二级依赖(node_modules),这就解决了版本冲突

require加载顺序

  • 加载核心模块,如:fs、path等
  • 加上对应文件后缀,优先级为:test.js > test.json > test.node
  • 搜索路径,如果有指定路径则按照路径去找,如:require('./test') 则在当前目录寻找
  • 如果没有指定路径,则从当前目录下往上去找 node_modules文件夹,然后从文件夹里去遍历寻找对应模块名,如果找不到则到上一层node_modules去找,直到最顶层目录
  • 首次会加载比较慢,后面node.js 会将缓存相关信息到内存避免二次查询

npm install执行过程

image.png

npm run执行过程

npm run serve为例,执行过程如下:

image.png

痛点问题

npm的管理包已经能解决大部分开发问题了,那么为什么还会有出现yarnpnpm等新型管理工具,主要npm包存在目前难以解决的痛点问题:

  • yarn新增yarn.lock,可以解决npm包版本变动问题,目前已被npm引入特性,生成package-lock.json
  • npm包多个项目依赖包一致,但每个项目都需要重新安装,不仅耗时,且占用磁盘空间,这是pnpm解决了的问题
  • 包经常创建太深的依赖树,这导致 Windows 上的目录路径过长,这是pnpm解决的问题
  • 平铺的结构不是 node_modules 的唯一实现方式,pnpm作者描述:目前node_modules大部分为了解决重复依赖包的问题,把npm依赖树进行打平,这样子会产生一些问题:
    • 项目可以访问一些不依赖的npm包
    • 打平依赖树的算法非常复杂,导致安装时更加慢
    • node_modules目录结构十分复杂

这些痛点问题都不是npm和yarn能解决的,因此才会pnpm的出现,接下来我们再了解一下pnpm。

pnpm

官网是这么定义的:

快速的,节省磁盘空间的npm包管理工具

同时还支持以下这些特性:

  • 快速: 比其他包管理器快 2 倍
  • 高效:node_modules 中的文件为复制或链接自特定的内容寻址存储库
  • 支持 monorepos: 内置支持单仓多包
  • 严格:默认创建了一个非平铺的 node_modules,因此代码无法访问任意包

这里安装教程就忽略了,推荐的用法是直接使用npm安装全局命令:

npm install -g pnpm

其他安装方式可以参考官网教程:pnpm 安装

实现原理

从作者博客或者github源码,我们可以尝试去推测实现pnpm几个特性所需要关键原理。

更快速的install

pnpm为了加快npm install,从以下两个方面进行优化:

  • 创建一个.pnpm store,除了第一次安装需要按照npm包,后续再次安装,将会创建一个符号链接到npm包
  • 不打平npm 依赖树,节省复杂依赖树打平时间

可以参考官网的架构图,去更好了解pnpm针对node_moduels的管理,如下:

image.png

这里有几个设计点需要弄清楚:

  1. pnpm将所有依赖树的目录,软链接到.pnpm store中对应的npm包
  2. node_moduels将不会有package.json依赖(devDependenciesdependencies)外的npm包
  3. node_moduels的目录结构,和npm依赖树保持一致

通过上述几个点,pnpm将拥有比yarnnpm更快的install速度。

额外知识点

  1. Linux文件链接分为软链接和硬链接,两者有什么区别?
  • Linux中的符号链接,就是我们平时说的软连接,可以针对文件、目录创建,但是源文件删除后链接不可用,命令:ln -s xxx xxx
  • Linux中的硬链接,只能针对文件,但是文件删除仍可使用,命令:ln xxx xxx
  1. Windows如何解决符号链接问题?

pnpm采用junctions,具体如下:

  • Windows的文件系统是基于NTFS架构,本身就支持hard link 、junctions和symbolic links
  • Hard Link和Linux中硬链接没什么区别,同样只支持文件类型创建链接,采用函数: CreateHardLinkA DeleteFileA
  • Junctions是符号链接,也叫软链接,支持文件、目录创建链接,实现:junction 命令行

更安全的install

我们从上文可以得在Node.js中,require()是如何查询对应npm包,是从node_modules一层层向上查找获取的。

由于pnpm使用非扁平化的node_modules管理依赖树,因此对于非package.json依赖的npm包,是不会放在项目的node_modules中,从而使得开发者无法获取无依赖的npm包。具体可以参考下图:

image.png

使用可能会遇到的问题

pnpm官网罗列了一些在使用过程可能会遇到的问题,具体有以下几个方面:

peer dependencies 如何解决

npm workplace

首先我们需要先了解一下npm workplace 或者 yarn workplace,工作空间的概念。

Workspaces 是一个通用术语,指的是 npm cli 中的一组功能,这些功能支持从单个顶级根包中管理本地文件系统中的多个包。 这组功能弥补了从本地文件系统处理链接包的更加简化的工作流程。自动链接过程作为 npm install 的一部分,避免手动使用 npm link 来添加对应该符号链接到当前 node_modules 文件夹中的包的引用。

简单的说,npm workplace主要用来解决以下几个问题:

  • 在同一个git仓库中管理多个npm包
  • 同时npm包之间可以存在互相依赖
  • 多个子项目会依赖一些公共的npm包
  • 减少之前因为npm link管理的混乱

peerDependencies

通过上面npm workplace概念了解,当工作区里有多个子项目需要依赖一些公共的npm包,但是这些公共npm包版本可能大家所需要的都不一样,这个时候就需要peerDependencies去描述依赖和版本。它是这么定义的:

在开发插件时,你的插件需要某些依赖的支持,但是你又没必要去安装,因为插件的宿主回去安装这些依赖。此时就可以用 peerDependencies 去声明一下需要依赖的插件和版本。如果出问题的话,npm 会有警告来提示使用者去解决版本中的冲突。

因此,peerDependencies的目的很清晰,就为了解决workplace下不同子项目中公共依赖包版本问题。

所以回到pnpm中,由于pnpm是认定项目中只有一组npm依赖树,那么针对workplace中的 peerDependencies,它是如何解决的呢?

pnpm的解决方案是:为不同的peerDependencies 依赖项创建不同的解析

如何理解这句话呢?参考官网的解释:

当有两个子项目,他们的依赖分别如下:

- 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

foo-parent-2foo-parent-1公共依赖了baz,但是要求版本不一样,如果这个时候项目中没有声明peerDependencies.pnpm store中保存的依赖树如下:

node_modules
└── .pnpm
    ├── foo@1.0.0
    ├── bar@1.0.0
    ├── baz@1.0.0
    ├── baz@1.1.0

如果有声明peerDependenciespnpm会根据不同的peerDependencies创建对应的链接,.pnpm store中保存的依赖树如下:

node_modules
└── .pnpm
    ├── foo@1.0.0_bar@1.0.0+baz@1.0.0 #不同声明peerDependencies所创建的链接目录
    │   └── node_modules
    │       ├── foo
    │       ├── bar   -> ../../bar@1.0.0/node_modules/bar
    │       ├── baz   -> ../../baz@1.0.0/node_modules/baz
    ├── foo@1.0.0_bar@1.0.0+baz@1.1.0   #不同声明peerDependencies所创建的链接目录
    │   └── node_modules
    │       ├── foo
    │       ├── bar   -> ../../bar@1.0.0/node_modules/bar
    │       ├── baz   -> ../../baz@1.1.0/node_modules/baz
    ├── baz@1.0.0
    ├── baz@1.1.0

通过创建不同的peerDependencies的链接,去解决不同子项目中的依赖包版本问题。

hosited配置

pnpm从2022年初支持hoisted配置,可以在 .npmrc 文件中使用 node-linker=hoisted 设置。

新增该设定,是为了基本上使 pnpm 可以兼容所有与 npm CLI 兼容的 Node.js 技术栈。

删除npm包

这个是个人的疑问,既然将所有的依赖包统一归置到.pnpm store管理,那么针对一些过期或者无用的npm包什么时候进行删除呢?

pnpm的给出的方案如下:

  • 设置npm包的有效期,可以在.npmrc配置中设置modules-cache-max-age配置项,单位为:分钟,默认值:10080,7天
  • pnpm prune, 从存储中删除未引用的包

其他问题

官网同时也提出未解决的问题:

  • 针对package-lock.json等锁文件更新,npm允许每次安装同样版本的npm包可能会更新lock文件,从而更新node_modules文件内容,但是pnpm由于创建隔离布局,无法实时按照package-lock.json的更新而更新,因此pnpm推荐将使用pnpm import命令将package-lock.json等文件转为pnpm-lock.yaml

这个问题就是pnpm没法按照项目现有的package-lock.json去做自动更新,只能通过pnpm import去做转换。

总结

pnpm确实给前端开发带来一些提醒,让我们关注到平时可能已经习惯的问题,比如npm包管理混乱导致的问题,虽然存在但是我们已经忽略或者已经习惯了。

总结一下学习pnpm后,在实际项目中引用会给我们带来什么:

  • 提高我们本地开发效率,毕竟多个前端项目同时在电脑,每次安装或更新都需要等待一段时间
  • 提高项目的编译速度,利用.pnpm store在编译机上,不用每次都重新安装
  • 提高项目npm依赖的安全性,清晰明白项目中npm依赖结构,不用担心非法npm包在项目中被开发使用

这里联想扩展几个问题点:

  • 什么时候node.js才能支持es6的import和export
  • node.js或者浏览器,什么时候才能直接加载远程js包,而不是需要安装到本地才能使用

参考资料