跳到主要内容
预计阅读 38 分钟

工程化篇 | Yarn与PNPM

前言

npm、Yarn、PNPM——前端的包管理器已经从”只有 npm 一家”变成了”三足鼎立”。很多人装包的时候随手用 npm install,也有人因为”Yarn 比较快”就换了 Yarn,还有人听说”PNPM 省磁盘”就跟风切换。

但面试中被问到包管理器的选型,很多人就只能说出”Yarn 快一点”、“PNPM 省空间”这种表面的区别。面试官继续追问:

  • npm 的”幽灵依赖”是什么?为什么它是个问题?
  • Yarn Berry 的 PnP(Plug’n’Play)方案到底改了什么?
  • PNPM 的”硬链接 + 符号链接”到底怎么工作的?
  • lockfile 到底锁的是什么?不同工具的 lockfile 有什么区别?

就说不清楚了。

本章,我们从 npm 的历史问题讲起,再分别拆解 Yarn(Classic 和 Berry)和 PNPM 的设计方案,最后做一个全面的横向对比。读完之后,你不仅能在面试中说清楚三者的区别,还能在实际项目中做出合理的技术选型。


诊断自测

Q1:什么是”幽灵依赖(Phantom Dependencies)“?请举一个具体的例子。

点击查看答案

幽灵依赖是指:你的项目代码中可以 requireimport 一个自己没有在 package.json 中声明的包,仅仅因为它是某个直接依赖的子依赖,且被 npm 提升(hoist)到了 node_modules 顶层。

例如:你的项目依赖了 A,A 又依赖了 B。npm 安装时把 B 提升到了 node_modules 根目录。你在代码里直接 import B from 'B' 是能正常工作的——但 B 并不在你的 package.json 里。一旦 A 升级后不再依赖 B,或者 B 的版本变了,你的代码就会莫名其妙地报错。

Q2:PNPM 是怎么解决幽灵依赖问题的?

点击查看答案

PNPM 的 node_modules 结构和 npm/Yarn Classic 完全不同。它不做提升(hoist),而是使用**符号链接(symlinks)**来构建正确的依赖关系:

  • node_modules 顶层只有你在 package.json 里直接声明的依赖(通过 symlinks 指向 .pnpm 目录)
  • 子依赖不会出现在顶层,只在 .pnpm 目录的对应位置
  • 这样你就无法 import 没有声明的包——因为它根本不在可访问的 node_modules 路径上

Q3:lockfile(如 package-lock.json、yarn.lock)到底”锁”的是什么?

点击查看答案

lockfile 锁定的是每个依赖包的精确版本号和下载地址(包括所有直接依赖和间接依赖)。package.json 中的版本号通常是范围(如 ^1.2.3),每次 npm install 可能解析出不同的具体版本。lockfile 确保团队所有成员和 CI 环境安装到完全一致的依赖树,消除”在我机器上是好的”这类问题。


一、npm 的历史问题

要理解 Yarn 和 PNPM 为什么存在,先得看看 npm 踩过哪些坑。

1.1 嵌套地狱(npm v2 及之前)

npm v2 以前,依赖是严格嵌套的:

node_modules/
├── A/
│   └── node_modules/
│       └── C@1.0/
└── B/
    └── node_modules/
        └── C@1.0/       ← 和上面是同一个包,但装了两份

如果 A 和 B 都依赖了 C@1.0,C 会被安装两份。更糟糕的是,如果依赖层级很深,Windows 上会因为路径过长而安装失败(Windows 路径最大 260 个字符)。

这就是”嵌套地狱”——依赖树深度爆炸、磁盘占用暴增、路径过长。

1.2 扁平化与幽灵依赖(npm v3+)

npm v3 引入了**扁平化(hoisting)**策略:把子依赖尽可能提升到 node_modules 根目录。

node_modules/
├── A/
├── B/
└── C@1.0/        ← 被提升到顶层,A 和 B 共享

这解决了嵌套和重复安装的问题,但引入了新问题——幽灵依赖(Phantom Dependencies)

