重新前端工程化:想了解前端工程化,PNPM 你可不能落下 😎😎😎

面试导航 是一个专注于前、后端技术学习和面试准备的 免费 学习平台,提供系统化的技术栈学习,深入讲解每个知识点的核心原理,帮助开发者构建全面的技术体系。平台还收录了大量真实的校招与社招面经,帮助你快速掌握面试技巧,提升求职竞争力。如果你想加入我们的交流群,欢迎通过微信联系:yunmz777

npm 缺点

npm 的依赖扁平化机制

在 npm v3 及以后版本中,当我们执行npm install安装依赖包时,npm 会采用依赖扁平化 策略。以安装axios为例,npm 不仅会安装axios本身,还会将axios的所有依赖包(如follow-redirectsproxy-from-env等)提升(hoist)到项目根目录的node_modules下,而不是嵌套在axios的子目录中。

幽灵依赖现象

这种扁平化机制导致了"幽灵依赖 "(Phantom Dependencies)问题。例如,虽然我们的package.json中只声明了对axios的依赖,但由于axios依赖的 proxy-from-env 被扁平化安装到了顶层 node_modules 目录,我们可以在代码中直接使用:

javascript 复制代码
// 能够正常工作,尽管我们从未在package.json中声明对proxy-from-env的依赖
const proxyFromEnv = require("proxy-from-env");
// 或
import proxyFromEnv from "proxy-from-env";

这是因为 Node.js 的模块解析机制会沿着目录层次向上查找 node_modules,直到找到匹配的包。

npm 后续版本没有从根本上解决幽灵依赖问题。即使在最新的 npm v8 和 npm v9 中,依赖扁平化仍是默认行为,这意味着:

  1. 非直接依赖仍会被提升到顶层 node_modules

  2. 项目代码仍然可以直接引用未在 package.json 中声明的依赖

  3. 没有提供类似 pnpm 的严格依赖树隔离机制

npm 团队认为改变这一行为可能会破坏大量现有项目,因此一直没有从根本上改变这一机制。

npm 的空间浪费问题

npm 的另一个显著问题是依赖重复安装造成的空间浪费。当我们在不同项目中使用相同的依赖包时,npm 会在每个项目的node_modules中分别安装一份完整的依赖副本,即使这些依赖的版本完全相同。

对于拥有多个项目的开发者来说,这不仅:

  • 延长了依赖安装时间,降低开发效率
  • 占用了大量磁盘空间(一个现代 JavaScript 项目的 node_modules 文件夹大小通常在数百 MB 甚至 GB 级别)
  • 增加了网络带宽消耗,特别是在 CI/CD 环境中

而这些问题在使用 pnpm 等更现代的包管理工具时可以得到有效解决,通过内容寻址存储和硬链接/符号链接技术,大幅减少重复依赖占用的磁盘空间。

对于空间浪费问题,npm 有一些有限的改进:

  1. npm v5 引入了 package-lock.json:提高了安装的确定性,但没有解决重复安装问题

  2. npm v7 引入了 workspaces 功能:改善了 monorepo 项目中的依赖管理,但与 yarn workspaces 和 pnpm workspaces 相比功能仍较弱

  3. 缓存机制改进:npm 的缓存机制有所改进,但仍然是复制而非硬链接方式,无法从根本上解决空间浪费

npm v8 和 v9 的性能优化:提高了安装速度,但依然保持了每个项目独立安装依赖的模式

为了解决这一痛点,pnpm 包管理工具从此诞生,我们先聊聊什么是 pnpm

什么是 pnpm

根据官方介绍,"p" 代表"performant"(高性能),因此 pnpm 全称为 "performant npm" ------ 一个专注于速度和效率的 npm 增强版本。

pnpm 的核心优势主要有以下几个方面:

  • 快速:pnpm 安装依赖的速度通常比 npm 和 yarn 快 2 倍以上,特别是在处理大型项目时,这种优势更为明显

  • 创新的磁盘空间利用 :pnpm 采用内容寻址存储机制,所有依赖包存储在一个集中位置,项目的 node_modules 中只包含指向这个存储的硬链接,大幅减少了重复存储相同依赖的空间浪费

  • 支持 monorepo:pnpm 内置工作区(workspace)功能,让多包仓库管理变得简单高效,无需额外工具即可处理相互依赖的多个包

  • 严格的依赖隔离 :pnpm 创建非扁平化的 node_modules 结构,确保项目只能访问 package.json 中明确声明的依赖,从根本上解决了"幽灵依赖"问题,提高了项目的稳定性和安全性

