Skip to main content

pnpm 依赖解析机制和 npm/yarn 的区别?

· 5 min read

npm/yarn 与 pnpm 依赖解析的 核心区别

  1. npm/yarn 采用 hoisting 提升机制,纵容幽灵依赖,是设计缺陷
  2. pnpm 基于虚拟存储 + 符号链接,严格禁止传递依赖直接访问,确保依赖树 100% 正确一致
  3. 典型问题:npm 下"正常运行"的代码,pnpm 下会报 Webpack 解析错误

解决方案:直接 pnpm add 添加为显式依赖,一行命令解决。

本质是设计理念的选择:pnpm 把正确性放在第一位,而不是开发便利性。

额外优势:磁盘节省 50%+,安装速度快 2-3 倍,全局硬链接共享同版本包。


从一个真实问题开始

在实现 navbar 主题切换按钮的 tooltip 时,遇到了一个令人困惑的 Webpack 错误:

Module not found: Error: Can't resolve '@docusaurus/theme-common'

令人困惑的点:

  • @docusaurus/theme-classic 已在 package.json
  • @docusaurus/theme-common 是它的依赖,应该已经安装
  • 能在 node_modules/.pnpm/ 目录下找到这个包

核心问题:为什么"已经安装"的包,Webpack 却找不到?

npm/yarn 的工作方式

依赖提升(Hoisting)

npm 和 yarn 采用扁平化策略,将深层依赖提升到 node_modules 根目录:

node_modules/
├── @docusaurus/
│ ├── theme-classic/ # 直接依赖(package.json 中声明)
│ └── theme-common/ # 传递依赖(被提升上来)
└── react/ # 其他传递依赖

副作用(也是问题根源)幽灵依赖(Phantom Dependencies)

你可以在代码中直接 import 那些没有在 package.json 中声明的依赖包。

这不是特性,这是 bug。它让你不知不觉中依赖了未声明的包。

pnpm 的工作方式

虚拟存储 + 符号链接

pnpm 的设计从根本上解决了幽灵依赖问题:

node_modules/
├── .pnpm/ # 虚拟存储目录(所有包实际存储在这里)
│ ├── @docusaurus+theme-classic@*
│ └── @docusaurus+theme-common@*
└── @docusaurus/
└── theme-classic/ # 只有直接依赖的符号链接

关键原则

  • pnpm 只在 node_modules 根目录创建直接依赖的符号链接
  • 传递依赖不会被提升,只能在自己的依赖树中访问
  • Webpack 的常规解析算法找不到未声明的传递依赖

深度对比

1. 磁盘空间与安装速度

维度npm/yarnpnpm
相同包重复存储是(每个项目一份)否(全局硬链接共享)
典型节省基准线50%+
安装速度基准线快 2-3 倍

pnpm 的内容寻址存储让相同版本的包在整个系统中只存一份。

2. 依赖正确性

npm/yarn 的问题

  • 依赖版本漂移:A 包依赖 lodash@4.17.0,B 包依赖 lodash@4.17.21,实际用哪个取决于安装顺序
  • 幽灵依赖泛滥:项目依赖了 100 个包,你可能能直接 import 1000 个

pnpm 的解决方案

  • 严格的依赖隔离,每个包只能访问自己声明的依赖
  • 版本确定性,相同 package.json 永远得到相同依赖树

3. 对 Webpack 构建的影响

Webpack 遵循 Node.js 模块解析算法:

当前目录 → ../node_modules → ../../node_modules → ... → 根目录

在 npm 中:

  • node_modules/@docusaurus/theme-common 存在 → 解析成功 ✓

在 pnpm 中:

  • node_modules/@docusaurus/theme-common 不存在 → 解析失败 ✗
  • 包实际在 node_modules/.pnpm/ 中,但 Webpack 不会去那里找

三种解决方案

方案 A:添加为直接依赖(推荐)

pnpm add @docusaurus/theme-common

为什么这是最佳实践?

  • 显式声明package.json 真实反映了代码的实际依赖
  • 版本可控:不依赖其他包的版本选择
  • 语义清晰:代码 import 什么,项目就依赖什么

方案 B:配置 public-hoist-pattern

对于大型项目或遗留代码,可以在 .npmrc 中配置提升规则:

# 只提升特定包
public-hoist-pattern[]=@docusaurus/theme-common

# 提升所有 @docusaurus 包
public-hoist-pattern[]=@docusaurus/*

# 完全恢复 npm 行为(不推荐,失去 pnpm 的核心优势)
shamefully-hoist=true
这是妥协方案,不是长远之计。仅用于临时兼容。

方案 C:重写代码避免内部依赖

某些场景下,可以改用其他方式避免依赖内部包。例如 Docusaurus 主题组件:

// 不使用:import { useColorMode } from '@docusaurus/theme-common'
// 改为直接读取 DOM 属性
const isDarkMode = document.documentElement.getAttribute('data-theme') === 'dark'

常见疑问

Q:为什么 @theme-original/* 导入能工作?

Docusaurus 的 Webpack 配置了特殊的模块解析别名:

// docusaurus 内部配置
resolve: {
alias: {
'@theme-original': path.resolve(__dirname, '../src/theme'),
// ... 其他别名
}
}

这些别名绕过了常规的 node_modules 解析,因此不受 pnpm 严格模式影响。

Q:pnpm 这样设计的意义是什么?

  1. 没有"在我机器上可以运行"的惊喜 —— 所有人的依赖树 100% 一致
  2. 尽早发现问题 —— 开发时就暴露隐式依赖,而不是上线后
  3. 可预测的升级 —— 改动一个依赖,不会意外影响不相关的代码

总结

特性npm/yarnpnpm
传递依赖可直接导入✅ 是❌ 否
幽灵依赖风险
磁盘空间占用少(50%+ 节省)
安装速度基准线快 2-3 倍
依赖树一致性完美
设计哲学方便优先正确优先

核心建议:遇到 Module not found 错误时,首先检查它是否是传递依赖。如果是,直接 pnpm add 添加到 package.json —— 这是最简单、最正确的解决方案。


参考资料

  1. pnpm 官方博客 - Flat node_modules is not the only way
  2. Webpack 模块解析文档
  3. Docusaurus Issue #6009 - pnpm support