理解pnpm的本质,如何通过高效管理提升项目效率

pnpm(Performant NPM),前端开发快速更新迭代,选择合适的包管理工具对于提高工作效率至关重要。pnpm不仅继承了npm和yarn的优点,还在性能、磁盘空间使用等方面进行了显著优化。

学习新知识同样遵循"WHAT---HOW---WHY"的黄金圈思维模式进行学习,逐层深入,才能牢牢掌握新知识。

文章大致内容如下:

  • pnpm是什么?
  • pnpm的工作原理?
  • 为什么这么设计,这么设计的优势在哪里?
  • 补充知识
  • 注意事项

pnpm是什么?

pnpm是一个基于Node.js环境下的包管理器,设计理念来源于npm和yarn,但采用了不同的存储机制来解决传统包管理工具存在的问题。通过硬链接的方式,pnpm可以避免重复安装相同的依赖项,从而大大节省了磁盘空间,并且提高了安装速度。

安装和使用

全局安装pnpm

shell 复制代码
npm install -g pnpm

为什么要全局安装?是因为pnpm这个包提供了一个命令,之后安装包可以通过pnpm这个命令来安装,局部安装的话,每次一个新的工程都要安装一遍,费劲

安装后,使用 pnpm -v 来查看是否安装成功

shell 复制代码
pnpm -v

在使用的时候,把平时的npm换成pnpm即可

安装项目中所需的包

shell 复制代码
pnpm i mocha

这里是安装了mocha工具包, downloaded下载了77,总共需要77,可见是首次安装,需要从远程下载下来

那么我们从工程中删除node_modules文件夹,删除完之后,再执行pnpm i mocha看看变化

可以看到downloaded已经没有下载了,而是reused重复使用缓存中的77个

而且项目中的node_modules文件夹中的内容也特别简洁

上篇文章里面提到npmyarn安装包的时候是扁平化处理,即mocha和mocha的依赖包都会显示到node_modules文件夹中,但是pnpm并没有这么做

这么做的好处在于之后项目中文件使用的时候,只能使用mocha,不能使用mocha安装时安装的依赖项的内容,

比方说mocha安装时依赖了diff这个包,但是文件中不能使用diff这个包的内容

js 复制代码
require('mocha')

项目中文件引入 mocha 的时候并不会报错

js 复制代码
require('diff')

但是引入diff的时候就会报错,找不到diff模块

但是如果使用npm或者yarn来安装项目依赖的时候呢,虽然package.json中的依赖项还是显示的只有mocha,但是在项目文件中却能够使用 diff 这个间接依赖,不会报错。大家可以自行测试一下

其实这一点很不好,容易造成不好的习惯

这一点pnpm就设计的特别好,在项目中不能够使用间接依赖

如果要执行安装在本地的CLI,可以使用pnpx,和npx的功能完全一样,唯一不同的是,在使用pnpx执行一个需要安装的命令时,会使用pnpm进行安装

如果使用npx执行mocha的命令,本地没有的话npx就会先临时下载mocha,保存在内存中,方便使用

下次使用的时候还需要重新安装,不会保存在本地的node_modules文件夹中

也就是说npx有可能触发包的安装

那么pnpx有什么区别呢?在于使用时pnpx执行一个需要安装的命令时,会使用pnpm进行安装,就这一点区别

pnpm的工作原理

和npm以及yarn一样,pnpm仍然使用缓存来保存已经安装过的包,以及使用pnpm-lock.yaml来记录详细的依赖版本

不同于yarn和npm,pnpm使用符号链接和硬链接(可将它们想象成快捷方式)的做法来放置依赖,从而规避了从缓存中拷贝文件的时间,使得安装和卸载的速度更快

由于使用了符号链接和硬链接,pnpm可以规避windows操作系统路径过长的问题,因此,它选择使用树形的依赖结果,有着几乎完美的依赖管理。也因为如此,项目中只能使用直接依赖,而不能使用间接依赖。

以windows系统为例,pnpm的缓存会保存在项目的根目录里