这些特性使 pnpm 成为当前最先进的 JavaScript 包管理器之一,特别适合对性能、安全性和工程规范有较高要求的团队使用。

软链接

软链接就像一张便条,上面写着"真正的文件在那边":

text 复制代码
┌─────────────────┐       ┌─────────────────┐
│   软链接文件    │       │   目标文件      │
│                 │       │                 │
│ "/moment/file" │──────>│  实际内容       │
│                 │       │                 │
└─────────────────┘       └─────────────────┘

软链接的核心特性主要有以下几个方面:

  1. 软链接是独立的文件,有自己的 inode 和数据块

  2. 软链接存储的仅是目标文件的路径文本

  3. 当访问软链接时,系统会读取这个路径,然后跳转到目标位置

  4. 如果目标文件被删除,软链接仍然存在,但指向一个不存在的位置(断链)

假设我们有这样的代码:

js 复制代码
// 目标对象
const targetFile = { content: "这是文件内容" };

// 软链接
const symbolicLink = "/path/to/targetFile"; // 仅存储路径字符串

// 访问软链接时的行为
function accessFile(path) {
  // 系统会解析路径,找到真正的文件
  const resolvedPath = resolvePath(path); // 假设这个函数能把路径解析成实际对象
  return resolvedPath; // 返回targetFile对象
}

// 如果删除目标文件
// delete targetFile;
// accessFile(symbolicLink); // 错误:找不到文件

硬链接

硬链接就像文件的别名,多个文件名共享同一块物理存储:

text 复制代码
┌─────────────────┐
│   文件名A       │
│   (原始文件)    │──┐
└─────────────────┘  │
                     │    ┌─────────────────┐
                     ├───>│                 │
                     │    │   物理存储      │
┌─────────────────┐  │    │  (inode内容)    │
│   文件名B       │  │    │                 │
│  (硬链接)       │──┘    └─────────────────┘
└─────────────────┘

它的核心特性主要有以下几个方面:

  1. 硬链接与原始文件共享完全相同的 inode 和数据块

  2. 系统会维护一个引用计数,记录有多少文件名指向这个 inode

  3. 只有当最后一个指向该 inode 的文件名被删除时,数据才会被真正删除

  4. 由于直接指向物理数据,硬链接不能跨文件系统,也通常不能用于目录

如下代码所示:

js 复制代码
// 文件系统中的inode和数据
const fileData = { content: "这是文件内容" };
const inodeTable = {
  1234: {
    data: fileData,
    linkCount: 2, // 引用计数
  },
};

// 两个文件名,指向同一个inode
const fileEntries = {
  "/path/original.txt": 1234, // inode号
  "/path/hardlink.txt": 1234, // 同一个inode号
};

// 删除一个文件名
function deleteFile(path) {
  const inodeNumber = fileEntries[path];
  delete fileEntries[path]; // 删除这个文件名

  inodeTable[inodeNumber].linkCount--; // 减少引用计数

  // 只有当引用计数为0时,才真正删除数据
  if (inodeTable[inodeNumber].linkCount === 0) {
    delete inodeTable[inodeNumber];
  }
}

软链接和硬链接的区别

接下来我们先用一个更贴近实际的代码来表示它们的区别:

js 复制代码
// 硬链接的情况
const fileContent = { text: "文件内容" }; // 内存中的实际数据

// 两个变量指向同一个对象(类似硬链接)
const originalFile = fileContent;
const hardLink = fileContent;

// 通过任何一个变量修改内容,都会影响到另一个
hardLink.text = "修改后的内容";
console.log(originalFile.text); // "修改后的内容"

// 即使将一个变量设为null,数据仍然存在,可通过另一个变量访问
originalFile = null;
console.log(hardLink.text); // 仍然可以访问"修改后的内容"

