什么是pnpm?

pnpm 是新式的包管理器,和npm一样,可以很方便的管理项目中的第三方库。

为什么需要pnpm?

有了 npm 或 yarn, 为什么还要使用 pnpm 呢?

因为 pnpm 解决了两个问题:

幽灵依赖

  • 幽灵依赖的表象是你的项目可以直接使用 package.json 中并未声明的依赖包.
  • 原因是这些包被你的直接依赖所依赖, 并被扁平化地安装在了你的项目 node_modules 根目录下. 一旦直接依赖在未来版本中不在需要这个包, 项目就会直接奔溃
  • npm 和 yarn 为了减少重复和嵌套过深, 引入了扁平化算法, 让幽灵依赖变得更容易出现, 更严重也更明显

重复安装

同一个包(即使版本完全相同)在不同的项目中会被重复下载和存储,占用大量磁盘空间.

甚至在同一个项目中, 如果多个依赖都依赖了某个包的不同版本, 这些版本也会被重复安装多次

什么是扁平化算法?

是从 v3 开始引入的一种安装依赖策略, 尽量将所有依赖包安装在项目的顶层 node_modules 目录中, 而不是嵌套安装在各自依赖包的 node_modules

如果多个包依赖同一个版本的库, 就只在顶层安装一份; 如果依赖冲突, 再回退到嵌套结构

pnpm的优势

pnpm 是通过内容寻址存储 + 软连接的方法彻底解决上述的两个问题

内容寻址存储是一种通过内容本身生成的哈希值来命名和访问数据的存储方式

  • 硬链接是内容寻址存储的关键实现手段
  • 内容寻址存储是根据文件内容决定存储位置和标识符
  • 它的特点是相同的内容会生成相同的 hash, 也就有相同的存储位置, 这是节省磁盘空间的关键
  • 也就是说系统不是通过 “文件名” 或 “文件路径” 来识别文件, 而是根据 “文件内容” 生成的哈希值来识别和存储

pnpm 使用了该机制, 它会将依赖包下载到一个全局内容寻址存储(一般在 .pnpm-store 目录)中, 并以内容哈希作为唯一标识. 然后通过软连接将这些包连接到项目的 node_modules

通过内容寻址存储, 大大的减少了项目依赖所占用的磁盘空间; 而且因为依赖包大部分都已经在全局存储中, 所有 pnpm install 的速度极快

什么是软连接?

我们需要先了解硬链接、软连接和传统的复制粘贴有什么不同

硬连接

硬链接不是复制文件, 而是 “文件别名”;多个路径指向磁盘上的同一块数据

硬链接在操作系统层面上和复制的文件是平等没有区别的, 它们拥有相同的文件内容, 文件权限, 元数据等信息

因为操作系统无论读取的是硬链接还是复制文件, 最终访问的都是磁盘上完全相同的数据块, 所以执行结果没有任何区别

然而与复制文件相比, 硬链接不占额外空间, 因为只是多条路径指向该数据, 所以修改内容也会同步更新

删除一个链接(路径)不会影响其他链接, 直到所有链接都删除才真正删除数据

硬链接的适用情况

node 环境、开发环境或 vite 打包工具, 都会向对的普通文件一样对待硬连接

所以无论是在开发环境还是在服务器环境, 使用硬链接都不会报错

打包工具会从入口文件开始, 分析我的源代码和依赖的源代码, 然后通过 babel 转译和压缩代码, 最终打包成一个或多个全新的文件, 此时以不在有硬链接和复制文件的区别, 因为最终打包出来的都是全新的文件

pnpm 兼容 npm

代码上传到 git 后, 他人如果拉取了改代码, 都需要执行下载依赖的操作

  • 使用pnpm:执行 pnpm install 后, pnpm 会根据 package.json 和 pnpm-lock.yaml 文件来计算依赖, 并通过硬链接 + 符号链接的方式, 在本地创建出 node_modules

  • 使用npm:如果执行的是 npm install,npm 不识别 pnpm-lock.yaml 文件, 因此它会忽略该文件, 仅根据 package.json 下载依赖, 并生成自己的锁文件 package-lock.json