我现在的测试项目在F盘,那么F盘就会有一个 .pnpm-store 的文件夹

但是这个mocha并不是完整的包存储在.pnpm-store这个目录中,而是按内容哈希存储的单个文件

打开json文件里面

我文件里面去除了不包含mocha的files,方便查看

而完整的包目录是存储在项目文件夹node_modules/.pnpm

项目中node_modules/.pnpm存储的是pnpm为当前项目构建的虚拟依赖树,里面通过硬链接或者符号链接只想store中的实际文件

在项目中可以找到实际的匹配信息 node_modules/.pnpm/mocha@11.7.5/node_modules/mocha/package.json,这个package.json文件是真实存在的,但是内容是链接到store的,不是复制(这个问题从项目中删除node_modules文件夹速度变快就可以看出)。

如果初次接触到pnpm的原理,觉得这种符号链接与硬链接比较难理解的话,可以理解为windows中的微信快捷方式(可以理解为你的项目中node_modules中.pnpm文件夹中的相关依赖包),点击快捷方式实际上就是找到文件位置中的实际文件并执行,删除快捷方式(可以理解为项目中删除node_modules)并不意味着删除了真实的文件

我这个例子可能某些措辞不太恰当,但是可以帮助大家理解一下。

虽然npm和yarn包管理器也使用了缓存,但是实际用到的包还是会进行拷贝到项目中,是真实存在的

pnpm呢,可以理解为我上面举的例子,在你项目里创建了一个快捷方式,指向到本地磁盘链接中的包,pnpm中就不叫拷贝了,应该叫建立链接更为准确一点

但是版本不一样呢,还是会重新下载的,在这一点上都是一样的

使用一个测试项目来更好的理解pnpm的工作原理

假设我们有一个项目名为project, 直接依赖模块A,则安装的时候,pnpm会做以下处理:

  1. 查询依赖关系,得到最终要安装的包:packageA和packageB
  2. 查看packageA和packageB是否有缓存,如果没有,下载到缓存中,如果有,则进入下一步
  3. 创建node_modules目录,并对目录进行结构初始化

文件结构如下:

这是自己创建的目录,与实际的pnpm安装的可能有差异,因为pnpm也在不断的更新迭代,而且每个人本地的pnpm版本可能不一致

实际的项目中,还有可能因为本地设置了镜像源,例如淘宝的镜像源,那么就有可能多出来一个文件夹 registry.npm.taobao.org 作为所有依赖包的父文件夹,跟.pnpm/node_modules文件夹平级

css 复制代码
project/
├── node_modules   project工程的npm包安装目录
├── .pnpm pnpm的包管理目录,该目录不会被node读取到
│   ├── node_modules 非工程直接依赖包保存在这里,例如packageB
│   ├── packageA 包A的目录
|       ├── 1.0.0版本
│           ├── node_modules 包A所有的依赖以及自身
|               ├── packageA 包A的代码目录
|                   ├── index.js 来自于缓存的硬链接
|               ├── packageB 假设packageA依赖了packageB,那么就会通过符号链接,放置到此目录中
|                   ├── index.js
│   ├── packageB 包B的目录
|       ├── 1.0.0版本 
│           ├── node_modules 包B所有的依赖以及自身
|               ├── packageB 包B的代码目录
|                   ├── index.js 来自于缓存的硬链接
|   ├── else...  其余的依赖包
└── index.js

.pnpm文件夹为什么不会被node读取到

这涉及到包查找顺序的规则(补充知识第七条),正常引入包的方式 require("packageA")或者import packageA from 'packageA'是不会进入.pnpm文件夹去查找的,除非是用绝对路径引入require('./node_modules/.pnpm/xxx')这样的方式去查找,但是一般不会这么去做

.pnpm/node_modules 这个文件夹会将间接依赖包都放在此处,存放方式是扁平化存储

registry.npm.taobao.org就是包的具体版本和代码文件存放的位置,意思就是包从哪里下载的,跟本地设置的镜像源和下载方式有关系,不一定有这个文件夹