// 软链接的情况
const realFile = { text: "真实文件内容" };
const fileRegistry = {
  "/documents/file.txt": realFile,
};

// 软链接只存储路径,不直接引用对象
const symLink = "/documents/file.txt";

// 访问软链接需要额外的解析步骤
function readSymLink(link) {
  return fileRegistry[link]; // 解析路径,找到真实文件
}

const fileContent = readSymLink(symLink);
console.log(fileContent.text); // "真实文件内容"

// 如果删除真实文件,软链接指向的位置不再有效
delete fileRegistry["/documents/file.txt"];
const brokenLinkContent = readSymLink(symLink); // undefined,链接断开

硬链接部分 展示了多个变量(originalFilehardLink)直接引用同一个内存对象,它们平等地共享访问权,一个变量的修改会影响所有引用,且只有当所有引用都消失时,对象才会被垃圾回收。

软链接部分 则演示了间接引用机制,symLink仅存储一个路径字符串,每次访问都需要通过readSymLink函数解析这个路径找到实际对象,当目标对象被删除时,软链接仍然存在但指向了不存在的位置,变成了"断链"。

对比维度 硬链接 软链接
本质区别 直接指向文件数据的多个入口点 指向另一个文件路径的特殊文件
依赖关系 硬链接之间是平等的,没有主从关系 软链接依赖于目标文件的存在和位置
删除行为 删除任一硬链接不影响文件内容,除非删除最后一个硬链接 删除软链接的目标文件会导致软链接失效
空间占用 不占用额外空间(除了目录项) 占用存储其路径所需的空间

这种理解不仅帮助掌握文件系统概念,也有助于理解现代包管理器如 pnpm 如何通过硬链接节省空间,以及如何通过软链接构建依赖结构。

pnpm 的依赖存储与更新机制

pnpm 采用了创新的内容寻址存储系统来管理依赖包,显著优化了依赖管理的效率和磁盘空间利用:

pnpm 将所有依赖包存储在一个全局的内容寻址仓库中(通常位于 ~/.pnpm-store )。这个存储系统具有以下特点:

  • 文件级别的存储粒度:依赖包被分解为单独的文件,每个文件基于其内容生成唯一哈希值

  • 全局单一副本:具有相同内容的文件只存储一次,无论它被多少个包或项目使用

  • 硬链接机制:项目安装依赖时,通过硬链接指向存储中心的文件,不复制文件内容

当更新依赖版本时,pnpm 的工作流程是:

  1. 差异化存储:只有实际变更的文件会被添加到存储中心

  2. 内容哈希比对:pnpm 比较文件内容的哈希值,识别出新版本中变化的文件

  3. 增量存储:只将新版本中有变化的文件添加到存储中,共享未变更的文件

例如,当一个拥有 100 个文件的依赖包发布新版本,但只修改了其中 1 个文件时:

  • npm/yarn 会下载并存储整个新版本的 100 个文件

  • pnpm 只会在存储中心添加那 1 个变更的新文件,其余 99 个文件与旧版本共享

这种设计带来的好处是:

  • 极高的存储效率:同一文件不会存储多份,大幅节省磁盘空间

  • 快速的更新速度:只需处理实际变更的文件,减少网络传输和磁盘写入

  • 节省带宽:只下载实际变更的文件,而非整个包

  • 版本切换成本低:在不同版本间切换时,只需更改硬链接指向,而非复制整个依赖包

通过这种文件级别的精细化存储和增量更新策略,pnpm 在多项目环境和频繁依赖更新的场景中表现出显著的性能优势,同时保持了与 npm 生态系统的完全兼容性。

创建非扁平的 node_modules

pnpm 采用了一种创新的"虚拟存储"架构来管理 node_modules,这种设计既解决了 npm 的幽灵依赖问题,又保持了高效的依赖访问:

当执行pnpm add axios安装依赖时,生成的 node_modules 结构有两个关键部分:

  1. 顶层符号链接:顶层的 node_modules 中,每个直接依赖(如 axios)都是一个符号链接,而不是实际的包内容

  2. 虚拟存储目录 :所有实际的包内容存储在特殊的.pnpm目录中,按照严格的命名模式组织:

xml 复制代码
   .pnpm/<包名>@<版本>/node_modules/<包名>

例如,axios 的实际内容位于:

bash 复制代码
node_modules/.pnpm/axios@1.8.3/node_modules/axios

如下图所示:

这种结构的工作原理:

  • 严格的依赖边界 :每个包只能访问自己在 package.json 中声明的依赖

  • 符号链接网络:pnpm 通过精心设计的符号链接网络,确保每个包可以找到其直接依赖

  • 依赖解析路径保持不变:Node.js 的模块解析机制通过这些符号链接正常工作

pnpm 的虚拟存储方式完美解决了 npm 扁平化结构带来的幽灵依赖问题:

  • 不可能访问未声明的依赖:项目只能访问直接声明在 package.json 中的依赖

  • 强制的依赖隔离:每个包只能看到其自身的依赖,不会意外访问其他包的依赖

  • 依赖关系清晰可见:node_modules 结构准确反映了实际的依赖关系图

pnpm 的这种结构设计在多个方面实现了平衡:

  • 避免长路径问题 :通过.pnpm目录的平铺结构,避免了早期 npm 嵌套结构导致的路径过长问题

  • 保持包隔离:不同于 npm 的完全扁平化,pnpm 保持了包之间的隔离,避免了依赖污染

  • 磁盘空间效率:通过硬链接共享实际文件内容,最大限度减少磁盘占用

这种创新的依赖管理结构使 pnpm 成为兼顾正确性和效率的现代包管理器,特别适合大型项目和对依赖管理质量有较高要求的团队使用。

node_modules 下的文件都是干嘛的?

除了 .pnpm 目录,我们还可以看到有以下这些文件夹:

.bin 目录

这个目录包含可执行文件的符号链接,允许您在命令行中直接运行依赖包提供的命令,当依赖包在 package.json 中定义了 "bin" 字段时,pnpm 会在此创建相应的符号链接,例如安装 TypeScript 后,.bin/tsc 会指向 TypeScript 编译器的可执行文件,从而使您可以在 npm 脚本或直接在项目中运行如 npx tsc 等命令。

各个包目录 (axios 等)

这些符号链接指向 .pnpm 目录中的实际包,代表您在 package.json 中直接声明的依赖,每个链接指向 .pnpm 目录中相应包的实际位置,这样应用代码可以正常导入这些依赖,而无需了解 pnpm 的虚拟存储结构,且这些符号链接仅包括您直接依赖的包,避免了幽灵依赖问题。

.modules.yaml 文件

根据前面的内容,我们可以知道 pnpm 的 node_modules 结构比 npm 复杂得多:

  1. npm 简单地把所有东西扁平化放在顶层

  2. pnpm 创建了复杂的符号链接网络,需要有一个地方记录这些安排

想象一下你有一个大型图书馆,.modules.yaml 就像是图书馆的管理记录本,记录了:

  1. 哪些书放在哪里:它记录了哪些依赖包被放在了哪个位置

  2. 特殊安排:哪些包需要特殊处理(被提升到顶层)

  3. 当前状态:整个依赖结构的当前状态和版本信息

首先我先来贴上我目前完整的代码:

yaml 复制代码
hoistPattern:
  - "*"
