Pnpm的进化进程

历史变迁

npm->yarn->pnpm可以看成一个包管理工具变迁的进程。本文主要梳理目前不同包管理工具之间的 差异 和 缺陷,便于日后使用中能够根据场景选择。

1.1 为什么需要npm

在npm发布之前,都是通过手动的方式下载和管理依赖项的,诸多不便。因此,形成了如今我们常用的包管理方式------将所需依赖名添加到package.json文件中,并将下载的依赖文件添加到node_modules中。那么不同版本的npm的包结构都是什么样:

  • npm v1 & v2
kotlin 复制代码
node_modules
├── A@1.0.0
│   └── node_modules
│       └── B@1.0.0
├── C@1.0.0
│   └── node_modules
│       └── B@2.0.0
└── D@1.0.0
    └── node_modules
        └── B@1.0.0
  1. 重复依赖:不同模块下的相同依赖被重复安装
  2. 依赖地狱:依赖嵌套层级很深,体积过大,安装速度慢
  3. 实例不共享:同一个依赖分包引入,两个包可能存在不在同一模块的情况,导致无法共享变量,导致bug问题
  • npm v3
kotlin 复制代码
node_modules
├── A@1.0
├── B@1.0
    └── node_modules
        └── C@2.0
└── C@1.0

AC依赖不再放在A的路径下,而是变成扁平结构 ,和AB同级。但是由于C存在两个相同版本,顶级提出C@1.0C@2.0版本放在B的依赖下

  1. 算法耗时长:由于扁平树的计算,耗时比过往版本更长
  2. 幽灵依赖 :例如上述的C@1.0依赖,虽然项目中没有添加,但是依旧可以在项目中引入使用。这造成了依赖不可控的问题,后续假设A@1.0不再使用C@1.0以后,项目引入的C@1.0就会存在问题
  3. 依赖分身 :虽然象征性的提升了子依赖层级,但是其实并没有根本的解决重复依赖的问题。在下面的依赖结构中,还是存在相同依赖C@2.0被重复引入的问题。
kotlin 复制代码
node_modules
├── A@1.0
├── B@1.0
│   └── node_modules
│       └── C@2.0
├── C@1.0
├── D@1.0
└── E@1.0
    └── node_modules
        └── C@2.0
  1. 破坏单例模式 :如上所示,虽然C@2.0是同一版本的同一依赖,但是本质上是不同的对象。如果依赖中存在单例模式如下,则会出现破坏单例的情况
js 复制代码
// 在某些情况下,可能会有多个版本的同一个包 
// 导致单例失效 
// package-a 使用的版本 
const dbConnection1 = require('my-db-singleton'); // 来自顶层 
// package-b 使用的版本 
const dbConnection2 = require('my-db-singleton'); // 来自嵌套目录 
// 在 npm v3 中,这两个可能是不同的实例! 
console.log(dbConnection1 === dbConnection2); // 可能为 false

1.2 为什么需要yarn

为解决npm v3上述问题,yarn率先提出了:

  • 锁版本的版本控制方式yarn.lock
  • 并发的网络请求:npm是串行的包安装模式,一个包安装完再去安装下一个。与此不同的是,yarn实现了并行的安装模式,提升了包安装速度。
  • 新增离线缓存机制:yarn会将包安装到本地磁盘,当第二次再进行安装时,可支持离线安装。
json 复制代码
// package-lock.json 锁定了具体版本
{
  "dependencies": {
    "my-singleton": {
      "version": "1.2.5",  // 锁定具体版本
      "integrity": "sha512-..."
    },
    "dep-a": {
      "version": "1.0.0",
      "requires": {
        "my-singleton": "^1.0.0"  // 解析为 1.2.5 (满足 ^1.0.0)
      }
    },
    "dep-b": {
      "version": "2.0.0", 
      "requires": {
        "my-singleton": "^1.2.0"  // 解析为 1.2.5 (满足 ^1.2.0)
      }
    }
  }
}

结果目录结构:

kotlin 复制代码
node_modules/
├── my-singleton@1.2.5/    # 唯一版本,所有依赖共享
├── dep-a@1.0.0/
└── dep-b@2.0.0/

1.3 为什么需要pnpm

yarn虽然提升了包安装速度,解决了部分npm问题,但是依旧没有解决幽灵依赖依赖分身 的问题