packageA下面的版本号里面会有一个node_modules文件夹**(至于为什么这么设计,详细可以查看补充知识第七条 node_modules查找规则)**,对应本地缓存(比方说在C盘)的包使用硬链接放置文件到相应包代码目录中,上方的两个index.js文件就是来自于缓存的硬链接

packageA中的直接依赖包,使用符号链接放置到自己的目录中

硬链接可以节省磁盘存储空间,符号链接可以节省查找时间,间接依赖包都放置到一个文件夹里是防止出现文件中引入间接依赖的代码。

用我本地创建的测试项目的文件结构,依赖关系就是下面这样子:

kotlin 复制代码
pnpm-test/
└── node_modules/
    ├── .pnpm/
    │   └── mocha@11.7.5/
    │       └── node_modules/
    │           └── mocha/               ← 这是你看到的"具体包信息"
    │               ├── package.json     ← 真实文件(小,常直接存储)
    │               ├── index.js         ← 实际是**硬链接**到 store
    └── mocha → symlink to .pnpm/mocha@11.7.5/node_modules/mocha

间接依赖包位置,以及间接依赖包的CLI工具的命令

实际项目中用到的mocha工具包(pnpm-test\node_modules\mocha)也是通过符号链接 .pnpm/mocha@11.7.5/node_modules/mocha 此处的具体包