hoistedDependencies:
  "@cspotcode/source-map-support@0.8.1":
    "@cspotcode/source-map-support": private
  "@jridgewell/resolve-uri@3.1.2":
    "@jridgewell/resolve-uri": private
  "@jridgewell/sourcemap-codec@1.4.15":
    "@jridgewell/sourcemap-codec": private
  "@jridgewell/trace-mapping@0.3.9":
    "@jridgewell/trace-mapping": private
  "@tsconfig/node10@1.0.11":
    "@tsconfig/node10": private
  "@tsconfig/node12@1.0.11":
    "@tsconfig/node12": private
  "@tsconfig/node14@1.0.3":
    "@tsconfig/node14": private
  "@tsconfig/node16@1.0.4":
    "@tsconfig/node16": private
  "@types/node@20.14.8":
    "@types/node": private
  acorn-walk@8.3.3:
    acorn-walk: private
  acorn@8.12.0:
    acorn: private
  ansi-styles@3.2.1:
    ansi-styles: private
  arg@4.1.3:
    arg: private
  array-back@3.1.0:
    array-back: private
  asynckit@0.4.0:
    asynckit: private
  call-bind-apply-helpers@1.0.2:
    call-bind-apply-helpers: private
  chalk@2.4.2:
    chalk: private
  color-convert@1.9.3:
    color-convert: private
  color-name@1.1.3:
    color-name: private
  combined-stream@1.0.8:
    combined-stream: private
  command-line-args@5.2.1:
    command-line-args: private
  command-line-usage@6.1.3:
    command-line-usage: private
  create-require@1.1.1:
    create-require: private
  deep-extend@0.6.0:
    deep-extend: private
  delayed-stream@1.0.0:
    delayed-stream: private
  diff@4.0.2:
    diff: private
  dunder-proto@1.0.1:
    dunder-proto: private
  es-define-property@1.0.1:
    es-define-property: private
  es-errors@1.3.0:
    es-errors: private
  es-object-atoms@1.1.1:
    es-object-atoms: private
  es-set-tostringtag@2.1.0:
    es-set-tostringtag: private
  escape-string-regexp@1.0.5:
    escape-string-regexp: private
  find-replace@3.0.0:
    find-replace: private
  follow-redirects@1.15.9:
    follow-redirects: private
  form-data@4.0.2:
    form-data: private
  function-bind@1.1.2:
    function-bind: private
  get-intrinsic@1.3.0:
    get-intrinsic: private
  get-proto@1.0.1:
    get-proto: private
  gopd@1.2.0:
    gopd: private
  has-flag@3.0.0:
    has-flag: private
  has-symbols@1.1.0:
    has-symbols: private
  has-tostringtag@1.0.2:
    has-tostringtag: private
  hasown@2.0.2:
    hasown: private
  lodash.camelcase@4.3.0:
    lodash.camelcase: private
  make-error@1.3.6:
    make-error: private
  math-intrinsics@1.1.0:
    math-intrinsics: private
  mime-db@1.52.0:
    mime-db: private
  mime-types@2.1.35:
    mime-types: private
  proxy-from-env@1.1.0:
    proxy-from-env: private
  reduce-flatten@2.0.0:
    reduce-flatten: private
  supports-color@5.5.0:
    supports-color: private
  table-layout@1.0.2:
    table-layout: private
  typical@4.0.0:
    typical: private
  undici-types@5.26.5:
    undici-types: private
  v8-compile-cache-lib@3.0.1:
    v8-compile-cache-lib: private
  wordwrapjs@4.0.1:
    wordwrapjs: private
  yn@3.1.1:
    yn: private
included:
  dependencies: true
  devDependencies: true
  optionalDependencies: true
injectedDeps: {}
layoutVersion: 5
nodeLinker: isolated
packageManager: pnpm@9.4.0
pendingBuilds: []
prunedAt: Tue, 18 Mar 2025 22:55:59 GMT
publicHoistPattern:
  - "*eslint*"
  - "*prettier*"
registries:
  default: https://registry.npmmirror.com/
skipped: []
storeDir: /Users/macmini/Library/pnpm/store/v3
virtualStoreDir: .pnpm
virtualStoreDirMaxLength: 120

这个文件作为 pnpm 的核心配置记录,通过以下三个关键部分发挥重要作用:

解决工具兼容问题 (publicHoistPattern)

yaml 复制代码
publicHoistPattern:
  - "*eslint*"
  - "*prettier*"

这部分定义了需要 "公开提升" 到顶层 node_modules 目录的包。ESLintPrettier 这类工具共有一个特点:它们会自动查找插件,但默认只在顶层 node_modules 中查找。如果严格遵循 pnpm 的隔离结构,这些工具将无法找到它们的插件,导致运行失败。通过这个配置,pnpm 会将所有包含"eslint"或"prettier"关键字的包提升到顶层,从而解决"ESLint 找不到插件"或"Prettier 无法加载配置"等常见问题。这本质上是一种兼容性妥协,在保持严格依赖隔离和确保开发工具正常工作之间取得平衡。

记录已提升的包 (hoistedDependencies)

