TL;DR:

本篇关于 pnpm patch 内容不多,若只需要 patch 使用的相关内容:降落链接

pnpm patch 的使用场景

当你需要持久化的修改 node_modules 中的依赖时,如在本地修改依赖进行测试,希望其他人使用的也是修改后的依赖,此时可以使用 pnpm patch 进行依赖修改,将相关变更提交即可。

理论上 pnpm patch 修改的依赖可以在任何环境中使用(如 CICD),应用生产前需谨慎使用!

vite 中如何使用 pnpm patch

patch 前的知识 1 - pnpm 的依赖管理

三层寻址、缓存、isolate(依赖隔离)

1. 默认情况下,pnpm 会先将依赖安装到全局 store 中,后续通过 软硬链接 或 clone or copy 复用 Store 中的内容

2. 在每个 workspacenode_modules 中都会有一个 .pnpm 文件目录,来平铺每个依赖,其中通过 硬链接 连接 Store 中源码(仅文件部分,如 index.js

3. workspacepackage.json 声明的相关依赖会平铺在 node_modules 中,其通过 软链接 连接 .pnpm 目录下的依赖,如:node_modules/package-a > 软链接 node_modules/.pnpm/package-a@1.0.0/node_modules/package-a

# 获取全局 Store 的路径
pnpm store path

如图,在 pnpm 中,依赖不会全部提升并平铺在 node_modules 中,仅存储当前项目的直接依赖(隔离,不提升),这将有效防止出现 幽灵依赖分身问题

硬链接和软链接

索引节点(inode):存储文件元数据的区域,每个文件或目录都有唯一的 inode

在硬盘中,扇区(Sector)是最小的物理存储单元,以前是 512 字节,现大部分 4KBmacwindows 中基本都为 4KB),块(block or 簇 Cluster)是逻辑存储单元,通常为 4KB,是扇区的整数倍(0、2048、4096),若扇区 4KB 那一个 4KB 的块对应一个扇区,和扇区的关系类似于 物理像素和逻辑像素

操作块 可以理解为连续操作多个扇区,文件数据都存储在 块 中,文件的元信息如 文件的创建日期、大小、权限等存储的区域称之为 索引节点(inode),每一个文件或目录都有对应的唯一 inode,其包含:文件类型(普通文件、目录)、权限(读、写、执行权限)、文件大小时间戳(创建、修改、访问时间)、指向数据块(block)的指针(文件的存储位置)、链接数(指向该文件的硬链接数)

文件名与 inode 的映射关系存储在 目录项 中,通过文件名访问文件时,会先通过 目录项 获取对应文件的 inode,再通过 inode 访问文件的元数据和数据

文件名(包括后缀)在系统层面上只是目录中的一个目录项,对于文件系统来说,文件名只是一个字符串,后缀影响的是其他程序如何处理该文件,如 txt 文件会被 文本编辑器 打开,exe 文件会被 系统 执行等

  • 4k 对齐:将逻辑块与物理扇区的起始对齐(是扇区大小的整数倍),减少跨扇区操作和冗余的 I/O 操作,优化存储效率和硬盘寿命。
  • 更大的 块 可以提高文件的读写性能,小的 块 可以减少碎片,提高文件的存储密度。
  • 目录文件:在 Linux 中,目录(directory) 也是一种文件,其包含了目录项的列表,每个目录项包含文件名和对应的 inode