// 你的 package.json 只声明了 A 和 B
// 但因为 C 被提升了,你可以直接 import 它
import something from 'C'; // ← 能用,但不安全!

C 不在你的 package.json 里,你对它的版本没有任何控制权。今天它是 1.0,明天 A 升级后它可能变成 2.0,甚至直接消失。

1.3 不确定性(Non-deterministic)

npm v5 之前没有 lockfile,每次 npm install 可能解析出不同的依赖版本。这意味着你的同事和你装出来的 node_modules 可能不一样——“在我电脑上是好的”成了日常。

npm v5 引入了 package-lock.json,但此时 Yarn 已经先一步解决了这个问题。

1.4 安装速度慢

npm 的早期版本是串行安装的——一个包装完再装下一个。加上 npm registry 的网络延迟,大项目安装一次可能要好几分钟。


二、Yarn Classic(v1):快速、稳定、扁平

Yarn 诞生于 2016 年,由 Facebook(Meta)主导开发,目标是解决 npm 当时的三大痛点:慢、不稳定、不安全。

2.1 核心改进

问题npm(当时)Yarn Classic
安装速度串行安装并行安装 + 离线缓存
确定性没有 lockfileyarn.lock 确保版本一致
安全性无校验校验每个包的完整性(checksum)

2.2 yarn.lock

Yarn 是第一个引入 lockfile 机制的主流包管理器。yarn.lock 记录了每个依赖的精确版本号下载地址

lodash@^4.17.0:
  version "4.17.21"
  resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#..."
  integrity sha512-...

2.3 局限性

但 Yarn Classic 仍然使用扁平化的 node_modules,这意味着幽灵依赖问题依然存在。它解决了速度和确定性问题,但没有从根本上改变 node_modules 的结构。


三、Yarn Berry(v2+):革命性的 PnP

2020 年,Yarn 发布了 v2(代号 Berry),带来了一个全新的依赖管理方案——Plug’n’Play(PnP)

3.1 PnP 的核心思路:干掉 node_modules

Yarn Berry 的 PnP 模式下,没有 node_modules 目录。取而代之的是一个 .pnp.cjs 文件和一个 .yarn/cache 目录。

项目根目录/
├── .pnp.cjs           ← 模块解析映射表
├── .yarn/
│   └── cache/         ← 所有依赖包的 zip 压缩文件
├── package.json
└── yarn.lock

3.2 怎么工作的?

  1. 不解压:所有依赖包以 zip 文件的形式存储在 .yarn/cache
  2. 映射表.pnp.cjs 是一个巨大的 JSON 映射,记录了”哪个包的哪个版本 → 在哪个 zip 文件里 → 导出了什么”
  3. 运行时拦截:Node.js 启动时加载 .pnp.cjs,它会猴子补丁(monkey-patch) Node 的模块解析逻辑,让 require / import 直接从 zip 文件中读取模块

3.3 PnP 的优势

  • 安装速度极快:不需要解压和写入大量文件到 node_modules
  • 严格依赖:每个包只能访问自己声明的依赖,幽灵依赖被彻底消灭
  • 可以提交到 Git.yarn/cache 中的 zip 文件可以直接提交到版本控制,实现”零安装(Zero-Installs)“——clone 后直接跑,不需要 yarn install
  • 磁盘占用小:zip 压缩 + 全局缓存

3.4 PnP 的问题

听起来很美好,但 PnP 在实践中遇到了不少阻力:

  1. 生态兼容性:很多工具(IDE、构建工具、测试框架)假设 node_modules 存在,PnP 会让它们出错。虽然大部分主流工具已经适配,但长尾工具仍然有问题
  2. 学习成本.pnp.cjs 的调试不太直观
  3. Zero-Installs 的代价:把 .yarn/cache 提交到 Git 会让仓库体积暴增
  4. Node.js 原生支持不足:PnP 依赖于对 Node 模块解析的 monkey-patch,这不是官方支持的方式

正因为这些问题,Yarn Berry 也提供了一个 nodeLinker: node-modules 的选项,让你可以回退到传统的 node_modules 模式。