补充知识

  1. 文件的本质 File Nature

    文件的本质涉及到操作系统的一些知识,可以做为了解,这篇文章也不会讲到很多

    在操作系统中,文件实际上是一个指针(指针的指向是根目录,也就是磁盘,比方说C盘、D盘这些根目录),只不过它指向的不是内存地址,而是一个外部存储地址,这里的外部存储可以是硬盘、U盘、甚至是网络

    当我们删除文件时,实际上删除的是指针,因此无论删除多么大的文件,速度都非常快。

    扩展:比方说现在市面上的数据恢复技术,当你的数据不小心删除了,回收站也清空了,理论是可以恢复的,因为数据还在,但是间隔的时间过久了,就不一定了,因为在这个时间跨度中,有可能文件区域新建了文件,之前的文件可能会被覆盖掉,有可能覆盖不完全还剩下一点之前的数据,所以删除的时间久了有可能恢复不完全甚至不能恢复

  2. 文件的拷贝 File Copy

    如果你复制一个文件,是将该文件指针指向的内容进行复制,然后产生一个新的文件指向新的内容

    是将原文件内容拷贝出来一份,并且有一个新的指针指向它,所以改变复制后的文件不会对原文件产生改变

  3. 硬链接 Hard Link

    硬链接的概念来源于Unix操作系统

    Unix 系统引入硬链接是为了解决文件共享问题,它允许一个文件拥有多个文件名,这些文件名指向同一个文件数据

    Unix中,硬链接和原文件共享相同的 inode 节点号,表明它们是同一个文件

    通俗的讲是指将一个文件指针A复制到另一个文件指针B中,文件B就是文件A的硬链接,实际上就是两个指针指向同一个数据

    通过硬链接,不会产生额外的磁盘占用,并且两个文件都能找到相同的磁盘内容

    硬链接的数量没有限制,可以为同一个文件产生多个硬链接

    windows Vista操作系统开始,支持了创建硬链接的操作,在cmd中使用下面的命令可以创建硬链接

    shell 复制代码
    mklink /h 链接名称 目标文件

    在桌面新建了一个文件夹,子文件夹为temp,里面有一个空文本文件article.txt

    创建硬链接 link.txt

    当我在article.txt中写入article这个单词时,link.txt文本文件也同样写入这个单词

    由于文件夹(目录)不存在文件内容,所以文件夹(目录)不能创建硬链接

    在windows操作系统中,通常不要跨越盘符创建硬链接

  4. 符号链接 Symbol Link

    符号链接又被称为软连接,如果为某个文件或文件夹A创建符号链接B,则B指向A

    意思就是软连接B指向了A,而不是指向文件内容,把A删除,B也就无效了

    windows在cmd中使用下方命令可以创建符号链接

    shell 复制代码
    mklink /d 链接名称 目标文件
    # /d表示创建的是目录的符号链接,不写则是文件的符号链接

    早期的windows不支持符号链接,但是提供了一个junction来达到类似的功能

    符号链接和硬链接的区别

    • 硬链接只能链接文件,而符号链接可以链接目录
    • 硬链接在链接完成后仅和文件内容关联,和之前链接的文件没有任何关系。而符号链接始终和之前的链接的文件关联,和文件内容不直接相关
  5. 快捷方式

    之前也举过例子,其实快捷方式类似于符号链接,是windows系统早期就支持的链接方式。

    它不仅仅是一个指向其他文件或目录的指针,其中还包含了各种信息:如权限、兼容性启动方式等其他各种属性

  6. node环境对硬链接和符号链接的处理

    硬链接:硬链接是一个实实在在的文件,node不对其做任何特殊处理,也无法区别对待,实际上,node根本无从知晓文件是不是一个硬链接

    符号链接:由于符号链接指向的是另一个文件或目录,当node执行符号链接下的JS文件时,会使用原始路径。

    js 复制代码
    // src/temp/sub/a.js
    console.log(__dirname) // C:\Users\jinai\Desktop\test\src\temp\sub
    
    const b = require('../b')
    console.log("b模块的内容", b) // b模块的内容 { name: 'b' }
    
    // src/temp/b.js
    module.exports = {
      name: 'b'
    }

    此时对a.js创建硬链接,放到src下面

    再运行src下面的a.js文件就会报错 Cannot find module '../b'

    js 复制代码
    // src/a.js
    // 将硬链接后的文件做如下修改
    console.log(__dirname) // C:\Users\jinai\Desktop\test\src
    
    // const b = require('../b')
    // console.log("b模块的内容", b) // b模块的内容 { name: 'b' }

    那么硬链接后运行的结果输出的 __dirname 就是 C:\Users\jinai\Desktop\test\src

    如果此时创建一个temp\sub的符号链接link

    那么link文件夹里面就会有一个a.js文件

    运行 link/a.js

    那么得到的 __dirname 就是 C:\Users\jinai\Desktop\test\src\temp\sub

    意思就是符号链接自己的路径虽然能找到,但是运行环境还是源文件路径

  7. 文件查找规则

    • 如果确定是文件模块,路径是一个相对路径require('./module') ,如果有相应的文件名直接匹配,加载该文件,如果没有会尝试.js ,.ts,.json,.node等等后缀名的文件
    • 如果是目录模块,会先查找目录下的package.json文件,根据main字段指定的文件名进行加载,如果没有package.json或者package.json文件中没有main字段,会尝试加载目录下的index.js或者index.node文件
    • 如果是内置模块,比方说fs,os,http等,则直接返回模块,不进行文件查找
    • node_modules查找,如果上述模块都未能解析模块,会在当前文件夹的父目录中查找node_modules文件夹,并尝试在其中查找模块。这一过程会一直向上递归至文件系统的根目录。

为什么这么设计,这么设计的优势在哪里?

pnpm 之所以采用这样的设计,根本目的是为了解决传统包管理器(如 npm、Yarn)在磁盘占用、安装速度、依赖一致性、安全性等方面长期存在的问题。

各种包管理工具也在互相学习,可能npm、yarn已经解决了其中的一些问题

解决的核心问题:

  1. 重复下载与磁盘浪费

    多个项目使用同一个包,每个项目都需要复制一份完整的包到自己项目中的node_modules

  2. 安装速度慢

    每次 npm install 都要大量文件复制、解压、写入磁盘

  3. 嵌套依赖地狱

    在npm v2中,完全嵌套,路径超长(Windows 路径长度限制常被触发)

    npm v3+ / Yarn:扁平化但有依赖风险(项目能 require 未声明的依赖)

  4. 依赖不一致

    因为扁平化,A 依赖 B,B 依赖 C,结果 A 能直接 require('C'),即使没声明

    这种问题就可能导致本地能跑起来,但是CI会报错

  5. 缓存机制弱

    无法跨项目共享已解压文件

    每次仍需解压到项目目录

