如果你在项目里见过 publicHoistPattern 这个字段,说明你开始摸到 pnpm 相对深水区的一层配置了。大多数项目用 pnpm 是被安装速度和磁盘占用吸引过来的,但对 pnpm 这种符号链接式 node_modules 带来的“严格依赖”到底什么时候会咬人,通常要等构建环境跑出一连串 MODULE_NOT_FOUND 之后才想起来查资料。
public-hoist-pattern 解决的正是这个具体问题:让哪些匹配模式的依赖被提升到 node_modules 根部,以便那些没有老老实实声明依赖的工具链能正常加载所需模块。这篇文章我会从 pnpm 的依赖解析原理开始,把 hoist-pattern、public-hoist-pattern、shamefully-hoist 这几组容易搞混的参数讲透,再给出完整配置写法、生效验证方法,以及我在实际项目中遇到的两类典型报错和排查过程。不管你是刚把老项目从 npm 迁到 pnpm,还是在 monorepo 里被工具链折腾到怀疑人生,这篇内容应该都能直接用上。
1. 项目背景:pnpm 的严格依赖与提升模式
1.1 为什么安装完 pnpm 会出现奇怪的 MODULE_NOT_FOUND
先说结论:pnpm 默认把依赖装进 node_modules/.pnpm 这个特殊目录,再用符号链接把每个直接依赖暴露到项目根。一个包能访问哪些依赖,是被严格限制住的。这个机制的好处是干净、可信、节省磁盘;坏处是,总有工具没有按规范声明依赖,运行时满世界找它期望的那个包,结果在 pnpm 布局里找不到,直接报“模块不存在”。
public-hoist-pattern 就是为这种现实场景留的一扇窗。配置匹配模式后,pnpm 会把匹配的包额外符号链接到 node_modules 根目录。这样一来,任何在根目录附近遍历依赖的工具,都能像在 npm 平铺布局里一样找到这些包。
为什么要用“模式”而不是“全部提升”?因为全部提升等于放弃 pnpm 的核心隔离优势,回到 npm 的“大锅饭”状态。pnpm 团队希望开发者只对特定工具链开一个口子,其他依赖继续严格隔离。这个平衡点,就是 public-hoist-pattern 存在的意义。
1.2 pnpm 的 node_modules 目录结构到底长什么样
要理解提升配置,得先知道 pnpm 的 node_modules 和 npm 有什么不同。一个安装了 express 和 lodash 的项目,pnpm 安装完后的目录大概是这样:
text复制node_modules/
├── .pnpm/
│ ├── express@4.18.2/
│ │ └── node_modules/
│ │ ├── express/
│ │ ├── accepts@1.3.8 -> ../../accepts@1.3.8/node_modules/accepts
│ │ ├── body-parser@1.20.1 -> ../../body-parser@1.20.1/node_modules/body-parser
│ │ └── ...
│ └── lodash@4.17.21/
│ └── node_modules/
│ └── lodash
├── express -> .pnpm/express@4.18.2/node_modules/express
└── lodash -> .pnpm/lodash@4.17.21/node_modules/lodash
每个直接依赖在 node_modules 根目录里只是一个符号链接,指向 .pnpm 下带版本号的真实目录。真实目录内部又通过符号链接引用它自己的依赖。这样,express 的代码运行时只会看到它自己声明过的依赖,不会误用项目里其他无关包。
这个设计有一个重要推论:如果你的代码直接 import('lodash'),但 package.json 里没有声明 lodash,那么在 pnpm 默认布局下会直接报错。npm 时代因为依赖全部平铺,这种未声明依赖能“碰巧”被找到。pnpm 把这种“碰巧”堵住了,暴露了很多项目里隐藏的依赖声明问题。
1.3 从幽灵依赖看 pnpm 的取舍
“幽灵依赖”(phantom dependency)指的是代码使用了未在 package.json 中声明、但因为依赖提升而实际存在于 node_modules 中的包。npm 和旧版 yarn 的提升机制,会把所有传递依赖尽量平铺到根目录。结果就是,package.json 里没写的依赖也能 import 成功,项目一多,谁依赖谁都成了一笔糊涂账。
pnpm 选择用符号链接做严格隔离,从根上解决了幽灵依赖问题,但也带来两个副作用。第一,开发体验变“硬”了:以前能跑的老项目迁过来,可能到处报模块找不到。第二,部分工具链设计时默认“所有依赖都在根目录”,它们内部会动态加载未声明依赖,在 pnpm 严格布局下就失灵了。public-hoist-pattern 就是为第二个副作用准备的补救机制。
我在实践里的体会是:不要因为一两个报错就急着把 shamefully-hoist 打开。先分析报错来自哪一层依赖,再针对性提升那一类包,这样既解决问题,又不至于把隔离优势废掉。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心参数拆解:public-hoist-pattern 和它的“邻居们”
2.1 容易混淆的三组配置:hoist-pattern、public-hoist-pattern、shamefully-hoist
pnpm 文档里有几个关键词长得太像了,第一次接触很容易看晕。我用一张表把它们的边界划清楚:
| 配置项 | 默认值 | 作用位置 | 效果 |
|---|---|---|---|
hoist-pattern |
* |
node_modules/.pnpm/node_modules |
控制哪些传递依赖被提升到 .pnpm 内部的公共目录,方便包之间共享解析 |
public-hoist-pattern |
['*eslint*', '*prettier*'] |
node_modules 根目录 |
把匹配的包提升到项目根目录,变成所有代码都能直接访问的“公共资源” |
shamefully-hoist |
false |
node_modules 根目录 |
完全模拟 npm 的平铺结构,所有依赖都暴露在根目录 |
hoist-pattern 和 public-hoist-pattern 最容易被搞混。前者管的是 .pnpm 内部的布局,默认 *,意思是所有依赖都会在 .pnpm/node_modules 里被提升,这是为了减少重复符号链接、加快解析速度做的优化。后者管的是根目录可见性,默认只提升 eslint 和 prettier 相关的包。
shamefully-hoist 是历史遗留方案,意图很简单:让 pnpm 的目录布局和 npm 一样,所有依赖全部平铺到根目录。配置它确实能解决绝大多数兼容性问题,但副作用也最大——你等于把 pnpm 的隔离优势全扔了。public-hoist-pattern 提供的是同样的思路,但更可控、更精确。
2.2 默认值为什么是 eslint 和 prettier
pnpm 默认的 public-hoist-pattern 是 ['*eslint*', '*prettier*'],这不是拍脑袋决定的。ESLint 和 Prettier 的插件生态非常依赖“从自身位置向上查找主包”的解析方式。比如 eslint 插件在运行时经常会执行 require('eslint') 或者读取 eslint/package.json,如果 eslint 不在插件能找到的祖先目录里,插件直接报错。
这类工具属于“主动去外面找依赖”的类型,和普通包“只使用自己声明依赖”的行为完全不同。pnpm 把 eslint、prettier 相关模式默认提升到根目录,就是为了保留工具链生态的正常工作方式。你可以把默认配置理解为 pnpm 官方对所有用户的生态兼容承诺。
设置成模式匹配而不是具体包名,还有一个好处:无论是 eslint、eslint-plugin-import、@typescript-eslint/parser,还是 prettier-plugin-tailwindcss,只要包名里带 eslint 或 prettier,都会被自动提升。这样可以覆盖插件生态里大量未严格声明依赖的场景。
2.3 node-linker 对提升行为的影响
public-hoist-pattern 并不是在所有情况下都生效。pnpm 有一个更底层的参数叫 node-linker,它决定整体依赖布局策略,取值有三个:isolated、hoisted、pnp。
默认是 isolated,也就是前面说的符号链接严格隔离布局。public-hoist-pattern 只在 isolated 模式下有意义。如果你把 node-linker 设为 hoisted,pnpm 会采用 npm 风格的平铺布局,所有依赖都直接放在根目录,这时候再配置 public-hoist-pattern 就没多少实际影响了,因为所有依赖本来就在根目录。pnp 模式则更激进,依赖会被打包进 zip 文件,不生成 node_modules 目录,public-hoist-pattern 同样失效。
所以排查提升相关问题时,第一件事就是确认自己的 node-linker 是什么。很多人改了 public-hoist-pattern 没效果,结果发现项目里早就因为其他兼容问题设置了 node-linker=hoisted,两个配置互相覆盖,白忙活半天。
3. 配置实操:写入位置、语法与生效验证
3.1 三种配置写入方式,按项目情况选
public-hoist-pattern 可以通过三种位置写入,分别适合不同项目形态。
第一种是单包项目直接在 package.json 里加 pnpm 字段:
json复制{
"pnpm": {
"publicHoistPattern": ["*eslint*", "*prettier*", "@types/*", "ts-node"]
}
}
第二种是 monorepo 或者想把配置集中管理的项目,写入 pnpm-workspace.yaml:
yaml复制pnpm:
publicHoistPattern:
- "*eslint*"
- "*prettier*"
- "@types/*"
第三种是使用 .npmrc 文件,适合只想在命令行或本地环境层面快速生效的场景:
ini复制public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*prettier*
public-hoist-pattern[]=@types/*
.npmrc 里使用的是连字符写法 public-hoist-pattern,并且用 [] 表示数组项。这里有两个容易踩的坑:一是数组不能只写一行 public-hoist-pattern=*eslint*,否则可能不会被当作数组处理;二是如果通过覆盖方式配置,你要注意默认值也会被覆盖,想保留 eslint 和 prettier 的提升,就得把默认模式也一起写进去。
3.2 配置生效与锁文件联动
改完配置不代表立刻生效。public-hoist-pattern 改变的是链接布局,不是依赖集合,所以需要重新执行安装来重建 node_modules 链接。我习惯先删掉 node_modules 再安装,避免增量安装时链接更新不彻底:
bash复制rm -rf node_modules
pnpm install
如果不想删目录,也可以执行 pnpm install --force,强制重新链接。安装完成后,你可以直接看根目录下是否出现对应的符号链接:
bash复制ls -la node_modules | grep eslint
能看到 eslint -> .pnpm/... 这样的链接,说明配置已经起作用。
关于 pnpm-lock.yaml,要注意一点:修改提升模式后重新安装,锁文件可能会产生 diff,比如某些包的 integrity 或 snapshot 信息发生更新。这是正常现象,直接把新锁文件提交即可。有一点可以放心,public-hoist-pattern 不会改变真正安装的依赖版本集合,所以不会因为配置变更导致依赖版本漂移。
3.3 常见场景的配置模板
不同技术栈需要提升的模式不完全一样。我把自己在几种项目里的实际配置整理出来,供参考。
TypeScript + ts-node 的项目,经常需要提升的类型包和运行时工具:
yaml复制pnpm:
publicHoistPattern:
- "*eslint*"
- "*prettier*"
- "@types/*"
- "ts-node"
- "tsconfig-paths"
Vite + 插件生态的项目,插件解析问题比较突出:
yaml复制pnpm:
publicHoistPattern:
- "*eslint*"
- "*prettier*"
- "*vite*"
- "rollup"
Electron 或者原生模块项目,构建脚本对 node-gyp 等工具的需求比较特殊:
yaml复制pnpm:
publicHoistPattern:
- "*eslint*"
- "*prettier*"
- "node-gyp"
- "node-pre-gyp"
模板的意义是给你一个相对稳妥的起点,不是让你照抄。如果某一类包没出问题,就不要乱加,提升范围越大,幽灵依赖的回旋镖风险越高。
4. 实战排错:两类典型报错的处理过程
4.1 报错一:包明明装了,eslint 插件还是找不到模块
我之前接手过一个老项目,从 npm 迁到 pnpm 后,执行 eslint . 一直报错,信息大概是:
text复制Error: Failed to load plugin 'import' declared in '.eslintrc.cjs':
Cannot find module 'eslint'
第一反应是 eslint 没装,但检查 package.json 发现 eslint 明明在 devDependencies 里,node_modules 里也能看到符号链接。问题出在 eslint-plugin-import 内部会执行 require('eslint'),在 pnpm 严格布局下,插件位于 .pnpm/eslint-plugin-import@x.x.x/node_modules/ 里,向上找 node_modules 找不到 eslint,于是直接抛错。
排查时可以用 pnpm 的依赖查询命令,确认插件和 eslint 的真实位置:
bash复制pnpm why eslint
pnpm why eslint-plugin-import
pnpm why 会显示依赖关系树。然后查看 node_modules 根目录里有没有被提升的 eslint:
bash复制ls -la node_modules | grep eslint
如果根目录里没有 eslint,说明默认的提升模式没覆盖到当前场景。虽然默认模式包含 *eslint*,但如果你之前手动设置过 public-hoist-pattern 并覆盖了默认值,eslint 就不会被提升。解决方案就是把 eslint 模式重新写进配置,然后删掉 node_modules 重装。
这个问题非常典型,它让我意识到一个关键点:public-hoist-pattern 数组是“整体覆盖”语义,不是“追加”语义。你自定义配置时如果没有带上默认的 eslint 和 prettier,就会连默认兼容性一起丢掉。
4.2 报错二:构建工具内部加载依赖失败
第二种常见情况是 Vite、Rollup、Webpack 这类构建工具,它内部会根据插件名动态加载模块。比如一个 Vite 插件,自身依赖里没有声明 vite,运行时却会执行 require('vite'),这在 pnpm 隔离布局下就会失败。
报错通常长这样:
text复制Error: Cannot find module 'rollup'
Require stack:
- /path/to/project/node_modules/.pnpm/@vitejs+plugin-vue@x.x.x/node_modules/@vitejs/plugin-vue/dist/index.js
看到报错堆栈里出现 .pnpm 路径时,基本可以判断是提升问题。我的处理步骤是:先看报错模块属于哪一层,再用 pnpm why rollup 确认它是不是某工具的传递依赖,最后把对应模式加入 public-hoist-pattern 并重装。
这个案例里,解决方式可以是:
yaml复制pnpm:
publicHoistPattern:
- "*eslint*"
- "*prettier*"
- "*rollup*"
这里我还想强调一个原则:能定位到具体包名,就尽量精确匹配,别图省事写成 *。写成 * 等于把 public-hoist-pattern 变成了 shamefully-hoist,长期来看会重新引入幽灵依赖问题。我见过一个项目为了省事把所有依赖都提升,结果生产环境跑起来后,一个未声明的包被误删依赖而崩掉,排查过程极其痛苦。
4.3 排查路径与调试命令汇总
排查提升相关问题时,我一般按这条路径走:先看报错堆栈里有没有 .pnpm 路径,有的话基本就是隔离布局问题;然后查目标包是否已在 node_modules 根目录;接着确认当前配置里 public-hoist-pattern 和 node-linker 的值;最后再决定是改配置还是用 pnpm overrides 补齐缺失依赖。
常用的调试命令可以汇总成下表:
| 命令 | 用途 |
|---|---|
pnpm config list |
查看当前生效的 pnpm 配置,包括 node-linker、public-hoist-pattern |
pnpm config get public-hoist-pattern |
只看 public-hoist-pattern 的值 |
pnpm why <包名> |
查这个包为什么会出现在依赖树里,是谁依赖了它 |
ls -la node_modules |
查看根目录下实际存在的符号链接 |
pnpm install --force |
强制重建链接,配置变更后常用 |
这套组合拳能覆盖绝大多数依赖可见性问题的定位。掌握了它,你就不需要靠删除 node_modules 反复试错来碰运气。
5. 安装与环境高频问题速查
5.1 pnpm 命令识别不了,先查这三样
不少人在新环境里第一次用 pnpm,会遇到类似 pnpm: 无法将“pnpm”项识别为 cmdlet、函数、脚本文件或可运行程序的名称 或者 pnpm 不是内部或外部命令 的报错。这通常不是 pnpm 本身坏了,而是安装路径没被正确加入环境变量。
我的排查顺序是:先确认 pnpm 是否真的装了,用命令直接看版本号,如果提示找不到命令,再检查安装方式。Node.js 环境里最干净的方式是用 corepack,Node 自带这个工具,执行 corepack enable pnpm 就能启用。或者用 npm 全局安装 npm install -g pnpm,安装完成后用 where pnpm 或 pnpm --version 验证路径。
Windows 上如果是 PowerShell 报执行策略问题,可以运行:
powershell复制Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
然后重新打开终端再试。这个操作只影响当前用户,不会动系统全局策略,相对安全。
如果确实想彻底清理 pnpm,用你原来安装它的方式卸载即可,比如 npm uninstall -g pnpm。卸载后记得检查 node_modules/.pnpm 这类临时目录是否需要手动清理,特别是全局缓存,可以执行 pnpm store prune 来回收磁盘空间。
5.2 下载慢或安装超时,镜像和超时参数怎么配
pnpm 默认从官方 registry 拉取包,国内网络环境下载经常很慢。解决思路和 npm 基本一致,改 registry 镜像地址就行。全局配置可以执行:
bash复制pnpm config set registry https://registry.npmmirror.com --location=global
只给某个项目配,就在项目根目录的 .npmrc 里写:
ini复制registry=https://registry.npmmirror.com
安装超时的问题,需要理解 pnpm 的网络参数体系。fetch-timeout 控制单次下载的超时时间,默认是 60000 毫秒;fetch-retries 控制重试次数。如果网络不稳定,可以在 .npmrc 里调大:
ini复制fetch-timeout=120000
fetch-retries=5
我建议先改镜像地址,再改超时参数。镜像通常能解决绝大多数下载慢问题,没必要一上来就盲目调大超时。镜像源也可能出现短暂不同步,遇到某包拉不下来时,先 pnpm install 重试一次,再考虑换回官方源。
5.3 pnpm 脚本被拦截的另一层配置
还有一个和提升问题相邻、但经常被混在一起的现象:pnpm v10 默认会拦截依赖包的生命周期脚本,安装时提示:
text复制Ignored build scripts: esbuild, @swc/core.
Run "pnpm approve-builds" to pick which dependencies
should be allowed to run build scripts.
这是 pnpm 出于安全考虑做的限制,和 public-hoist-pattern 完全不是一回事。public-hoist-pattern 管的是依赖的“可见性”,这里管的是依赖的“执行权限”。esbuild、swc、sharp 这类包含原生二进制构建步骤的包,如果构建脚本被拦截,运行时会报错。遇到这种情况,你需要根据 pnpm 的提示执行 pnpm approve-builds,选择允许哪些包执行构建脚本。
我把这两类问题区分开,是因为经常看到有人把构建失败误判成提升问题,改了半天 public-hoist-pattern 也没用。先看报错发生在安装阶段还是运行阶段:安装阶段报错,优先查脚本拦截和镜像问题;运行阶段报错,再查依赖可见性问题。
最后再分享一个小技巧
配置完 public-hoist-pattern 之后,建议提交一份注释清晰的 .npmrc 或 pnpm-workspace.yaml 到仓库里,并在注释里写明“为什么需要提升这些包”。我见过太多项目,配置里有一长串提升模式,但没人知道当初是为了哪个报错加的。等到某天升级依赖后问题消失,也不敢删,只能一直堆着。给配置写注释,看起来是小事,但对长期维护帮助非常大。
我在项目中实操后的体会是:public-hoist-pattern 是一个功能明确但容易被滥用的配置。它存在的意义是兼容现实中不完美的工具链,默认的 eslint 和 prettier 模式已经覆盖了大多数常规场景。遇到新的 MODULE_NOT_FOUND,先别急着放大提升范围,花十分钟用 pnpm why 定位依赖关系,再精确加模式,永远比一刀切地把所有依赖都提升到根目录来得稳妥。把严格依赖的底线守住,pnpm 带来的项目可维护性收益才能持续发挥出来。
