前段时间在整理前端工程化面试题时,我遇到一个很有意思的连环追问:
“项目为什么用 Monorepo?”
“pnpm 的优势是什么?”
“那 pnpm 怎么做到‘没声明就不能用’的?”
前两问,大多数候选人能聊上几句,比如代码复用、安装速度快、依赖隔离。但问到第三问,很多人会卡住。大家用着 pnpm,却不清楚它背后那段“非扁平 node_modules + 符号链接”的依赖解析机制。这篇文章就把 Monorepo、pnpm、幽灵依赖三者串成一条线,从原理讲到实验,从安装讲到排错,帮你在面试和实际项目里都能把这一整块问题说清楚。
1. 从一个面试场景说起:Monorepo、pnpm 与幽灵依赖
先还原一下面试中的完整场景。面试官想看候选人是否真的理解工程化选型,所以不会只问“你用没用过 pnpm”,而是会沿着一条逻辑链往下挖:
- 项目为什么选 Monorepo?
- 在 Monorepo 场景下,为什么选 pnpm 而不是 npm 或 yarn?
- pnpm 声称能解决幽灵依赖,它是用什么机制解决的?
- 追问:pnpm 怎么做到“package.json 里没声明,代码里就不能用”?
你会发现,这三个问题其实是层层递进的。Monorepo 是一个仓库组织策略,pnpm 是为这个策略提供工程能力的包管理器,而幽灵依赖是传统包管理器在 Monorepo 场景下最容易暴露的问题。搞懂了最后一条,你才算真正理解了 pnpm 的设计核心。
为了便于理解,先给三个概念做一句话定位:
- Monorepo:把多个项目放进同一个 Git 仓库统一管理。
- pnpm:一个速度快、省磁盘、依赖隔离严格的包管理器。
- 幽灵依赖:代码里直接使用了一个没有在 package.json 中声明的包,却因为 node_modules 的扁平化结构“侥幸”能运行。
下面逐个展开。
2. pnpm 安装与环境准备
正式开始动手之前,先把 pnpm 装好。pnpm 是一个 Node.js 生态的包管理器,所以安装 pnpm 之前,本机要先有 Node.js 环境。
2.1 检查 Node.js 环境
在终端执行:
node -v npm -v如果你还没有安装 Node.js,建议先安装一个较新的 LTS 版本。不同项目对 Node 版本要求不同,建议使用 nvm(macOS/Linux)或 nvm-windows(Windows)来管理 Node 版本,方便随时切换。
2.2 安装 pnpm 的三种方式
pnpm 官方提供了多种安装方式,这里列出三种最常见的。
方式一:使用 Corepack 安装(Node.js 自带,推荐)
Node.js 16.9 及以上版本自带 Corepack,可以用它来管理和启用 pnpm:
corepack enable corepack prepare pnpm@latest --activate这种方式的好处是无需手动安装全局工具,也不容易出现 PATH 找不到命令的问题。
方式二:通过 npm 安装
npm install -g pnpm安装后可以用pnpm -v验证版本。如果你在 Windows 上遇到权限问题,可以尝试用管理员身份打开终端再执行。
方式三:macOS / Linux 使用官方安装脚本
curl -fsSL https://get.pnpm.io/install.sh | sh -安装完成后,重新打开终端,执行:
pnpm -v如果能够输出版本号,说明 pnpm 已经安装成功。
2.3 配置国内镜像
由于网络原因,直接安装依赖可能会很慢。如果安装速度不理想,可以把 registry 切换到国内镜像:
pnpm config set registry https://registry.npmmirror.com设置后,建议查看一下当前配置:
pnpm config get registry如果输出的是https://registry.npmmirror.com,说明镜像已生效。
2.4 Windows 常见安装问题
很多 Windows 用户在安装 pnpm 后,会遇到类似这样的报错:
pnpm : 无法将“pnpm”项识别为 cmdlet、函数、脚本文件或可运行程序的名称。或者:
'pnpm' 不是内部或外部命令,也不是可运行的程序或批处理文件。这通常说明 pnpm 已经安装,但它所在的全局 bin 目录没有加入系统 PATH。解决办法如下:
- 执行
pnpm setup,让 pnpm 自动配置 PATH。 - 重新打开终端窗口。
- 如果问题依然存在,手动将全局安装目录加入 PATH。
- 如果你使用 nvm-windows 管理 Node,全局安装目录通常在
C:\Users\你的用户名\AppData\Roaming\npm下。
另外,如果你安装的 pnpm 版本较新,而本机 Node.js 版本偏低,可能会出现类似这样的报错:
error: this version of pnpm requires at least node.js v22.13意思是当前 pnpm 版本要求更高版本的 Node.js。解决方式有两种:升级本机 Node.js 到报错要求的版本,或者安装一个兼容当前 Node 版本的 pnpm 版本。
3. Monorepo 的核心价值与适用场景
现在进入第一个面试题:项目为什么用 Monorepo?
3.1 什么是 Monorepo
Monorepo 并不是一种具体的软件,而是一种仓库管理策略。它的核心思想是:把多个应用、多个包、多个服务的源码放到同一个 Git 仓库中,使用同一套依赖管理、构建流程和发布流程。
与 Monorepo 相对的是 MultiRepo(多仓库),也就是每个项目单独建一个 Git 仓库,项目之间通过发布 npm 包或服务接口来协作。
在 Monorepo 出现之前,很多团队的做法是:
- 公共组件库单独一个仓库。
- 业务项目 A 单独一个仓库。
- 业务项目 B 单独一个仓库。
当公共组件库需要改一个接口时,流程是:改组件库 → 发布新版本 → 去项目 A 升级依赖 → 去项目 B 升级依赖。如果项目 A 和项目 B 都忘了升级,就会出现多个版本并存的问题。
Monorepo 的做法是:所有项目放在同一个仓库里,公共代码通过 workspace 协议直接引用,改完公共代码,所有引用它的项目立刻感知。
3.2 Monorepo 解决了什么
Monorepo 带来的收益,主要体现在四个方面。
第一,代码复用更自然。公共包不需要先发布到 npm registry,再在业务项目里安装。通过 workspace,你可以在仓库内直接引用另一个包的源码。本地开发时,改完公共包,业务项目马上生效。
第二,原子提交成为可能。一个功能如果同时涉及前端项目、公共组件和后端 API 定义,在 Monorepo 中可以一次提交全部搞定。代码评审时,也能看到完整的变更上下文。
第三,依赖版本更容易统一。多个项目共享同一套依赖解析结果,可以减少“环境不一致”带来的问题。尤其当多个微前端子应用需要共用同一版本的 React 或 Vue 时,Monorepo 优势非常明显。
第四,统一构建和发布流程。只需要维护一套 CI 配置、一套代码规范、一套测试策略,而不是为每个仓库各配一套。
下面用表格对比多仓库和 Monorepo 的差异:
| 维度 | MultiRepo(多仓库) | Monorepo(单仓库) |
|---|---|---|
| 代码共享 | 通过发布 npm 包 | 通过 workspace 直接引用 |
| 跨项目改动 | 多次提交、多次发布 | 一次提交完成 |
| 版本一致性 | 依赖版本容易漂移 | 统一依赖解析、统一版本 |
| 构建与 CI | 每个仓库独立配置 | 统一配置,可增量构建 |
| 仓库权限管理 | 仓库维度隔离 | 需要目录/业务维度约束 |
| 仓库体积 | 每个仓库较小 | 仓库会持续变大 |
3.3 Monorepo 的缺点与适用场景
Monorepo 并不是银弹。它的缺点也很明显:
- 仓库体积会越来越大,克隆和下载成本变高。
- 权限控制粒度变粗,很难限制某个团队只能访问某个子目录。
- CI 如果配置不当,容易变成“每次提交全量构建”,导致构建时间变长。
- 团队规范要求更高,否则容易变成“大乱炖”。
所以,Monorepo 更适合以下场景:
- 多个前端应用共享大量 UI 组件或工具库。
- 多个 Node.js 服务共享公共 SDK。
- 微前端架构下,多个子应用需要统一依赖管理。
- 团队希望用一套工具链统一管理所有前端项目。
如果你的项目是独立的单页应用,并且没有和其他项目共享代码的需求,用普通单仓库就够了,没有必要强行上 Monorepo。
4. pnpm 相比 npm/yarn 的核心优势
第二个面试题是:pnpm 的优势是什么?这一节我们展开讲。
4.1 速度快
pnpm 的安装速度比 npm 和 yarn classic 更快,原因有三个:
- 并行下载依赖,而不是串行逐一安装。
- 使用全局内容寻址存储(Content-Addressable Store),同一个版本的包在机器上只存一份。
- 通过硬链接直接链接到项目目录,不需要重复解压和复制。
真实项目里,如果你用 npm 安装一个依赖很大的项目,耗时可能需要好几分钟。而 pnpm 在首次安装后,第二次安装几乎就是秒级完成,因为它可以复用全局 store 中的缓存。
4.2 省磁盘空间
npm 的做法是:每个项目都会把依赖完整地复制到自己的node_modules目录里。如果你在十个项目里都用了lodash,磁盘上就有十份几乎一样的lodash。
pnpm 的做法是:所有依赖的真实文件都存放在全局 store 中,项目里的node_modules通过硬链接从 store 指向这些文件。如果你的项目很多,并且共享了大量依赖,pnpm 能节省非常可观的磁盘空间。
你可以使用pnpm store path查看 store 的路径,使用pnpm store prune清理不再被引用的缓存包。
4.3 依赖隔离严格
这是 pnpm 最核心的设计亮点。
npm 会把所有依赖扁平化安装到顶层node_modules中,导致你的代码可以访问那些并没有在 package.json 中声明的包,也就是“幽灵依赖”。
pnpm 默认不会这样做。它采用非扁平结构,项目顶层node_modules里只会有你直接声明过的依赖,其他传递依赖会被放到一个隐藏的.pnpm目录中,并通过符号链接精确连接到对应位置。这样,代码里只能用 package.json 中声明过的包,没声明就找不到。
4.4 对 Monorepo 的原生支持
pnpm 原生支持 workspace,只需要一个pnpm-workspace.yaml文件,就能管理一个包含多个包的 Monorepo 仓库。它提供了pnpm --filter等命令,可以精确地只安装、构建某个子包,这在大型 Monorepo 中非常实用。
4.5 命令迁移成本低
pnpm 的命令和 npm 非常接近,比如:
| npm 命令 | pnpm 命令 |
|---|---|
| npm install | pnpm install |
| npm run dev | pnpm dev |
| npm run build | pnpm build |
| npm add lodash | pnpm add lodash |
| npm remove lodash | pnpm remove lodash |
大多数情况下,把 npm 换成 pnpm 就能直接使用,团队迁移成本比较低。
5. 幽灵依赖:问题从哪来,危险在哪
第三个面试题是:如何解决幽灵依赖?要理解 pnpm 的解法,先要知道幽灵依赖是什么。
5.1 幽灵依赖是什么
幽灵依赖(Ghost Dependency / Phantom Dependency)指的是:你的代码里直接引入了一个包,但这个包并没有在你的 package.json 中声明。
举个例子:
{ "name": "my-app", "version": "1.0.0", "dependencies": { "eslint": "^8.0.0" } }你的项目只声明了eslint,但 eslint 内部依赖了lodash。在 npm 的扁平化 node_modules 结构下,lodash会被提升到顶层node_modules/lodash。于是,你的源码里可以直接写:
const _ = require('lodash');而且程序能正常运行。
这个lodash就是一个幽灵依赖。你明明没有在 package.json 里声明它,它却因为“别人依赖它”而间接存在于你的项目里。
5.2 为什么会产生幽灵依赖
问题出在 npm 的依赖提升策略上。
为了兼容 Node.js 的模块解析机制并避免非常深的目录层级,npm 从 v3 开始把依赖树扁平化。当一个包被安装时,npm 会把它的全部传递依赖尽可能提升到顶层 node_modules。这样做的结果是:所有被打包进项目的依赖,无论是否被声明,都在顶层目录里“可见”。
视觉上,你打开node_modules会发现里面躺着一堆你根本不认识的包。这堆包不是你安装的,而是“别人”的依赖被提升上来了。
5.3 幽灵依赖为什么危险
幽灵依赖最大的危险在于“侥幸运行”。
你写了一行require('lodash'),开发时一切正常。某天 eslint 升级了,它的内部依赖变了,lodash不再被 eslint 依赖,npm 安装后就不会把lodash提升到顶层。于是你的项目突然报错:
Cannot find module 'lodash'更隐蔽的是,即使lodash还在,但它可能被升级到了一个大版本,某个 API 被移除,你的代码就会在运行时崩溃。
这类问题很难定位,因为报错信息不会提示“你少声明了一个依赖”,只会提示某个模块找不到。尤其在大型 Monorepo 里,依赖关系复杂,排查成本非常高。
5.4 pnpm 如何解决幽灵依赖
pnpm 的解决思路,是彻底放弃 npm 式的扁平化 node_modules,改为“非扁平化 + 符号链接”的结构。我们把这种结构放到下一节详细拆解,因为这也对应面试官最后那个追问:
“pnpm 怎么做到 package.json 里没声明就不能用?”
6. pnpm 如何做到“没声明就不能用”:原理拆解与动手验证
这一节是整篇文章的重点,也是面试官最想听的部分。
6.1 Node.js 的模块查找机制
要理解 pnpm 的严格隔离,先要理解 Node.js 是如何找到模块的。
当你在某个文件里写:
const lodash = require('lodash');Node.js 会从当前文件所在的目录开始,逐级向上查找node_modules目录:
- 查找
/项目目录/src/node_modules/lodash - 查找
/项目目录/node_modules/lodash - 查找
/上级目录/node_modules/lodash - 一直查到系统根目录
只要在某个层级找到了lodash,就停止查找并加载。如果一直找不到,就会抛出:
Error: Cannot find module 'lodash'所以,“某个包能不能被使用”只取决于一件事:它是否存在于当前文件向上查找的任意一个node_modules路径中。
pnpm 正是通过控制这些路径里的内容,来实现“声明了才能用”。
6.2 pnpm 的非扁平 node_modules 结构
一个典型 pnpm 项目安装后的 node_modules 结构大概是这样的:
node_modules/ ├── .pnpm/ │ ├── lodash@4.17.21/ │ │ └── node_modules/ │ │ └── lodash/ │ └── pkg-a@1.0.0/ │ └── node_modules/ │ ├── lodash -> ../../lodash@4.17.21/node_modules/lodash │ └── pkg-a └── pkg-a -> .pnpm/pkg-a@1.0.0/node_modules/pkg-a注意几个关键点:
- 项目顶层的
node_modules中,只有你直接声明的依赖被创建了符号链接,比如这里的pkg-a。 - 真实依赖文件存放在
node_modules/.pnpm/<name>@<version>/node_modules/下。 - 每个包内部依赖的其他包,都以符号链接的形式放在自己的
node_modules中。
简单来说,pnpm 把 node_modules 从“一个全公司共享的大平层”改成了“每个包独立的小房间”。
6.3 “没声明就不能用”的完整链路
现在把 Node.js 解析机制和 pnpm 目录结构结合起来,你就能回答面试官了。
假设你的项目根目录package.json是这样:
{ "name": "my-app", "dependencies": { "pkg-a": "1.0.0" } }你在根目录的index.js里写:
require('lodash');Node.js 会从index.js所在目录开始向上查找node_modules/lodash。但 pnpm 安装后,根目录node_modules下只有pkg-a这个符号链接,根本没有lodash。所以 Node.js 一直向上找,也找不到lodash,最终抛出:
Cannot find module 'lodash'这就是 pnpm 的“没声明就不能用”:直接依赖被符号链接暴露在顶层,而不在顶层暴露传递依赖。
反过来,pkg-a内部为什么可以用 lodash?因为 pnpm 会在pkg-a自己的node_modules目录里放置指向lodash的符号链接。也就是说,每个包只能看到自己 package.json 中声明的依赖。
6.4 硬链接与符号链接各司其职
这里有个常见的理解误区:pnpm 省磁盘用的是硬链接,还是符号链接?
答案:两者都在用,但作用不同。
- 符号链接(symlink)负责“让模块能被找到”,解决的是依赖解析问题。
- 硬链接(hard link)负责“让磁盘上只存一份内容”,解决的是空间占用问题。
具体安装时,pnpm 会把依赖的真实内容存放在全局 store 中,然后通过硬链接把文件映射到.pnpm目录中,再通过符号链接让包能找到对应的依赖。硬链接让多个项目共享同一份文件数据,符号链接让 Node.js 的模块解析能找到正确的依赖位置。
6.5 动手验证:一个最小 Demo
为了让你更直观地理解,下面做一个最简单的本地实验。
先创建项目结构:
mkdir pnpm-ghost-demo cd pnpm-ghost-demo mkdir -p packages/pkg-a创建pnpm-workspace.yaml:
packages: - 'packages/*'创建根目录package.json:
{ "name": "pnpm-ghost-demo", "private": true, "dependencies": { "pkg-a": "workspace:*" } }创建packages/pkg-a/package.json:
{ "name": "pkg-a", "version": "1.0.0", "main": "index.js", "dependencies": { "lodash": "^4.17.21" } }创建packages/pkg-a/index.js:
const _ = require('lodash'); module.exports = _.camelCase('hello world');创建根目录index.js:
// 根项目没有直接声明 lodash,只有依赖的 pkg-a 内部声明了 lodash const _ = require('lodash'); console.log(_.camelCase('pnpm ghost dependency'));然后安装依赖:
pnpm install安装完成后,先运行根目录的index.js:
node index.js你会看到类似这样的报错:
Error: Cannot find module 'lodash'这是因为根项目的 package.json 中没有声明 lodash,pnpm 也没有把 lodash 提升到根目录的 node_modules 中,所以 Node.js 找不到它。
然后到pkg-a目录中运行:
node packages/pkg-a/index.js这次可以正常输出:
helloWorld因为 pkg-a 自己声明了 lodash,pnpm 通过符号链接让 pkg-a 可以访问到它。
这个实验很直观地说明了一件事:pnpm 的依赖隔离不是靠“规则约束”,而是靠“物理上不让你找到”。
6.6 如果你非要把依赖提升到顶层
有些开源项目在迁移到 pnpm 时,可能会因为依赖提升策略不同而出现构建脚本找不到命令的问题。针对这种情况,pnpm 也提供了提升配置,可以在项目根目录创建.npmrc文件:
shamefully-hoist=true或者更克制地指定某些包提升到顶层:
public-hoist-pattern[]=*eslint* public-hoist-pattern[]=*vitest*但这里要提醒:提升配置等于重新引入了幽灵依赖的空间,不到万不得已不建议全局开启。更好的方式是修复代码,让每一个被使用的依赖都显式声明。
7. 常见问题与排查思路
pnpm 在安装和使用过程中,有一些频率很高的问题。这里整理成表格,方便快速检索。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| pnpm 无法被识别为 cmdlet 或内部命令 | pnpm 全局 bin 目录不在 PATH 中 | 执行 pnpm setup,并将全局目录加入 PATH |
| 报错 require at least node.js v22.13 | pnpm 版本要求更高版本的 Node.js | 升级 Node.js,或降低 pnpm 版本 |
| pnpm install 速度很慢 | 默认 registry 网络不稳定 | 设置国内镜像后重试 |
| 安装过程中提示 pnpm approve-builds | 某些依赖的 postinstall 脚本默认被 pnpm 阻止 | 执行 pnpm approve-builds 按需允许 |
| 迁移到 pnpm 后,原本能运行的代码报 Cannot find module | 代码中存在幽灵依赖,npm 时代靠扁平化侥幸运行 | 将代码中用到但未声明的包显式加入 package.json |
| 某些开源项目在 pnpm 下构建失败 | 依赖提升策略不同,构建脚本找不到命令 | 检查项目文档,或使用 .npmrc 中的 hoist 配置 |
| 重复安装相同依赖但磁盘占用增长 | 多版本依赖并存,或 store 中存在过期缓存 | 使用 pnpm store prune 清理 |
7.1 关于 pnpm approve-builds 的补充说明
近期的 pnpm 版本出于安全考虑,默认不会执行依赖包里的 postinstall 等构建脚本。如果你安装esbuild、node-sass、sqlite3这类需要编译的包,可能会看到提示:
Run "pnpm approve-builds" to pick which dependencies should be allowed to run scripts.解决方式有两种。
第一种,执行交互式命令:
pnpm approve-builds然后按提示选择允许执行的依赖。
第二种,在根目录 package.json 中配置构建白名单:
{ "pnpm": { "onlyBuiltDependencies": [ "esbuild" ] } }第二种方式更可控,也更容易在团队中统一执行。
7.2 关于从 npm 迁移到 pnpm
如果项目原本使用 npm,并且存在package-lock.json,可以通过 pnpm 自动生成体验更好的锁文件:
pnpm import这个命令会根据已有的 npm lockfile 生成pnpm-lock.yaml。不过要注意,迁移后最好执行一次pnpm install,并重点检查是否存在幽灵依赖问题。如果之前代码里“偷偷”使用了未声明的包,迁移到 pnpm 后会立刻暴露出来。换个角度想,这其实是帮你排掉了一个隐患。
8. 最佳实践与工程建议
理解了原理之后,再看 pnpm 在 Monorepo 中的工程实践,思路会清晰很多。
8.1 目录结构建议
一个 pnpm Monorepo 常见的目录结构如下:
my-monorepo/ ├── apps/ │ ├── web/ │ └── admin/ ├── packages/ │ ├── ui/ │ ├── utils/ │ └── request/ ├── package.json ├── pnpm-workspace.yaml ├── pnpm-lock.yaml └── .npmrc建议用apps放应用入口,用packages放公共库和业务模块。这样pnpm --filter指令一眼就能看清楚作用范围。
8.2 根依赖与子包依赖的划分
根目录的package.json尽量只放公共开发依赖,比如:
- TypeScript
- ESLint
- Prettier
- Vitest
- Husky
各业务包需要的运行时依赖,放进各自包的package.json。这样每个包的职责边界清晰,也方便单独发布。
8.3 使用 workspace 协议
在 Monorepo 中,子包之间互相引用时,推荐使用workspace:*协议:
{ "dependencies": { "@my-org/ui": "workspace:*" } }这样 pnpm 会把包直接链接到本地源码,开发时改动即时生效。发布时,pnpm 会根据发布配置将协议转换为实际版本号。
8.4 使用 --filter 做增量操作
在 Monorepo 中,不要总是对整个仓库执行构建。使用pnpm --filter可以精确控制作用范围:
# 只构建 web 应用 pnpm --filter web build # 构建 web,并构建它依赖的所有本地包 pnpm --filter web... build # 向 utils 包添加依赖 pnpm --filter @my-org/utils add lodash如果希望只运行某个包及其依赖包的脚本,可以这样写:
pnpm --filter web... run build三个点表示“包含该包的所有依赖链”。
8.5 在 CI 中优化安装效率
CI 中建议固定 lockfile,使用以下命令:
pnpm install --frozen-lockfile这意味着安装过程严格以pnpm-lock.yaml为准,不会解析新的依赖版本,保证 CI 和本地环境一致。
同时可以缓存 pnpm 的全局 store 目录:
pnpm store path把输出路径加入 CI 的缓存配置,能显著减少重复下载。
8.6 安全与生产环境注意事项
在生产环境执行任何依赖变更前,建议先确认以下几点:
- 当前分支代码已经提交,或者已备份现有 lockfile。
- 删除或升级依赖后,在测试环境完整验证一次构建和部署流程。
- 保持最小权限原则,CI 中的
pnpm publish权限只授予必要的账号。
另外,对于构建后部署的问题,很多前端项目关心pnpm run build之后的产物如何部署到 Nginx。这其实和单仓项目没有本质区别:构建产物一般在对应子包的dist目录,把 Nginx 的root指向该目录即可。如果使用前端路由的 history 模式,还需要配置try_files回退到index.html,避免刷新页面时出现 404。
9. 面试时如何组织回答
最后,把前面所有内容浓缩成一套面试回答思路。
如果面试官问:“项目为什么用 Monorepo?”
你可以回答:
“Monorepo 的核心价值是代码复用和原子提交。多个项目共享公共代码时,可以直接通过 workspace 引用,不需要先发包再升级依赖。一次改动可以跨多个项目同时提交,代码评审的上下文也更完整。同时它可以统一依赖版本、统一构建流程,减少环境差异。当然它也有成本,比如仓库体积变大、权限管理需要额外设计,所以适合多项目有较强共享关系的团队。”
如果面试官接着问:“pnpm 的优势是什么?”
你可以回答:
“站在 Monorepo 的视角,pnpm 有三个核心优势。第一是速度快,它的全局内容寻址 store 会让同一个版本的依赖只存一份,后续安装通过硬链接复用,二次安装几乎是秒级。第二是省磁盘,多项目共享依赖时效果非常明显。第三是依赖隔离,pnpm 不会把传递依赖扁平化提升到顶层 node_modules,所以代码里使用任何没有在 package.json 中声明的包都会直接报 Cannot find module。此外,pnpm 原生支持 workspace 和 --filter,这让 Monorepo 的包管理非常顺手。”
如果面试官继续追问:“那 pnpm 是怎么做到‘没声明就不能用’的?”
这个时候,你已经可以通过前面第六节的原理来回答了:
“pnpm 会把项目的真实依赖放在 node_modules/.pnpm 目录下,每个依赖都有自己的子目录。项目顶层 node_modules 里只有 package.json 中直接声明的依赖符号链接。每个包的内部依赖,会被符号链接到 .pnpm 下对应版本的真实目录中。Node.js 解析模块时会从当前文件目录向上查找 node_modules,而没有被声明、没有被链接到解析路径里的包,根本不在任何可见的 node_modules 路径中,所以 Node.js 永远无法找到它。这就从物理层面实现了‘没声明就不能用’。”
如果面试官还愿意深挖,你还可以补充硬链接和符号链接的区别:符号链接解决依赖解析路径,硬链接解决磁盘空间复用。
这一串回答下来,Monorepo、pnpm、幽灵依赖这条技术线就很完整了。技术概念只有在实践中被亲手验证过,面试时才不会被追问卡住。建议你找个空闲时间,把第六节的 demo 完整跑一遍,体会一下依赖报错从“意外”变成“预期”的感觉。