
前一阵子有个读者跟我聊面试经历说自己遇到一个很典型的问题“项目为什么用 Monorepopnpm 的优势是什么怎么解决幽灵依赖”他准备得不错答案张口就来代码复用、速度快、依赖隔离。但面试官接下来一句“那 pnpm 是怎么做到‘没声明就不能用’的”直接让他卡住了。这个场景很有代表性。很多人知道 pnpm 能解决幽灵依赖但说不清它到底靠什么机制解决。如果只是背答案面试官多追问一层就容易露馅。更重要的是如果你在真实项目里也说不清 pnpm 的依赖隔离原理那遇到 monorepo 依赖问题、幽灵依赖排查、CI 构建异常时同样会束手无策。这篇文章不打算只给你面试话术。我会从 Monorepo 为什么会出现、npm 的依赖提升为什么有坑、pnpm 的内容寻址存储和符号链接结构一直讲到完整的 workspace 实战和常见问题排查。读完之后你既能理解 pnpm 的设计原理也能在项目里真正落地还能在面试官追问“怎么做到没声明就不能用”时把机制讲清楚。1. 为什么项目要选 Monorepo先回答最基础的问题Monorepo 到底解决了什么过去很多团队做的是 MultiRepo多仓库模式每个业务项目一个独立 Git 仓库。这种模式在项目少、团队小的时候没问题但一旦项目变多就会遇到几个非常具体的痛点。第一跨项目代码复用变成“复制粘贴”。公共组件、工具函数、接口定义散落在各个仓库今天 A 项目改了一个工具函数B 项目还在用旧版本。想同步要么发 npm 包要么手动复制。发包意味着要走版本发布流程复制意味着代码很快会分叉各有各的维护成本。第二跨仓库修改非常痛苦。假设你改了公共组件库的源码同时需要改调用它的业务项目一次需求要提交两个甚至更多仓库的 PR然后还要按顺序发版、升级依赖。代码评审、CI 检查、版本对齐全部被拆散。第三依赖版本不一致。同一个框架在 A 项目是 1.x在 B 项目是 2.x一旦出了问题你很难判断是业务代码差异还是依赖版本差异导致的。Monorepo 的答案很直接把所有相关项目放在同一个仓库里管理。这不是一个新技术Linux 内核、React、Vue、Babel 这些知名项目一直是这么做的。它带来的核心收益有三个代码复用成本极低。公共代码就在仓库里改完立刻生效不需要发版等待。原子提交。一次提交可以同时包含公共库修改和业务方修改代码评审能看到完整变更。依赖和配置统一。根目录统一管理依赖版本锁定文件只有一个CI 构建也可以复用缓存。但 Monorepo 也有代价。仓库会变大Git 操作变慢没有严格的分层规范模块边界容易腐化依赖关系复杂后包管理器必须足够可靠否则一个依赖提升问题就能让所有人卡住。这也是为什么 Monorepo 和 pnpm 经常一起出现Monorepo 放大了依赖管理的复杂性而 pnpm 恰好在这种场景下有一套更严谨的依赖模型。2. Monorepo 落地为什么需要专门的包管理器你可以在一个仓库里建很多子目录每个子目录一个package.json然后手动维护依赖关系。但这样做很快会失控因为 Node 的模块解析默认是沿目录向上查找的子包很容易“意外”用到根目录的依赖。所以 Monorepo 一般都需要 workspace 能力让包管理器统一处理子包之间的链接和依赖安装。npm 和 Yarn 也提供了 workspace 能力。你可以在根目录配置一下然后用它们安装所有子包的依赖。这比纯手动管理好很多但仍然残留一个问题依赖提升hoisting。npm 从 3.x 开始为了减少node_modules的嵌套深度默认会把依赖“拍平”。例如你的项目依赖了expressexpress又依赖了accepts在 npm 2 时代会形成很深的嵌套目录node_modules/ └── express/ └── node_modules/ └── accepts/npm 3 之后它会尽量把所有依赖提升到顶层node_modules/ ├── express/ ├── accepts/这种扁平化设计让依赖查找更快也避免了 Windows 上路径过长的问题但它带来了一个副作用很多你并没有直接声明的依赖也会出现在顶层的node_modules里。于是你在代码里随手写require(accepts)Node 也能找到它尽管你的package.json里根本没有accepts。这就是幽灵依赖的温床。3. 幽灵依赖是怎么产生的为什么危险幽灵依赖Phantom Dependencies指的是一个包使用了它并未在package.json中显式声明的依赖。代码能跑起来恰恰是因为依赖提升让这个未声明依赖“碰巧”存在于node_modules里。举个例子。你的项目声明了reactreact内部依赖了object-assign。使用 npm 安装后node_modules里会出现react和object-assign。代码里可能有人写了const objectAssign require(object-assign);这在 npm 下能正常跑因为object-assign被提升到了顶层node_modules。但它是个幽灵依赖。为什么说它危险因为它随时可能消失。只要react升级后不再依赖object-assign或者 npm 的依赖提升策略调整object-assign不再出现在顶层你的代码就会突然报错Cannot find module object-assign。更麻烦的是这个错误往往不是立刻出现的项目可能一天后才在 CI 中挂掉排查起来非常费劲。另外一个场景是版本被悄悄替换。假设你的项目依赖 A 和 BA 依赖lodash4B 依赖lodash3。npm 可能把其中一个版本提升到顶层另一个版本嵌套在对应包下面。如果你在项目里直接require(lodash)拿到的可能是提升上来的那个版本而不是你以为的那个版本。这种“隐式版本选择”非常容易埋坑。所以我一直认为npm 的扁平化属于“牺牲严格性换取便利”。它在小项目里问题不大但在多人协作、子包众多的 Monorepo 里幽灵依赖会成为稳定性隐患。pnpm 的核心设计正是冲着这个问题去的。4. pnpm 的核心优势不只是快很多人对 pnpm 的第一印象是“快”和“省磁盘空间”。这两个优势确实存在但如果你只记住这两点面试时就会像我开头说的那位候选人一样被一个追问卡住。pnpm 真正区别于 npm/Yarn 的是它的依赖解析模型。pnpm 有两个关键设计第一个是内容寻址存储。所有依赖包的实际文件都被存放在一个全局的 store 中并不会针对每个项目重复下载。store 里文件的存放位置由文件内容哈希决定所以同一个版本的同一个包在不同项目、不同分支之间是共享的。你新建一个项目只要依赖的版本之前已经下载过pnpm 几乎可以瞬间完成安装。这就是它“快”和“省空间”的本质。第二个是严格的符号链接结构。pnpm 不会把依赖拍平到顶层node_modules。项目根目录node_modules下只会出现你显式声明的直接依赖以及一个.pnpm目录。所有真实的依赖包都放在.pnpm目录中其他位置通过链接指向这里。这里用一张典型结构来说明node_modules/ ├── .pnpm/ │ ├── express4.18.2/ │ │ └── node_modules/ │ │ ├── express/ # 硬链接到全局 store │ │ ├── accepts/ # 符号链接 │ │ └── ... # express 的其它依赖 │ ├── accepts1.3.8/ │ │ └── node_modules/ │ │ ├── accepts/ # 硬链接到全局 store │ │ └── mime-types/ # 符号链接 │ └── ... ├── express - .pnpm/express4.18.2/node_modules/express原理拆开来看是这样.pnpm目录中的真实包文件通过硬链接从全局 store 关联不重复占用磁盘。每个包都有自己的一层node_modules里面只放它声明过的依赖通过符号链接指向.pnpm下对应的具体版本。项目根目录的node_modules只暴露直接依赖的符号链接未声明的包不会出现在这里。换言之pnpm 用“内容寻址存储”解决了重复下载问题用“符号链接 每包独立节点”解决了依赖可见性问题。快和省空间是结果依赖隔离才是它更重要的设计目标。拿 npm、Yarn、pnpm 做个小对比对比维度npmYarn Classicpnpmnode_modules 布局扁平化提升扁平化提升符号链接 .pnpm 目录幽灵依赖常见常见默认被拦截磁盘占用每个项目重复安装每个项目重复安装全局 store 共享安装速度一般较快快有缓存时更快Monorepo 支持有 workspace有 workspaceworkspace 是一等公民依赖隔离严格度低低高5. 核心原理pnpm 如何做到“没声明就不能用”现在到了最关键的章节pnpm 到底是怎么做到“没声明就不能用”的。先看单包项目。假设你的package.json只声明了express没有声明lodash。npm 安装后由于依赖提升node_modules里大概率会出现lodash因为express的某个间接依赖是lodash。你写require(lodash)能跑。pnpm 安装后node_modules里只有.pnpm和express的符号链接。lodash只存在于.pnpm/lodash某版本/node_modules/lodash它是express依赖树的一部分并不会出现在你的根node_modules里。Node 做模块解析的时候是从当前文件目录开始逐级向上查找node_modules。对项目源码来说它只能看到根目录的node_modules以及更上层目录的node_modules。pnpm 没有把lodash放在这些地方所以 Node 找不到它最终报错MODULE_NOT_FOUND。这就是“没声明就不能用”的机制来源pnpm 让未声明的依赖在模块解析路径上不可见。再看 Monorepo 中的子包。每个子包都会有自己的node_modules但 pnpm 只会为这个子包安装它在package.json中声明的依赖其他所有包都被藏在.pnpm内部。子包代码试图require未声明依赖时Node 在自己的node_modules里找不到就报错。这样就把幽灵依赖的生存空间压缩到了最小。这里有一个容易混淆的点需要说清楚。pnpm 的依赖隔离核心是“包自身声明的依赖才可见”。但在 Monorepo 根目录同时声明了大量依赖的情况下子包代码使用 Node 解析时依然有可能沿目录向上找到根node_modules里的包。所以实践中有两类问题要区分pnpm 保证的是它不会像 npm 那样通过提升让未声明依赖“自动可见”。pnpm 不承诺的是你的子包完全看不到 workspace 根目录的依赖。Node 的向上查找机制不受 pnpm 控制。真要严格隔离需要在代码规范层面对子包做约束同时让每个子包显式声明它自己的依赖。pnpm 给了你一个很好的默认地基但工程规范还需要人来定。关于面试回答你可以这样组织pnpm 使用内容寻址存储把所有包放在全局 store 中项目内使用.pnpm目录作为虚拟商店。每个包在.pnpm内部有独立的node_modules只包含该包显式声明的依赖并通过符号链接组织依赖关系。项目根目录的node_modules也只暴露直接声明依赖的符号链接。因此未声明依赖不会出现在模块解析路径中Node 自然找不到也就实现了“没声明就不能用”。这段话信息量足够至少能证明你是理解原理而不是背答案。6. 环境准备与安装理论讲清楚了下面进入实操。先配置好 pnpm 环境。6.1 安装 pnpm推荐三种方式任选一种即可。方式一通过 npm 全局安装。npm install -g pnpm方式二通过 corepack 启用。corepack 是 Node.js 自带的包管理器管理工具使用前需要先启用。corepack enable启用后可以在package.json中声明packageManager字段这样团队所有人都会自动使用指定版本的 pnpm{ packageManager: pnpm10.4.1 }方式三在 Windows 上如果安装后提示“pnpm 不是内部或外部命令”通常是环境变量没有配置好。先执行下面的命令确认 npm 的全局目录npm config get prefix然后把输出的目录例如C:\Users\你的用户名\AppData\Roaming\npm加到系统 PATH 中重启终端即可。6.2 检查 Node 版本新版 pnpm 对 Node 版本有最低要求。如果安装或运行时报类似错误error: this version of pnpm requires at least node.js v22.13说明当前 Node 版本过低。处理方式有两种升级 Node 到 pnpm 要求的版本或者安装与当前 Node 版本兼容的 pnpm 版本。不要忽略这个提示直接强跑可能导致安装异常。6.3 配置国内镜像网络环境不理想时安装依赖可能很慢或频繁失败。可以配置国内镜像pnpm config set registry https://registry.npmmirror.com验证配置是否生效pnpm config get registry安装完成后运行pnpm -v能看到版本号就说明环境已经就绪。7. 完整实战pnpm workspace 与幽灵依赖验证下面用一个最小 Monorepo 示例演示 pnpm workspace 的配置并验证“没声明就不能用”。7.1 创建项目结构my-monorepo/ ├── package.json ├── pnpm-workspace.yaml └── packages/ ├── utils/ │ ├── package.json │ └── index.js └── app/ ├── package.json └── index.js根目录package.json{ name: my-monorepo, private: true, scripts: { test:app: pnpm --filter app start } }创建一个pnpm-workspace.yaml声明 workspace 包含的包目录packages: - packages/*这里的packages/*表示packages目录下的所有一级子目录都属于同一个 workspace。7.2 创建公共工具包 utilspackages/utils/package.json{ name: demo/utils, version: 1.0.0, main: index.js, dependencies: { lodash: ^4.17.21 } }packages/utils/index.jsconst lodash require(lodash); function formatName(name) { return lodash.startCase(name); } module.exports { formatName };这个包本身声明了lodash所以它可以正常引用。7.3 创建业务包 apppackages/app/package.json{ name: demo/app, version: 1.0.0, dependencies: { demo/utils: workspace:* } }注意这里的关键点demo/app只声明了demo/utils没有声明lodash。packages/app/index.jsconst { formatName } require(demo/utils); function main() { console.log(formatName(hello world)); } main();7.4 安装依赖并验证 workspace 链接在根目录执行安装pnpm install安装完成后检查packages/app的node_modulesls -la packages/app/node_modules你看到的是demo/utils的符号链接指向.pnpm虚拟商店而不会看到lodash的顶层目录。demo/app能使用demo/utils是因为workspace:*协议让 pnpm 在安装时自动建立了 workspace 内部链接。运行 apppnpm --filter demo/app start正常情况下输出Hello World7.5 验证幽灵依赖被拦截现在故意在packages/app/index.js里直接引用未声明的lodashconst lodash require(lodash); function main() { console.log(lodash.startCase(hello world)); } main();然后再次运行pnpm --filter demo/app startpnpm 会阻止模块解析Node 找不到lodash最终报错Error: Cannot find module lodash Require stack: - /my-monorepo/packages/app/index.js这个例子非常直观地说明了 pnpm 的依赖隔离能力lodash明明被安装在 monorepo 里demo/utils依赖它但因为demo/app没有声明它所以demo/app在代码里访问不到。如果想用必须先在packages/app/package.json里显式加上lodash然后重新执行pnpm install。8. 常见问题与排查方法pnpm 相关的问题很多来自安装和环境配置。这里把常见的坑汇总成一张表方便排查。问题现象可能原因排查方式解决方案执行pnpm提示“不是内部或外部命令”npm 全局目录未加入 PATH或未正确启用 corepack执行npm config get prefix检查目录是否在 PATH将全局目录加入 PATH重启终端或执行corepack enable安装报错“requires at least node.js v22.13”Node 版本过低node -v查看版本升级 Node或安装兼容当前 Node 的 pnpm 版本pnpm install卡住或下载慢默认 registry 网络不稳定pnpm config get registry设置为国内镜像后重试安装后提示运行pnpm approve-builds新版 pnpm 默认不执行依赖的 postinstall 构建脚本查看提示中的依赖名确认真实需要后执行pnpm approve-builds选择允许的依赖workspace:*依赖无法解析workspace 目录未配置或包名不匹配检查pnpm-workspace.yaml和包名确认packages/*配置正确包名一致后重新pnpm install子包代码引用根目录依赖“居然能用”Node 模块解析沿目录向上查找根依赖被意外访问到检查子包是否显式声明在子包package.json中补齐依赖避免依赖向上查找机制另外补充一个关于pnpm approve-builds的背景。pnpm 10 之后出于安全考虑默认不会自动运行依赖包中的postinstall脚本因为这类脚本在安装时会被执行等于代码在拿到你的环境权限。如果你确实需要某个依赖安装后执行构建脚本运行pnpm approve-builds然后按提示选择。这一步既是为了安全也是新版 pnpm 的一个明显变化很多人第一次升级后安装esbuild、sharp这类依赖时会遇到。9. 最佳实践与工程建议pnpm 在 Monorepo 里用得好的团队通常不只是把它当“另一种包管理器”而是把它和工程规范绑定在一起。下面是几条实际项目中比较有价值的建议。9.1 团队只保留一种包管理器最忌讳的是团队混用 npm 和 pnpm。npm 生成的package-lock.json和 pnpm 的pnpm-lock.yaml不同的锁文件格式一旦混用node_modules结构会被重复改变CI 构建结果也会不一致。建议在根目录加上packageManager字段锁定 pnpm 版本同时把package-lock.json和yarn.lock加入.gitignore。9.2 严格约束每个子包声明自己的依赖pnpm 能拦截幽灵依赖但如果子包代码通过根目录向上查找依赖还是会出现“碰巧能用”的情况。团队应该把“每个包显式声明自己需要的依赖”作为 Code Review 的基本检查项。如果条件允许可以在 CI 中增加检查脚本或 ESLint 插件来验证依赖声明。9.3 善用 workspace 协议Monorepo 内部包之间的依赖推荐使用workspace:*协议{ dependencies: { demo/utils: workspace:* } }这样 pnpm 在本地开发时自动链接 workspace 内的包发版时再通过pnpm publish或发布流程替换为实际版本号。手动写死^1.0.0版本号会让本地开发和线上发布出现不一致。9.4 统一 Node 版本Monorepo 参与人数多如果每个人本机 Node 版本不一样依赖安装结果和运行行为都可能不一致。建议在.nvmrc或.node-version文件中声明 Node 版本并让 CI 使用同一个 Node 版本。这在 pnpm 新版本对 Node 版本要求比较高的情况下尤其重要。9.5 不要把pnpm-lock.yaml加进.gitignorepnpm-lock.yaml是 pnpm 的锁文件它记录了完整的依赖解析结果。提交它可以保证团队成员和 CI 使用一致的依赖版本也是复现问题的重要依据。很多团队第一次用 pnpm 时习惯性忽略锁文件这是非常危险的。9.6 存量项目迁移要渐进如果现有项目正在使用 npm并且已经存在大量幽灵依赖不要指望一次性切到 pnpm 就能通过。切换时大概率会暴露一批“之前能用但其实没声明”的依赖。合理的方式是先创建一个小范围试点包切到 pnpm根据报错逐个补齐声明依赖再逐步扩大到整个 Monorepo。迁移期间可以临时使用pnpm install生成的报错信息来反推缺失依赖。10. 总结与面试回答思路回到开头的场景。当面试官问“pnpm 怎么做到没声明就不能用时”最忌讳的回答是“因为 pnpm 会隔离依赖”这种循环论证。更好的回答是讲清楚三个层次第一层npm/Yarn 的依赖提升导致未声明依赖也出现在顶层node_modules这是幽灵依赖的根源。第二层pnpm 使用全局内容寻址存储项目内用.pnpm目录保存所有包的真实文件。第三层pnpm 为每个包单独创建node_modules只包含该包显式声明的依赖并通过符号链接组织依赖关系。未声明依赖不会出现在模块解析路径中Node 找不到自然就用不了。在 Monorepo 场景下pnpm 的 workspace 能力让子包之间的引用可以显式声明配合pnpm-lock.yaml锁定版本工程的依赖管理会清晰很多。但它不是银弹Node 的向上查找机制、子包依赖声明的规范性、Node 版本统一仍然需要工程规范来约束。建议你跟着文章里的 demo 亲手搭一个最小 Monorepo专门做一个“未声明依赖访问失败”的验证。把这个过程跑通你对 pnpm 的理解会从“听说”变成“实践过”。下一次面试被追问时你就能把一个机制问题回答得有理有据。如果这篇文章对你有帮助建议收藏备用。后续你也可以继续研究 pnpm 的hoist-pattern、public-hoist-pattern配置以及pnpm approve-builds带来的供应链安全变化这些都是项目深度改造时会遇到的实际问题。