【大杂烩】在 pnpm 中直接修改 node_modules(.pnpm) 中的依赖项,项目中持久化 - 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.jsst_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依赖中的幽灵依赖
there is a node_modules folder under the .pnpm folder, What is the purpose of this node_modules
node_modules/
├── .pnpm/
│   ├── node_modules # pnpm 内部平铺的依赖
│   │   ├── lodash/
│   │   ├── antd/
│   │   └── react/
│   ├── react@18.2.0/
│   │   └── node_modules/
│   │       ├── react/
│   │       └── lodash
│   ├── ui-component@latest/ # 一个 UI 组件,其中依赖了 antd 但没有在 package.json 中声明
│   └── lodash@4.17.21
└── react -> .pnpm/react@18.2.0/node_modules/react/umi 框架中如何处理不同的 React Version
Umi内部通过配置不同路径的alias来统一React版本(若项目中包含相关依赖,则优先匹配项目中声明的版本),此方法可以解决React多实例问题,但也会影响依赖的peerDependencies中的声明,如antd的peerDependenciesreact为^18,但项目中的为^19,虽然不会造成多实例问题,但antd可能会出现兼容性的问题
resolveProjectDep:解析当前项目的 package.json 中的依赖是否包含 reactrequire.resolve:从当前文件的目录的 node_module 开始逐级向上解析,最终找到 react 模块的路径
const configDefaults = {
  alias: {
    react:
      resolveProjectDep({
        pkg: api.pkg,
        cwd: api.cwd,
        dep: 'react',
      }) || dirname(require.resolve('react/package.json')),
    ...(isLT18
      ? {
          'react-dom/client': reactDOMPath,
        }
      : {}),
    'react-dom': reactDOMPath,
  },
};存储使用策略
相关配置如下(均为 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格式(在JetbrainsIDE 中如webstorm查看文件如同git diff,vscode需要对应插件)。