四、PNPM:硬链接 + 符号链接的优雅方案

PNPM(Performant npm)采用了一种和 npm、Yarn 都不同的策略——内容寻址存储(Content-Addressable Storage)

4.1 全局存储 + 硬链接

PNPM 在磁盘上维护一个全局存储目录(通常在 ~/.local/share/pnpm/store)。所有项目共享这个存储。

当你在项目中安装一个包时,PNPM 不会把文件复制到项目的 node_modules 中,而是创建一个**硬链接(Hard Link)**指向全局存储中的文件。

全局存储:
~/.local/share/pnpm/store/v3/
├── files/
│   ├── ab/cdef1234...   ← lodash 的某个文件
│   ├── 12/345678...     ← react 的某个文件
│   └── ...

项目 A 的 node_modules:
node_modules/.pnpm/lodash@4.17.21/node_modules/lodash/index.js
  → 硬链接到全局存储中的 ab/cdef1234...

项目 B 的 node_modules:
node_modules/.pnpm/lodash@4.17.21/node_modules/lodash/index.js
  → 硬链接到同一个文件

硬链接的效果: 同一个版本的包,无论被多少个项目使用,在磁盘上只存储一份。

4.2 node_modules 结构:符号链接

PNPM 的 node_modules 结构分为两层:

第一层(顶层): 只有你直接声明的依赖,通过符号链接指向 .pnpm 目录

node_modules/
├── express -> .pnpm/express@4.18.2/node_modules/express
├── lodash -> .pnpm/lodash@4.17.21/node_modules/lodash
└── .pnpm/
    ├── express@4.18.2/
    │   └── node_modules/
    │       ├── express/        ← 实际文件(硬链接到全局存储)
    │       ├── body-parser -> ../../body-parser@1.20.1/node_modules/body-parser
    │       └── cookie -> ../../cookie@0.5.0/node_modules/cookie
    ├── lodash@4.17.21/
    │   └── node_modules/
    │       └── lodash/         ← 实际文件(硬链接到全局存储)
    └── body-parser@1.20.1/
        └── node_modules/
            └── body-parser/    ← 实际文件(硬链接到全局存储)

第二层(.pnpm 目录): 每个包在自己的目录中通过符号链接引用它声明的依赖。

4.3 为什么这么设计?

这个结构巧妙地解决了三个问题:

  1. 没有幽灵依赖node_modules 顶层只有直接依赖(通过 symlink),你无法 import 未声明的包
  2. 没有重复安装:硬链接到全局存储,同一版本只占一份磁盘空间
  3. 兼容 Node.js 的解析规则:Node.js 的模块解析仍然基于 node_modules 目录结构,不需要 monkey-patch

4.4 PNPM 的其他优势

  • 安装速度快:硬链接比文件复制快得多,加上优秀的并行化策略
  • 严格模式:默认阻止幽灵依赖,帮你发现潜在问题
  • 内置 Monorepo 支持pnpm workspace 原生支持多包管理(详见 Monorepo 章)
  • 磁盘空间节省显著:如果你的电脑上有 10 个项目都用了 React 18,React 只存一份

五、三者全面对比

5.1 功能对比

特性npmYarn ClassicYarn Berry (PnP)PNPM
node_modules 结构扁平化扁平化无 node_modules符号链接 + 硬链接
幽灵依赖存在存在解决解决
磁盘占用小(zip)小(硬链接)
安装速度中等较快
确定性package-lock.jsonyarn.lockyarn.lockpnpm-lock.yaml
Monorepo 支持workspacesworkspacesworkspacespnpm workspace(更强)
生态兼容性最好有兼容问题

5.2 lockfile 对比

包管理器lockfile 文件格式
npmpackage-lock.jsonJSON
Yarn Classicyarn.lock自定义格式
Yarn Berryyarn.lockYAML
PNPMpnpm-lock.yamlYAML

lockfile 的黄金规则:

  • 一定要提交到 Git——它保证团队和 CI 的依赖一致性
  • 不要手动编辑——由包管理器自动维护
  • 不要混用包管理器——一个项目只用一种,否则 lockfile 冲突