json 复制代码
// 无法统一的情况
{
  "dependencies": {
    "dep-a": {
      "version": "1.0.0",
      "requires": {
        "my-singleton": "^1.0.0"  // 需要 >=1.0.0 <2.0.0
      },
      "dependencies": {
        "my-singleton": {
          "version": "1.5.0"      // 嵌套安装
        }
      }
    },
    "dep-b": {
      "version": "2.0.0",
      "requires": {
        "my-singleton": "^2.0.0"  // 需要 >=2.0.0 <3.0.0
      },
      "dependencies": {
        "my-singleton": {
          "version": "2.1.0"      // 嵌套安装
        }
      }
    }
  }
}
kotlin 复制代码
// 生成包结构
node_modules/
├── dep-a@1.0.0/
│   └── node_modules/
│       └── my-singleton@1.5.0/    # 分身 1
└── dep-b@2.0.0/
    └── node_modules/
        └── my-singleton@2.1.0/    # 分身 2

而pnpm就采用内容寻址方式,很好的解决了yarn遗留的问题

pnpm解决幽灵依赖

2.1 什么是幽灵依赖

幽灵依赖是指你的代码能够导入和使用某个包,但这个包并没有在你的 package.json 中声明的现象。

json 复制代码
// package.json
{
  "dependencies": {
    "antd": "^4.24.0"
  }
}
jsx 复制代码
// 你的代码
import React from 'react';        // 幽灵依赖!antd 带来的
import moment from 'moment';      // 幽灵依赖!antd 带来的
import { Button } from 'antd';

// 这些代码能工作,但很危险
const App = () => {
  const now = moment();  // 如果 antd 某天不用 moment 了呢?
  return <Button>Click me</Button>;
};

2.2 硬连接 & 符号连接

pnpm通过内容寻址的方式解决了幽灵依赖的问题,并高效提升了磁盘利用率。其中有两个很关键的概念:

  • 硬链接 Hard link:硬链接可以使得用户可以通过路径引用查找到全局 store 中的源文件。不同的项目可以从全局 store 寻找到同一个依赖,大大地节省了磁盘空间。
  • 符号链接 Symbolic link :也叫软连接,可以理解为快捷方式,pnpm 可以通过它找到当前项目下的同版本依赖项。
kotlin 复制代码
node_modules
├── .pnpm
│   ├── A@1.0
│   │   └── node_modules
│   │       ├── A => <store>/A@1.0
│   │       └── B => ../../B@1.0
│   ├── B@1.0
│   │   └── node_modules
│   │       └── B => <store>/B@1.0
│   ├── B@2.0
│   │   └── node_modules
│   │       └── B => <store>/B@2.0
│   └── C@1.0
│       └── node_modules
│           ├── C => <store>/C@1.0
│           └── B => ../../B@2.0
│
├── A => .pnpm/A@1.0.0/node_modules/A
└── C => .pnpm/C@1.0.0/node_modules/C

如果遇到不同版本的同一依赖,则在store中缓存多版本的依赖项,并且通过硬连接映射

kotlin 复制代码
.pnpm-store
  ├── C@1.1
  └── C@2.3
kotlin 复制代码
node_modules/
  .pnpm/
    A@x.x_xxx/
      node_modules/
        C/   -> 硬链接到 .pnpm-store/C@1.1
    B@x.x_xxx/
      node_modules/
        C/   -> 硬链接到 .pnpm-store/C@2.3
相关推荐
屿小夏1 小时前
openGauss020-openGauss 向量数据库深度解析:从存储到AI的全栈优化
前端
Y***98511 小时前
【学术会议论文投稿】Spring Boot实战:零基础打造你的Web应用新纪元
前端·spring boot·后端
q***33371 小时前
SpringMVC新版本踩坑[已解决]
android·前端·后端
亿元程序员2 小时前
做了十年游戏,我才意识到:程序员最该投资的,是一台专业的编程显示器
前端
IT_陈寒2 小时前
Python高手都在用的5个隐藏技巧,让你的代码效率提升50%
前端·人工智能·后端
lcc1872 小时前
Vue3 ref函数和reactive函数
前端·vue.js
艾小码2 小时前
还在为组件通信头疼?defineExpose让你彻底告别传值烦恼
前端·javascript·vue.js
gnip2 小时前
docker总结
前端
槁***耿2 小时前
TypeScript类型推断
前端·javascript·typescript