基于以上这些问题,pnpm才会采用现阶段的模式,来实现隔离,复用以及确定性

原则 实现方式 效果
单一事实源(Single Source of Truth) 所有包文件只存一份在全局 消除重复
依赖严格隔离 每个包只能访问自己声明的依赖 杜绝直接使用间接依赖
安装即链接(Link, not Copy) 使用硬链接/符号链接代替文件复制 安装快、省空间
确定性结构 node_modules 结构由 lockfile 唯一决定 可重现构建

设计优势如下:

  1. 节省磁盘空间

    比方说你本地有50个项目,每个项目都用到react@18.2.0 这个依赖

    假设一个react包有5M,那么使用npm就需要300M左右的空间,pnpm只需要全局存一份,只需要5M的空间,之后的项目中reused即可(前提是在一个根目录磁盘中)

  2. 安装速度快

    第一次安装与npm速度差不多,因为都需要远程下载

    第二次安装,pnpm会直接使用本地缓存

  3. 杜绝使用间接依赖

    在 pnpm 中,你的代码 只能 require 自己 package.json 中声明的依赖

    即使某个间接依赖(如 mocha 依赖 debug),你也不能直接 require('debug') ,除非显式安装

  4. 更安全的依赖解析

    • 每个包的依赖树都是完全隔离的,不同版本的同一个包(如 lodash@4.17.0lodash@4.17.21)不会互相污染。
    • 即使下载源被黑,根据本地的lock文件也可以确保构建项目可重现
    • 使用内容哈希 + integrity 校验可以防止磁盘缓存篡改
  5. 高效的缓存与清理

    pnpm store prune:自动删除没有任何项目引用的包,安全释放空间。

    Docker 构建中只需缓存 %LOCALAPPDATA%\pnpm\store,即可加速后续构建。

注意事项

由于pnpm会改动node_modules目录结构,使得每个包只能使用直接依赖,而不能使用间接依赖,因此,如果使用pnpm安装的包中包含间接依赖,则会出现问题,由于pnpm超高的安装效率,越来越多的包开始修正之前的间接依赖代码。

其实并不是pnpm这个包管理工具的注意事项

一个大的项目工程中有可能依赖很多的包,这些包里面有可能使用不规范,这些包内部有可能直接引用了间接依赖

A依赖了B,B依赖了C

有可能在A中直接使用C

这实际上是包的内部代码不规范造成的

甚至有可能出现相对路径 ./node_modules/xxx 这样子,更不规范了

实际上是这些第三方包的不规范,所以造成了pnpm使用起来需要注意,因为很多大的第三方依赖有可能改造起来没有那么好改造,内部代码依赖层级过深等等原因造成的

所以使用pnpm这个管理工具还是需要谨慎使用

感谢观看本篇文章,有什么写的不好的地方还请指出,谢谢~

相关推荐
~无忧花开~3 小时前
JavaScript实现PDF本地预览技巧
开发语言·前端·javascript
小时前端4 小时前
“能说说事件循环吗?”—— 我从候选人回答中看到的浏览器与Node.js核心差异
前端·面试·浏览器
IT_陈寒4 小时前
Vite 5.0实战:10个你可能不知道的性能优化技巧与插件生态深度解析
前端·人工智能·后端
SAP庖丁解码4 小时前
【SAP Web Dispatcher负载均衡】
运维·前端·负载均衡
天蓝色的鱼鱼4 小时前
Ant Design 6.0 正式发布:前端开发者的福音与革新
前端·react.js·ant design
HIT_Weston4 小时前
38、【Ubuntu】【远程开发】拉出内网 Web 服务:构建静态网页(一)
linux·前端·ubuntu
零一科技5 小时前
Vue3拓展:自定义权限指令
前端·vue.js
t***D2645 小时前
Vue虚拟现实开发
javascript·vue.js·vr
im_AMBER5 小时前
AI井字棋项目开发笔记
前端·笔记·学习·算法
小时前端5 小时前
Vuex 响应式原理剖析:构建可靠的前端状态管理
前端·面试·vuex