5.3 选型建议

场景推荐原因
新项目 / 追求最佳实践PNPM速度快、严格依赖、省磁盘
已有 Yarn 项目继续用 Yarn(或迁移 PNPM)迁移成本需评估
需要零安装Yarn Berry (PnP)支持把依赖提交到 Git
生态兼容性优先npm 或 PNPMPnP 的兼容性问题最多
MonorepoPNPM内置 workspace 支持最成熟

六、lockfile 深入理解

lockfile 是面试中的一个高频考点,很多人知道它”锁定版本”,但说不清楚具体机制。

6.1 package.json 的版本范围

package.json 中的版本号通常不是精确的,而是一个范围

{
  "dependencies": {
    "lodash": "^4.17.0",     // >=4.17.0 <5.0.0
    "express": "~4.18.0",    // >=4.18.0 <4.19.0
    "react": "18.2.0"        // 精确的 18.2.0
  }
}
  • ^(caret):允许中间版本和补丁版本变化
  • ~(tilde):只允许补丁版本变化
  • 无前缀:精确版本

6.2 lockfile 锁定的内容

lockfile 会记录解析后的精确信息

// package-lock.json 片段
{
  "lodash": {
    "version": "4.17.21",
    "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
    "integrity": "sha512-v2kDE...",
    "requires": {}
  }
}

包括:精确版本号、下载地址、完整性哈希。这保证了任何人在任何环境下 install 出完全一样的依赖树

6.3 什么时候 lockfile 会变?

  • npm install / yarn add / pnpm add 新增依赖
  • 手动修改 package.json 中的版本范围后重新安装
  • 运行 npm update / yarn upgrade / pnpm update

注意: npm install(不带参数)在有 lockfile 的情况下会严格按 lockfile 安装。但如果 package.json 中的版本范围和 lockfile 中的版本不兼容(比如你手动把 ^4.17.0 改成了 ^5.0.0),lockfile 会被更新。


常见误区

误区一:“Yarn 和 PNPM 比 npm 好,npm 已经过时了”

npm 在 v7+ 之后有了大幅改进:支持 workspaces、package-lock.json v3 格式更合理、安装速度也有提升。npm 作为 Node.js 自带的包管理器,有最广泛的生态兼容性。说”npm 过时了”是不客观的——选择哪个取决于项目需求,而不是”新的就是好的”。

误区二:“幽灵依赖不是什么大问题,能用就行”

幽灵依赖是一颗定时炸弹。它在你的机器上能用,不代表在同事的机器上能用(依赖提升的结果可能因安装顺序而异)。更危险的是,某次依赖升级后幽灵依赖可能突然消失或版本改变,导致生产环境崩溃。PNPM 的严格模式正是为了帮你提前发现这些问题。

误区三:“Yarn Berry 的 PnP 是未来方向,所有项目都该用”

PnP 的设计理念确实先进,但它的生态兼容性问题至今没有完全解决。很多工具和库仍然假设 node_modules 存在。如果你的项目依赖了大量第三方工具(如 Electron、某些 CLI 工具),PnP 可能会带来比它解决的更多的问题。务必先在小项目上验证兼容性。

误区四:“lockfile 不需要提交到 Git,反正每次 install 都会生成”

这是一个非常危险的误区。不提交 lockfile 意味着团队成员和 CI 环境可能安装到不同版本的依赖——你用的是 lodash@4.17.21,同事可能装到 4.17.20,行为可能有微妙的差异。lockfile 是保证依赖一致性的关键,必须提交到 Git


小结

本章我们从 npm 的历史问题(嵌套地狱、幽灵依赖、不确定性)出发,分别拆解了 Yarn Classic、Yarn Berry(PnP)和 PNPM 三种方案的设计思路。