硬链接(hard link):同一文件(inode)的多个入口

  • 多个目录项(文件)共享同一个 inode数据块,具有相同 inode 的文件互为硬链接文件(所以硬链接中没有原始文件的概念,或都是原始文件
  • 硬链接不能跨文件系统不能对目录创建硬链接(避免循环引用)
  • 每个文件的初始硬连接数为 1,每个互相硬链接增加 1,只有当所有硬链接文件被删除时,链接树为 0 时,文件的 inode数据块 才会被释放
  • 不额外占用磁盘空间,仅在目录中新增一个条目

硬链接的文件,因为 inode数据块 是共享的,在文件系统中,可以理解为同一个文件,如图中的修改、创建时间等信息

软链接 or 符号链接(symbolic link): 源文件的快捷方式,独立文件,内容为源文件的路径

  • 软链接的文件与源文件是不同的文件
  • 软链接的文件有自己的 inode数据块(存储的源文件路径),其至少占用一个数据块(如常见的 4KB)
  • 软链接可以跨文件系统除了文件,也可以对目录创建软链接
  • 大部分系统中,修改软链接的文件等同于修改源文件,虽然存储是源文件的路径,在访问时会根据路径找到源文件
  • 删除软链接不会影响源文件,但删除源文件后,软链接会变成断链,因为通过路径找不到源文件,所以软链接的文件无法被访问,恢复被删除的源文件后,软链接的文件又可以被访问
# 源文件被删除的场景
cat symbolic-link.js2
# cat: symbolic-link.js2: No such file or directory

软硬链接的文件都可以编辑,互相硬链接的文件可以理解为对应同一个 inode 和 数据块,所以编辑一个等于编辑全部文件

软链接的文件拥有独立的 inode 和 数据块,虽然也可以编辑,但编辑的是源文件的内容,所以其 inode 的相关信息并不会改变

创建软链接和硬链接

ln A B      # 创建硬链接 B
ln -s A C   # 创建软链接 C

获取文件的 inode

ls -i A B C # 获取 A B C 的 inode

# 84913976 hard-link.js   84913976 index.js

st_nlink 为硬链接的数量,默认 1

stat -s A B C # 获取 A B C 的 inode

# st_dev=16777227 st_ino=84913976 st_mode=0100644 st_nlink=2 st_uid=501 st_gid=20 st_rdev=0 st_size=16 st_atime=1747304799 st_mtime=1747304711 st_ctime=1747304798 st_birthtime=1747304697 st_blksize=4096 st_blocks=8 st_flags=0

# st_dev=16777227 st_ino=84913976 st_mode=0100644 st_nlink=2 st_uid=501 st_gid=20 st_rdev=0 st_size=16 st_atime=1747304799 st_mtime=1747304711 st_ctime=1747304798 st_birthtime=1747304697 st_blksize=4096 st_blocks=8 st_flags=0

查看当前文件软链接对应的源文件

# 对应的是软链接的文件名称,不是任何路径
ls -l A B C # 查看 A B C 的链接信息
# lrwxr-xr-x  1 a666  staff  8 May 15 18:47 symbolic-link.jsx -> index.js

# 不含路径
readlink symbolic-link.jsx
# index.js

# 不含路径
file symbolic-link.jsx
# symbolic-link.jsx: broken symbolic link to index.js

如何解决的幽灵依赖?

幽灵依赖(Phantom dependencies):使用了 package.json 中没有声明的依赖

因为 workspacerepo 中的 node_modules 只会安装 package.json 中声明的直接依赖,并不会有其他未知的依赖提升,在 模块寻址 的过程中(👇🏻有说明),只能找到当前 repo 的直接依赖,

如下所示的结构,无论是原生 Node 还是 webpack@latestvite@latest 的寻址规则,你都无法 requireimport lodash,因为项目根目录中的 node_modules 中没有 lodash 模块,但在 react.pnpm/react@18.2.0/node_modules/react)中却可以,因为向上寻址可以找到 lodash这就是 pnpm 的方案

node_modules/
├── .pnpm/
│   ├── react@18.2.0/
│   │   └── node_modules/
│   │       ├── react/             # 实际的 React 包内容
│   │       └── lodash -> ../../lodash@4.17.21/node_modules/lodash/
│   └── lodash@4.17.21/
│       └── node_modules/
│           └── lodash/            # 实际的 lodash 包内容
└── react -> .pnpm/react@18.2.0/node_modules/react/    # 指向 React 的符号链接

npmyarn@1 中依赖默认是 提升扁平化 安装的,pnpm 也可通过下面配置实现:

  • shamefullyHoist: 平铺但是仍会软硬链接全局 store
  • nodeLinker: hoisted:和 npm 策略一致,适合在不支持链接的环境中使用,如 React NativeMetro
# 平铺但有软硬链接
shamefullyHoist: true
# 同样平铺,但不会有软硬链接
nodeLinker: hoisted

存储使用策略

pnpm 在安装依赖时默认使用全局 store 缓存,虽然是通过软硬链接到 node_modules 中,但当你编辑时,并不会直接修改 store 中的内容,这是因为默认情况下 pnpm 根据多个配置对全局 store 进行编辑保护和完整性校验,以确保 store 中的内容是正确的。

相关配置如下(均为 pnpm-workspace.yaml 中,版本:pnpm@10):

