【大杂烩】在 pnpm 中直接修改 node_modules(.pnpm) 中的依赖项,项目中持久化 - 由 pnpm patch 引出的关于 pnpm 使用的一些知识
TL;DR:
pnpm patch 的使用场景
patch 前的知识 1 - pnpm 的依赖管理
三层寻址、缓存、isolate(依赖隔离)
# 获取全局 Store 的路径
pnpm store path
如图,在
pnpm
中,依赖不会全部提升并平铺在node_modules
中,仅存储当前项目的直接依赖
(隔离,不提升),这将有效防止出现幽灵依赖
和分身问题
硬链接和软链接
索引节点(inode):存储文件元数据的区域,每个文件或目录都有唯一的 inode
- 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
创建软链接和硬链接
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
中没有声明的依赖
如下所示的结构,无论是原生 Node
还是 webpack@latest
或 vite@latest
的寻址规则,你都无法 require
或 import
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 的符号链接
npm
和yarn@1
中依赖默认是提升
且扁平化
安装的,pnpm
也可通过下面配置实现:
shamefullyHoist
: 平铺但是仍会软硬链接全局store
nodeLinker: hoisted
:和npm
策略一致,适合在不支持链接的环境中使用,如React Native
(Metro)
# 平铺但有软硬链接
shamefullyHoist: true
# 同样平铺,但不会有软硬链接
nodeLinker: hoisted
存储使用策略
相关配置如下(均为 pnpm-workspace.yaml
中,版本:pnpm@10
):
- verifyStoreIntegrity(true, boolean):默认情况下,如果存储中文件已经被修改,则在将其链接
到项目的 node_modules
之前会检查该文件的内容(integrity
)。若设置为 false
,则在 install
时跳过检查
该配置可在安装前确保依赖的完整性,防止依赖篡改等问题。
当
store
中的依赖的integrity
和pnpm-lock.json
中的integrity
不一致时重新安装依赖(安装integrity
不同的依赖),lock
文件不存在时不处理(表现如同false
)
- packageImportMethod:控制从存储中(store
)导入包的方式
auto
(默认):从存储中clone
包,如果不支持clone
则从存储中硬链接包,若都不支持,则复制。hardlink
:从存储中硬链接包clone-or-copy
:尝试从存储中clone
包,如果不支持clone
则回退到复制copy
:从存储中复制包clone
:从存储中clone
(copy-on-write
)包
该配置控制项目中
node_modules
中的包如何从store
中导入,默认是auto
(clone
),这样修改依赖时不会影响到store
pnpm-workspace.yamlpackageImportMethod: hardlink verifyStoreIntegrity: false
patch 前的知识 2 - 依赖查找
目前大多数项目中基本都使用 webpack
或 vite
来进行模块的解析,但构建工具们并非完全按照 Node
的模块解析规则来解析模块,如通过 resolve
、target
等来配置模块解析。找到模块后,如何确定模块的入口文件也略有差异
寻址规则
Node
中三方模块的寻址规则:
- 确定是否为三方模块:当路径非 相对路径、绝对路径、文件路径、核心模块时,则为外部模块
- 遍历
node_modules
目录:从当前文件目录开始,向上逐级查找node_modules
目录,直到找到根目录(/node_modules
),同名模块优先加载离调用者最近的 - 入口文件:当在
node_modules
目录中找到对应模块时,先检查type
字段,若为module
则优先加载esm
模块,否则加载cjs
模块,再根据exports
字段来确定入口文件,若无exports
字段,则查找main
字段,若无main
字段,最后找index
文件(后缀根据type
决定,如mjs
、cjs
、js
) - 包名/子路径:若
exports
中有相关子路径定义,则以exports
中的定义为准,否则直接在对应路径中查找入口文件(规则同上) - 查找失败:若以上步骤都无法找到入口文件,则抛出异常 “
Cannot find module
“
require.resolve.paths
:返回 Node.js
实际搜索的路径列表
require.resolve.paths("lodash")
// [
// '当前文件所在目录的 node_modules',
// '当前文件所在目录的上一级目录的 node_modules',
// '以此类推,直到根目录',
// '/node_modules',
// ]
- Node resolution algorithm:https://nodejs.org/api/modules.html#modules_all_together
webpack
中三方模块的寻址规则:
- resolve.exportsFields(exports, string):定义默认的 条件导出 or 情景导出 or 多入口导出 字段(默认为
exports
字段,如下) - resolve.modules(node_modules, string):告诉
webpack
解析模块时应该搜索的目录,类似Node
- resolve.mainFields:根据
config.target
的值来决定默认值,当target
为web
(默认)时,会按顺序寻找package.json
中的browser
、module
、main
字段,当target
为其他
时,会按顺序寻找package.json
中的module
、main
字段 - resolve.mainFiles(index, string):解析目录时使用的文件名,如
lodash/get
则寻找当前目录下的index
文件 - 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" } } }
Vite
中的寻址规则:vite@6.3.x
在生产构建时使用 rollup,其依赖 @rollup/plugin-node-resolve 插件解析模块,其默认规则和Node
一致,所以没有复杂的其他配置
pnpm patch 使用
废话那么多,目的只是为了知道 模块
对应的 入口文件
是哪一个,避免改半天却搞错了对象~
- 创建指定
依赖
的patch
副本:通过pnpm patch <pkg name>@<version>
命令,在node_modules/.pnpm_patches/pkgname@version
中生成依赖的副本,可在该文件中修改相关内容(注意,该命令会返回副本的路径,用于后续提交变更)。 - 提交
diff
持久化变更:通过pnpm patch-commit <path>
(path
为patch
命令返回),执行后将在workspace
的 根目录 中生成patches
目录,该目录下会生成.patch
文件,用于记录变更,diff
格式(在Jetbrains
IDE 中如webstorm
查看文件如同git diff
,vscode
需要对应插件)。