核心要点

  1. npm 的核心问题:扁平化解决了嵌套地狱,但引入了幽灵依赖
  2. Yarn Classic:并行安装 + lockfile + 离线缓存,但仍用扁平化 node_modules
  3. Yarn Berry PnP:干掉 node_modules,用 zip + 映射表替代,严格依赖,但生态兼容性有挑战
  4. PNPM:硬链接 + 符号链接,严格依赖,省磁盘,兼容 Node.js 原生解析
  5. lockfile:锁定精确版本号和下载地址,必须提交到 Git
  6. 选型原则:新项目推荐 PNPM,存量项目评估迁移成本,需要零安装选 Yarn Berry

本章思维导图

包管理器:Yarn 与 PNPM
  • npm 的历史问题
    • v2:嵌套地狱(路径过长、重复安装)
    • v3+:扁平化解决嵌套,但引入幽灵依赖
    • v5+:引入 package-lock.json
  • Yarn Classic (v1)
    • 并行安装 + 离线缓存 → 速度快
    • yarn.lock → 确定性安装
    • 仍用扁平化 node_modules → 幽灵依赖依旧
  • Yarn Berry (v2+) / PnP
    • 干掉 node_modules
    • zip 存储 + .pnp.cjs 映射表
    • 零安装(Zero-Installs)
    • 生态兼容性挑战
  • PNPM
    • 全局存储 + 硬链接 → 省磁盘
    • 符号链接构建 node_modules → 严格依赖
    • 兼容 Node.js 原生模块解析
    • 内置 Monorepo 支持
  • lockfile
    • 锁定精确版本 + 下载地址 + 完整性哈希
    • 必须提交到 Git
    • 不要手动编辑,不要混用包管理器
  • 选型
    • 新项目:PNPM
    • 零安装需求:Yarn Berry
    • 生态兼容优先:npm / PNPM

练习挑战

第一题 ⭐(基础):判断幽灵依赖

下面是一个项目的 package.json 和代码。请判断是否存在幽灵依赖问题,并说明原因。

// package.json
{
  "dependencies": {
    "express": "^4.18.0"
  }
}
// app.js
const express = require('express');
const bodyParser = require('body-parser'); // express 依赖了 body-parser

const app = express();
app.use(bodyParser.json());
点击查看答案与解析

存在幽灵依赖问题。 body-parser 没有出现在 package.jsondependencies 中,但代码里直接 require 了它。在 npm/Yarn Classic 的扁平化模式下,body-parser 作为 express 的子依赖被提升到了 node_modules 顶层,所以代码可以正常运行。

但这是不安全的:

  1. 如果 express 未来版本不再依赖 body-parser,代码就会报错
  2. 实际上 express 4.16+ 已经内置了 express.json()express.urlencoded(),不再需要单独引入 body-parser
  3. 如果用 PNPM,这段代码会直接报 MODULE_NOT_FOUND 错误

修复方案: 要么在 package.json 中显式声明 body-parser,要么改用 express.json()

第二题 ⭐⭐(进阶):分析 node_modules 结构

假设项目依赖如下:

{
  "dependencies": {
    "A": "^1.0.0",
    "B": "^1.0.0"
  }
}

其中 A 依赖 C@1.0,B 依赖 C@2.0。请分别画出 npm(扁平化)和 PNPM 下的 node_modules 结构。

点击查看答案与解析

npm(扁平化):

node_modules/
├── A/
├── B/
│   └── node_modules/
│       └── C@2.0/        ← C@2.0 不能提升(和 C@1.0 冲突),留在 B 内部
└── C@1.0/                ← C@1.0 被提升到顶层

注意:npm 会把先解析到的版本提升到顶层,后面冲突的版本留在各自的依赖内部。所以哪个版本被提升,取决于包名的字母序(A 排在 B 前面,先处理 A 的依赖 C@1.0)。

PNPM:

node_modules/
├── A -> .pnpm/A@1.0.0/node_modules/A
├── B -> .pnpm/B@1.0.0/node_modules/B
└── .pnpm/
    ├── A@1.0.0/
    │   └── node_modules/
    │       ├── A/                ← 实际文件
    │       └── C -> ../../C@1.0.0/node_modules/C
    ├── B@1.0.0/
    │   └── node_modules/
    │       ├── B/                ← 实际文件
    │       └── C -> ../../C@2.0.0/node_modules/C
    ├── C@1.0.0/
    │   └── node_modules/
    │       └── C/                ← 硬链接到全局存储
    └── C@2.0.0/
        └── node_modules/
            └── C/                ← 硬链接到全局存储

PNPM 的结构中,C@1.0 和 C@2.0 各自独立存在于 .pnpm 目录中,通过符号链接被各自的依赖者引用,互不干扰,也不会出现在顶层。

第三题 ⭐⭐⭐(综合):迁移方案设计

你接手了一个使用 npm 的中型项目(约 200 个直接依赖),团队反馈安装慢、CI 构建时间长、偶尔出现”在我电脑上是好的”的问题。请设计一个从 npm 迁移到 PNPM 的方案,包括:

  1. 迁移前需要做哪些评估?
  2. 具体的迁移步骤是什么?
  3. 迁移后可能遇到什么问题?如何解决?
点击查看答案与解析

一、迁移前评估

  1. 幽灵依赖检查:用 depcheck 工具扫描项目,找出代码中使用了但 package.json 未声明的依赖——这些在 PNPM 下会直接报错
  2. 构建工具兼容性:确认项目使用的构建工具(Webpack/Vite 等)与 PNPM 兼容
  3. CI/CD 脚本:检查 CI 脚本中是否硬编码了 npm 命令
  4. postinstall 脚本:某些包的 postinstall 脚本可能依赖 node_modules 的扁平结构

二、迁移步骤

# 1. 安装 PNPM
npm install -g pnpm

# 2. 删除旧的 node_modules 和 lockfile
rm -rf node_modules package-lock.json

# 3. 使用 PNPM 的导入功能(如果还保留着 package-lock.json)
pnpm import  # 从 package-lock.json 生成 pnpm-lock.yaml

# 4. 安装依赖
pnpm install

# 5. 修复幽灵依赖(pnpm install 后跑一遍项目,把报错的包加到 package.json)
pnpm add <missing-package>

# 6. 更新 CI 脚本,把 npm 命令替换为 pnpm
# npm install → pnpm install
# npm run build → pnpm build
# npm test → pnpm test

# 7. 添加 .npmrc 配置(可选)
echo "shamefully-hoist=true" > .npmrc  # 如果兼容性问题太多,暂时开启提升模式

# 8. 提交 pnpm-lock.yaml,删除 package-lock.json

三、可能的问题和解决方案

  1. 幽灵依赖报错:最常见的问题。解决:把缺失的包加到 package.json 的依赖中
  2. 某些工具不认 symlink:少数工具(如某些 Electron 构建工具)不能正确处理符号链接。解决:在 .npmrc 中配置 shamefully-hoist=true 回退到扁平模式,或者对特定包配置 public-hoist-pattern
  3. CI 环境没有 PNPM:解决:使用 corepack enable(Node 16.13+)或在 CI 中增加 PNPM 安装步骤
  4. lockfile 冲突:团队成员需要同时切换。解决:在一个 PR 中完成迁移,merge 后所有人同步

自我检测

  • 能说清楚 npm v2 的嵌套问题和 v3+ 扁平化带来的幽灵依赖问题
  • 能解释 Yarn Classic 相比 npm(早期)的三大核心改进
  • 能描述 Yarn Berry PnP 的工作原理:zip 存储 + .pnp.cjs 映射 + 运行时拦截
  • 能画出 PNPM 的 node_modules 结构,解释硬链接和符号链接的分工
  • 能解释为什么 PNPM 能解决幽灵依赖问题而 npm/Yarn Classic 不能
  • 能说出 lockfile 锁定的具体内容,以及为什么必须提交到 Git
  • 能根据项目需求给出包管理器的选型建议并说明理由
  • 能列出从 npm 迁移到 PNPM 的关键步骤和可能遇到的问题

购买课程解锁全部内容

大厂前端面试通关:71 篇构建完整知识体系

¥89.90