yaml 复制代码
hoistedDependencies:
  "proxy-from-env@1.1.0":
    "proxy-from-env": private

这个部分跟踪所有已被提升到顶层的依赖包,对每个提升的包记录其确切版本和状态。"private" 标志表示这个包虽然被提升,但不是项目的直接依赖。当 pnpm 需要重建或更新 node_modules 时,它会参考这个记录,确保依赖结构的一致性和可重现性。在示例中,"proxy-from-env" 包被提升了,它很可能是 axios 的间接依赖。提升某些间接依赖可能是出于性能考虑或为了解决特定包的路径解析问题,保证所有依赖正常工作。

全局信息 (storeDir)

yaml 复制代码
storeDir: /Users/macmini/Library/pnpm/store/v3

这记录了 pnpm 全局内容寻址存储的位置,所有项目共用的依赖包实际文件都存储在这个目录中。每个文件根据其内容的哈希值只存储一次,项目中的依赖则通过硬链接指向这个全局存储中的文件。当安装新依赖时,pnpm 首先检查这个全局存储中是否已有相同内容,这大大减少了磁盘空间使用和下载时间。在示例中,存储位于用户的 Library 目录,这是 macOS 上的标准位置,而 "v3" 表示使用的是 pnpm 的第 3 版存储格式。

小结

这个文件就是 pnpm 用来记住"我把所有东西都放在哪里了"的记录本。由于 pnpm 采用了复杂的链接结构来节省空间并解决幽灵依赖问题,它需要这样一个记录文件来跟踪所有内容的位置和状态。 当你运行 pnpm 安装或更新依赖时,pnpm 会读取和更新这个文件,以确保它知道如何正确构建 node_modules 目录。

pnpm 的 Node.js 版本管理功能

pnpm 不仅是一个高效的包管理器,它还具备管理 Node.js 版本的能力,这是它区别于 npm 和 yarn 的独特优势之一。通过pnpm env命令,您可以轻松安装和切换不同版本的 Node.js:

bash 复制代码
# 安装长期支持版(LTS)的Node.js
pnpm env use --global lts

# 安装特定主版本(如Node.js 16)
pnpm env use --global 16

# 安装预发行版本
pnpm env use --global nightly     # 最新的每日构建版
pnpm env use --global rc          # 最新的发布候选版
pnpm env use --global 16.0.0-rc.0 # 特定的预发行版本
pnpm env use --global rc/14       # 特定主版本的最新发布候选版

# 安装最新稳定版Node.js
pnpm env use --global latest

这一集成功能让开发者无需安装额外的版本管理工具(如 nvm 或 n),就能在单一工具中同时管理包依赖和 Node.js 运行时,简化了开发环境的配置和维护。

pnpm 对 workspace 的支持

pnpm 内置了对工作区(Workspace)的强大支持,使其成为实现 Monorepo 项目的理想选择。Workspace 允许您在单个代码仓库中管理多个相互关联的包。

创建 pnpm 工作区非常简单,只需在项目根目录创建 pnpm-workspace.yaml 文件:

yaml 复制代码
packages:
  # 包含所有直接子目录中的包
  - "packages/*"
  # 包含特定目录的包
  - "components/**"
  # 排除某些目录
  - "!**/test/**"

它的主要功能有以下这几个方面:

  1. 集中管理依赖:所有工作区共享一个根目录的 node_modules,公共依赖被安装一次,减少重复。

  2. 本地依赖引用:工作区中的包可以相互引用而无需发布,当包 A 依赖包 B 时,会创建符号链接而非安装远程版本。

  3. 选择性操作:通过过滤器(--filter)精确控制命令适用的包并支持复杂的包选择模式和依赖关系筛选

为什么 pnpm 对 Monorepo 特别友好

在 Monorepo 中,多个包往往共享大量相同依赖。pnpm 的内容寻址存储和硬链接机制在这种场景下表现出色。即使有数十个包,每个依赖的每个版本在磁盘上只存储一次,避免了传统 Monorepo 工具中常见的依赖重复安装问题,大型 Monorepo 中可以节省高达 90%的磁盘空间