- verifyStoreIntegrity(true, boolean):默认情况下,如果存储中文件已经被修改,则在将其链接到项目的 node_modules 之前会检查该文件的内容(integrity)。若设置为 false,则在 install 时跳过检查

该配置可在安装前确保依赖的完整性,防止依赖篡改等问题。

store 中的依赖的 integritypnpm-lock.json 中的 integrity 不一致时重新安装依赖(安装 integrity 不同的依赖),lock 文件不存在时不处理(表现如同 false

- packageImportMethod:控制从存储中(store)导入包的方式

  • auto(默认):从存储中 clone 包,如果不支持 clone 则从存储中硬链接包,若都不支持,则复制。
  • hardlink:从存储中硬链接包
  • clone-or-copy:尝试从存储中 clone 包,如果不支持 clone 则回退到复制
  • copy:从存储中复制包
  • clone:从存储中 clonecopy-on-write)包

该配置控制项目中 node_modules 中的包如何从 store 中导入,默认是 auto(clone),这样修改依赖时不会影响到 store

pnpm-workspace.yaml
packageImportMethod: hardlink verifyStoreIntegrity: false

patch 前的知识 2 - 依赖查找

目前大多数项目中基本都使用 webpackvite 来进行模块的解析,但构建工具们并非完全按照 Node 的模块解析规则来解析模块,如通过 resolvetarget 等来配置模块解析。找到模块后,如何确定模块的入口文件也略有差异

寻址规则

Node 中三方模块的寻址规则:

  1. 确定是否为三方模块:当路径非 相对路径、绝对路径、文件路径、核心模块时,则为外部模块
  2. 遍历 node_modules 目录:从当前文件目录开始,向上逐级查找 node_modules 目录,直到找到根目录(/node_modules),同名模块优先加载离调用者最近的
  3. 入口文件:当在 node_modules 目录中找到对应模块时,先检查 type 字段,若为 module 则优先加载 esm 模块,否则加载 cjs 模块,再根据 exports 字段来确定入口文件,若无 exports 字段,则查找 main 字段,若无 main 字段,最后找 index 文件(后缀根据 type 决定,如 mjscjsjs
  4. 包名/子路径:若 exports 中有相关子路径定义,则以 exports 中的定义为准,否则直接在对应路径中查找入口文件(规则同上)
  5. 查找失败:若以上步骤都无法找到入口文件,则抛出异常 “Cannot find module

require.resolve.paths:返回 Node.js 实际搜索的路径列表

require.resolve.paths("lodash")

// [
//   '当前文件所在目录的 node_modules',
//   '当前文件所在目录的上一级目录的 node_modules',
//   '以此类推,直到根目录',
//   '/node_modules',
// ]

webpack 中三方模块的寻址规则:

默认情况下,未配置 alias 和下面的 resolve 的相关配置,则和 Node 差不多。在解析模块时,webpack 会从 resolve.modules 定义的目录中检索,当找到对应模块时,优先通过 resolve.exportsFields 字段来确定入口文件,若无 exportsFields 字段,则查找 mainFields 字段,若都不存在,则根据 mainFiles 确定文件,最后通过 resolve.extensions 来确定文件后缀

package.json
{ "main": "./lib/index.js", "module": "./es/index.js", "browser": "./dist/index.js", "exports": { ".": { "import": "./es/index.js", "require": "./lib/index.js" }, "./lib": { "import": "./lib/index.js", "require": "./lib/index.js" } } }

模块解析(Module Resolution)

Vite 中的寻址规则: vite@6.3.x 在生产构建时使用 rollup,其依赖 @rollup/plugin-node-resolve 插件解析模块,其默认规则和 Node 一致,所以没有复杂的其他配置

pnpm patch 使用

废话那么多,目的只是为了知道 模块 对应的 入口文件 是哪一个,避免改半天却搞错了对象~

  1. 创建指定 依赖patch 副本:通过 pnpm patch <pkg name>@<version> 命令,在 node_modules/.pnpm_patches/pkgname@version 中生成依赖的副本,可在该文件中修改相关内容(注意,该命令会返回副本的路径,用于后续提交变更)。
  2. 提交 diff 持久化变更:通过 pnpm patch-commit <path>pathpatch 命令返回),执行后将在 workspace 的 根目录 中生成 patches 目录,该目录下会生成 .patch 文件,用于记录变更,diff 格式(在 Jetbrains IDE 中如 webstorm 查看文件如同 git diffvscode 需要对应插件)。

相关链接