为嘛你的前端项目用npm可以运行, pnpm会崩

1,158 阅读5分钟

pnpm 以其高效的磁盘空间利用和快速的安装速度,在前端社区中越来越受欢迎。然而,从 npmyarn 迁移到 pnpm 的过程中,开发者有时会遇到一些“水土不服”的依赖问题。最近,我就遇到了一个典型案例:一个使用 npm 安装运行正常的项目,在切换到 pnpm 后,即使通过 pnpm import 转换了 lock 文件,启动时依然报错。

错误信息直指 is-core-module

Error: Your application tried to access is-core-module, but it isn't declared in your dependencies; this makes the require call ambiguous and unsound.
Required package: is-core-module
Required by: E:\project\xxkg_3dweb_rebuild\node_modules\.pnpm\resolve@1.22.10\node_modules\resolve\lib\
...
... .pnp.cjs:10676
      Error.captureStackTrace(firstError);
            ^

这个错误明确告诉我们,resolve 包(版本 1.22.10)尝试访问 is-core-module,但 pnpm 的机制认为这次访问不合法。有趣的是,查看 package.json,我们发现 is-core-module 确实是项目的直接依赖项,版本为 2.16.1,并且 resolve@1.22.10 本身在其 package.json 中也声明了对 is-core-module@"^2.9.0" 的依赖。那问题出在哪里呢?

npm vs pnpm:依赖管理的“楚河汉界”

要理解这个问题,首先需要明白 npmpnpm 在依赖管理上的核心区别:

  1. npm/yarn classic 的“扁平化”与“幽灵依赖”: npm (v3+ ) 和 yarn classic 会尽力将所有依赖(包括子依赖的子依赖)提升 (hoist) 到 node_modules 的顶层。这带来了便利,但也可能导致“幽灵依赖” (phantom dependencies) —— 即项目代码可以访问到那些没有在 package.json 中明确声明,但被其他依赖项引入并提升到顶层的包。在我们的案例中,npm 之所以能正常运行,很可能是因为 is-core-module 被某个依赖(可能是 resolve 自身,也可能是其他包)提升了,使得 resolve 包能够“碰巧”访问到它。

  2. pnpm 的“严格模式”与符号链接: pnpm 则采用了更为严格的策略。它通过符号链接 (symlinks) 的方式创建 node_modules 结构。每个包只能直接访问其在 package.json 中明确声明的依赖。这种方式避免了幽灵依赖,保证了依赖结构的纯净和可预测性。

  3. .pnp.cjs 文件与 Plug'n'Play (PnP): 错误堆栈中出现的 .pnp.cjs 文件是一个关键线索。这个文件是 pnpm (当 node-linker=pnp 时) 或 Yarn Berry 的 Plug'n'Play (PnP) 特性所使用的。PnP 是一种比 pnpm 默认的 isolated 链接器更为严格的依赖管理策略,它不依赖传统的 node_modules 文件夹结构,而是通过一个 JavaScript 文件 (.pnp.cjs) 来告诉 Node.js 如何查找和加载模块。PnP 对依赖的声明和访问有着极其严格的规定。如果 resolve 包试图以 PnP 机制不支持的方式加载 is-core-module,即使它们之间存在声明的依赖关系,也可能失败。

错误根源:resolve 包与 pnpm (PnP) 的“代沟”

resolve@1.22.10在其内部尝试 require('is-core-module')。虽然它在其 package.json 中声明了对 is-core-module 的依赖,并且我们的项目也直接安装了 is-core-module,但 pnpm 的 PnP 机制在解析这个 require 调用时,由于其严格的链接规则,可能认为这次访问不符合 PnP 的预期,从而抛出错误。

简单来说,pnpm import 忠实地转换了依赖版本,但它无法解决包本身与 pnpm 更严格的依赖解析策略 (尤其是 PnP) 之间的潜在兼容性问题。

解决方案:让 pnpm “放宽”一点

既然问题出在 pnpm 的严格性上,特别是 .pnp.cjs 暗示的 PnP 模式,我们可以从调整 pnpm 的行为入手。

方案一:修改 node-linker 设置 (推荐)

PnP 模式虽然在某些场景下性能优异,但对生态中部分包的兼容性可能不够友好。我们可以将 pnpm 的 node-linker 调整为更通用的模式。

  1. 在项目根目录下创建或编辑 .npmrc 文件。
  2. 添加以下配置,优先尝试 isolated(pnpm 默认且推荐的非 PnP 严格模式):
    node-linker=isolated
    
    如果 isolated 仍然存在问题(理论上此场景下应该能解决,因为 PnP 更严格),可以尝试 hoisted,它会创建更扁平的 node_modules 结构,更接近 npm/yarn 的行为:
    node-linker=hoisted
    
  3. 彻底清理环境:删除 node_modules 文件夹、pnpm-lock.yaml 文件以及 .pnp.cjs 文件。
    rm -rf node_modules pnpm-lock.yaml .pnp.cjs
    
  4. 重新安装依赖:
    pnpm install
    
  5. 再次尝试运行项目。

方案二:使用 public-hoist-pattern (当希望保留 PnP 或 isolated 模式时)

如果你希望继续使用 node-linker=pnp 或默认的 isolated 模式,但想针对性解决特定包的访问问题,可以使用 public-hoist-pattern。这会告诉 pnpm 将匹配的包提升到 node_modules 的根级别,使其更容易被其他包访问。

  1. .npmrc 文件中添加:
    public-hoist-pattern[]="*is-core-module*"
    # public-hoist-pattern[]="*resolve*" # 通常只需要提升有问题的子依赖
    
  2. 同样,清理环境并重新安装。

方案三:使用 .pnpmfile.cjs (PnP 模式下的高级修复)

如果坚持使用 PnP 且上述方法无效,.pnpmfile.cjs 允许你通过钩子函数修改包的元数据,以影响 pnpm 的解析行为。例如,可以尝试在 resolve 包的元数据中更显式地声明对 is-core-module 的依赖关系,以适应 PnP 的解析。但这通常较为复杂,是最后的手段。

总结

npm 迁移到 pnpm 带来的性能和管理优势是显著的,但其严格的依赖管理策略,尤其是 PnP 模式,有时会暴露一些在 npm 环境下被“隐藏”的依赖问题。遇到类似 is-core-module 的访问错误时,首先应理解 pnpm 的工作原理,检查 .npmrc 配置,并尝试调整 node-linker。通常情况下,将 node-linker 设置为 isolatedhoisted 能解决大部分兼容性问题。

通过这些调整,我们可以更好地驾驭 pnpm,享受其带来的便利,同时确保项目的稳定运行。希望这篇分析能帮助遇到类似问题的开发者快速定位并解决问题。