pnpm 的非扁平化 node_modules 结构在 Monorepo 环境中强制每个包只能访问自己明确声明的依赖,消除了幽灵依赖隐患,确保了包的独立性和可维护性,同时使项目内部依赖关系更加清晰和可靠。

pnpm 在 Monorepo 环境中通过并行依赖安装、仅处理变更包的增量操作和智能缓存机制,显著提升了大型项目的性能表现,使开发工作流更加高效。

pnpm 提供了专为 Monorepo 设计的命令和功能:

  • 过滤命令--filter参数支持复杂的包选择逻辑

    bash 复制代码
    # 只在changed包上运行测试
    pnpm -r --filter="[origin/main]" test
    
    # 在ui-components及其依赖包上构建
    pnpm -r --filter="ui-components..." build
  • 工作区协议 :支持使用workspace:协议声明内部依赖

    json 复制代码
    {
      "dependencies": {
        "shared-utils": "workspace:*"
      }
    }
  • 拓扑排序:智能处理包之间的依赖关系,确保按正确顺序构建

    bash 复制代码
    # 按照依赖关系顺序执行构建
    pnpm -r build

pnpm 可以与现代 Monorepo 工具完美配合:

  • Turborepo:利用 pnpm 的依赖结构提供更快的构建缓存

  • Changesets:简化版本管理和发布流程

  • TypeScript 项目引用:支持复杂的 TypeScript 项目结构

实际应用示例

一个典型的 pnpm Monorepo 项目结构:

tree 复制代码
my-monorepo/
├── package.json
├── pnpm-workspace.yaml
├── pnpm-lock.yaml
├── packages/
│   ├── core/
│   │   ├── package.json
│   │   └── src/
│   ├── ui/
│   │   ├── package.json
│   │   └── src/
│   └── api/
│       ├── package.json
│       └── src/
└── apps/
    ├── web/
    │   ├── package.json
    │   └── src/
    └── mobile/
        ├── package.json
        └── src/

pnpm 的 Monorepo 结构实现了应用间无缝共享库代码、修改即时生效、一次性安装共享依赖以及独立开发测试的完美平衡,大幅提升了多包项目的开发效率和代码一致性。

这种组合使 pnpm 成为目前实现 Monorepo 架构最高效、最便捷的工具之一,特别适合大型前端项目和组织。

pnpm 安装为什么这么快

pnpm 执行依赖安装时,采用了高效的三阶段执行模式:

1. 依赖解析阶段 (Resolving)

在这一阶段,pnpm 进行以下操作:

  • 读取项目的 package.json 文件,分析直接依赖

  • 递归解析所有子依赖,构建完整的依赖树

  • 解决版本冲突,确定每个依赖的具体版本

  • 检查本地缓存,识别哪些依赖需要从远程下载

pnpm 的解析算法非常高效,尤其在处理已有部分依赖的项目时。它能够精确识别出仅需新增的依赖,避免对整个依赖树进行不必要的重复解析。

2. 获取依赖阶段 (Fetching)

确定了需要的依赖后,pnpm 进入获取阶段:

  • 并行下载多个依赖包,最大化利用网络带宽

  • 将下载的包存储到内容寻址的全局存储中

  • 验证包的完整性,确保下载无误

  • 解压必要的包信息,准备后续链接

这一阶段的并行处理能力是速度优势的关键之一。图中蓝色区域显示多个包同时被获取,而不是串行处理。

3. 链接依赖阶段 (Linking)

这是 pnpm 最具创新性的阶段:

  • 创建项目的 node_modules 目录结构

  • 从全局存储中创建硬链接到项目目录,而非复制文件

  • 建立符号链接网络,构建正确的依赖引用关系

  • 处理特殊情况,如二进制文件和需要提升的包

链接操作本质上只是创建文件引用,而不涉及数据复制,几乎是瞬时完成的,这使得即使对大型项目,链接阶段也能快速完成。

速度优势的核心因素

1. 硬链接复用机制

传统包管理器在每个项目中都复制完整的依赖文件,而 pnpm 使用硬链接:

  • 全局存储中的每个包只保存一份物理文件

  • 项目安装依赖时,只创建指向这些文件的硬链接

  • 硬链接创建速度比文件复制快数百倍

  • 对磁盘 I/O 的需求大幅降低