所以pnpm是向下兼容npm的,两者并不冲突, 但此时依赖结构可能与使用 pnpm 安装的不同

所以协同开发的项目任然需要统一包管理器

软连接

硬链接指向的是文件内容, 而软连接可以指向文件或目录

它的体积很小, 只存储路径字符串, 可以理解为 Windows 系统中的快捷方式

当原文件被删除后, 软连接会变成死链接

通过软连接, pnpm 实现了使用扁平化算法管理 node_modules 的同时, 将每个包的依赖进行隔离, 避免了因扁平化算法带来的依赖冲突和幽灵依赖问题

Monorepo

传统开发中, 可能会把公共函数或设计组件(以下统称公共库)分散到不同的项目中, 这会带来以下痛点:

  • 如果采用直接复制的方式, 当公共库发生变化后, 需要再次手动复制
  • 如果采用发布到 npm 的方式, 当公共库发生变化后, 需要通知所有用到的项目更新

现在, 我们可以利用 pnpm 的软连接机制, 非常方便地在本地实现项目之间的依赖引用. 而不需要手动复制代码或发布到 npm

  • 具体来说, pnpm 会在项目的 node_modules 中为本地依赖包创建软连接, 这些链接指向的是工作区内其他包的真实路径
  • 这种机制让各个包之间可以通过相对路径进行引用, 像使用外部依赖一样使用本地包
  • 而这种依赖机制的实现, 正是基于 pnpm 的工作区机制. 它通过 pnpm-workspace.yaml 文件定义了哪些包属于同一个工作区, 从而自动识别项目间的本地依赖关系并建立软连接
新建一个 monorepo 项目

初始化项目 pnpm init, 然后新建 apps(存放子项目) 和 packages(存放公共库) 目录

修改根目录的 package.json 文件, 添加配置项: "private":true, 告诉 npm 不能发布到公共库里

添加 pnpm-workspace.yaml 文件, 告诉 pnpm 该项目采用 monorepo 模式, 并添加工作区

packages:
  - 'packages/*'
  - 'apps/*'

添加 .gitignore 文件, 告诉 git 哪些目录下的文件不需要同步

在子项目的 package.json 文件中, 像按照第三方库一样在 dependencies 或者 devDependencies 中引用本地项目

本地项目推荐命名方式 "name": "@my-workspace/shared-lib"

使用 @ 符号开头, 表示这个是作用域包. 作用域包是指这个包由谁谁谁发布, 相当于在包的名称中加入了作者的名字. 可以有效的避免和其他公共包名称冲突

而如果使用到本地的项目, 则不仅 name 要保持一致, 还必须使用 workspace 明确告诉 pnpm 这是工作空间内部的一个包

  ```
   "dependencies": {
      "@my-workspace/shared-lib": "workspace:*"
    },
  ```

此时就能够在项目中正常的导入使用本地项目了: import {} from "@my-workspace/shared-lib"

注意

当子项目添加完成后, 必须要在更目录下执行 pnpm install

在 monorepo 中, pnpm install 是一个需要在根目录执行的 “全局操作”

一个推荐的 monorepo 项目目录

monorepo-demo/
├── pnpm-workspace.yaml
├── package.json     ✅ 必须存在,并设置 "private": true
├── packages/
│   └── shared-lib/
│       ├── package.json ✅ name: "@wxm/shared-lib"
│       └── index.ts
├── apps/
│   └── app-vue/
│       ├── package.json ✅ dependencies: { "@wxm/shared-lib": "workspace:*" }
│       └── ...

启动项目

可以去到子项目的路径里通过 pnpm dev 启动

也可以在根目录启动: pnpm --filter hello_vue3 run dev, 该命令使用了过滤器, 值针对 hello_vue3 这个包执行对应脚本, 省去了切目录的麻烦

更进一步, 在根目录的 package.json 里, 配置快捷启动方式

"scripts": {
  "dev:hello_vue3": "pnpm --filter hello_vue3 run dev"
}

更多推荐