这一机制在图中体现为链接阶段(橙色)的高效率,特别是对已缓存依赖的处理几乎无需时间。

2. 并行处理架构

pnpm 采用了并处处理的架构,有效利用了现代计算机的多核 CPU 能力:

  • 多个包的安装过程同时进行

  • 不同阶段的任务可以重叠执行

  • 每个阶段内部也是多任务并行

  • 处理器和 I/O 资源得到更充分利用

这种多层次的并行架构使 pnpm 能够显著缩短安装时间,尤其在多核处理器和快速网络环境下。

如下图所示:

而这种方法比传统的三阶段安装过程(解析、获取和写入所有依赖项到 node_modules)要快得多。

3. 增量安装优化

pnpm 通过精确识别需更新的依赖并只处理变更部分,同时重用已建立的链接结构,实现了高效的增量安装。这种最小化必要操作的方法使得部分依赖已存在时,解析和链接阶段显著缩短,大幅提升了日常开发中的安装速度。

4. 高效的缓存利用

pnpm 采用内容寻址的全局存储设计,确保相同文件只存储一次且不同版本的包只保存差异部分,实现了高缓存命中率和最小化网络下载。这种机制大幅降低了重复安装相同依赖的时间开销,特别适合管理多项目开发环境。

小结

pnpm 通过创新的存储架构、三阶段并行执行模式和高效的链接机制,在不牺牲依赖正确性的前提下,实现了显著的速度优势。对于大型项目和团队开发环境,这种优势尤为明显,能够将依赖安装时间从分钟级降至秒级,大幅提升开发效率。

npm 与 pnpm 全局缓存的核心区别

npm 和 pnpm 的全局缓存机制存在本质差异,主要体现在三个关键方面:

存储粒度不同:npm 以整个包为单位缓存完整 tarball,而 pnpm 采用文件级内容寻址存储,相同内容的文件只存储一次,无论它属于哪个包或版本。

使用方式不同:npm 安装时将缓存中的包解压并复制到项目中,导致每个项目都有依赖的完整副本;pnpm 则通过硬链接直接引用全局存储中的文件,无需复制,大幅节省磁盘空间和安装时间。

增量更新策略不同:npm 通常替换整个包,而 pnpm 智能识别并只存储版本间的实际差异文件,使得更新和切换版本更加高效,特别是在管理多个项目时优势显著。

总结

pnpm 是一个革新性的 JavaScript 包管理器,通过内容寻址存储和硬链接机制显著减少磁盘空间占用,同时以非扁平化的 node_modules 结构彻底解决了幽灵依赖问题。它采用三阶段高效并行安装架构充分利用多核 CPU,使依赖安装速度比传统工具快 2-3 倍,并且提供了一流的 Monorepo 支持,使多包项目管理变得简单高效。pnpm 不仅保持了对 npm 生态的完全兼容,还集成了 Node.js 版本管理功能,成为当前最全面、高效的 JavaScript 包管理解决方案。

相关推荐
码客前端几秒前
理解 Flex 布局中的 flex:1 与 min-width: 0 问题
前端·css·css3
Komorebi゛几秒前
【CSS】圆锥渐变流光效果边框样式实现
前端·css
工藤学编程13 分钟前
零基础学AI大模型之CoT思维链和ReAct推理行动
前端·人工智能·react.js
徐同保13 分钟前
上传文件,在前端用 pdf.js 提取 上传的pdf文件中的图片
前端·javascript·pdf
怕浪猫14 分钟前
React从入门到出门第四章 组件通讯与全局状态管理
前端·javascript·react.js
博主花神15 分钟前
【React】扩展知识点
javascript·react.js·ecmascript
欧阳天风22 分钟前
用setTimeout代替setInterval
开发语言·前端·javascript
EndingCoder26 分钟前
箭头函数和 this 绑定
linux·前端·javascript·typescript
郑州光合科技余经理26 分钟前
架构解析:同城本地生活服务o2o平台海外版
大数据·开发语言·前端·人工智能·架构·php·生活
沐墨染28 分钟前
大型数据分析组件前端实践:多维度检索与实时交互设计
前端·elementui·数据挖掘·数据分析·vue·交互