前端圈子里对 node_modules 的感情差不多是又爱又恨。十年了我们早就习惯它的存在可它带来的问题也越来越让人无法假装看不见。前几天我帮朋友清理一台开发机磁盘里躺着十几个前端项目每个 node_modules 动辄几个 GB加起来比系统镜像还大。更难受的是跑完yarn install之后明明什么都没改第二次构建却在某个传递依赖上翻车。这种“玄学”问题根子多半就出在 node_modules 这套物理目录方案上。Yarn 的 PnPPlug and Play即插即用就是冲着这个问题去的。它从 Yarn 1 的实验版本开始冒头到 Yarn 2Berry正式成为默认的链接方案再到如今生态已经相当成熟。简单说启用之后你的项目不再生成 node_modules 目录依赖关系变成一张由 Yarn 维护的地图所有包以 zip 形式集中存放Node 在运行时按需加载。这篇文章会从原理、实操、IDE 配置到排坑经验完整过一遍 Yarn PnP。它适合正在考虑迁移的老项目团队也适合刚接触 Yarn 2 的新手。顺便提醒一下搜错词的朋友如果你要找的是 Hadoop 里的 YARN资源调度器或者电子学里的 PNP 三极管这篇文章帮不上忙但如果你想知道“不用 node_modules 怎么跑前端项目”那请继续往下读。1. node_modules 时代的三个顽固问题为什么要折腾出 PnP1.1 安装速度和磁盘占用真的到了离谱的程度不夸张地说一个中型 React 项目的 node_modules 能轻松突破 1GB大型 monorepo 更是常见 5GB 往上。我之前做过一个数据可视化中台项目依赖树里光是一个electron连带它的各种二进制包就能吃下 300MB。几百个包平铺复制成物理文件每个包都可能出现 3、4 个版本比如lodash4.17.21出现在 37 个不同目录下这 37 份代码是完全相同的却硬生生占着 37 份磁盘空间。安装速度也是同样的逻辑。机械地把几万个文件从缓存复制到 node_modules这个 IO 开销根本绕不开。就算网络再快到了本地解压、复制、递归创建目录这一步也会把安装时间拖到几分钟。Yarn 的 PnP 把这些问题一起解决掉每个包只保留一个 zip 归档项目里没有任何物理目录复制行为。我实际测过一个 40 多个 workSpace 的 monorepo传统 node_modules 方式首次安装大约需要 4 分 20 秒切到 PnP 后首次安装只需要 1 分 10 秒左右。后续的增量安装更是离谱很多时候 5 秒内就能完成因为 Yarn 只需要对比 lockfile 判断哪些 zip 需要新增或删除。1.2 幽灵依赖最阴险的“能跑就行”node_modules 的解析规则是Node 从当前文件所在目录开始逐级向上查找只要某个node_modules目录下存在同名文件夹就算命中。这个设计带来的副产品就是幽灵依赖phantom dependency。什么叫幽灵依赖举个例子你的项目安装包 AA 依赖了 B。按 package.json 的规则你的代码根本不应该直接require(b)因为你没有声明 B 这个依赖。但是在 node_modules 的物理布局下B 的目录就在 A 的node_modules旁边或者更上层你的代码依然能直接require(b)还跑得好好的。这个问题有多阴险某天 B 发了一个破坏性升级或者 A 决定不再依赖 B你的项目就突然在运行时炸掉并且报错信息完全看不出和 B 有任何关系。我在生产环境里排查过一个诡异问题某个内部组件库的sass工具函数突然找不到chalk了项目代码里根本没有直接引用 chalk后来定位到是那个组件库悄悄升级把自己的 chalk 传递依赖移除了。如果一开始就启用 PnP 的严格隔离机制这种问题根本不会出现。1.3 构建链路的不确定性传统 node_modules 还有一个隐含问题同一个 lockfile在不同机器上生成的物理目录结构可能不完全一致。尤其是横跨 Windows、macOS、Linux 的团队符号链接的处理、大小写敏感的差异、某些包 postinstall 脚本在不同平台上的行为都会让 node_modules 的最终状态产生微小差异。这些差异平时不显山露水一旦 CI 和生产环境出问题排查成本就很高。PnP 的思路是把所有依赖的信息固化到一份.pnp.cjs文件里这个文件的解析结果是确定性的。同样的 lockfile 同样的.pnp.cjs在任何机器上解析出来的依赖位置都是完全一致的从根源上消除了这种不确定性。2. PnP 的核心机制从“物理目录查找”到“逻辑地图解析”2.1 没有 node_modules依赖到底放到哪里去了PnP 模式下Yarn 会把所有依赖包打包成 zip 文件放在项目根目录的.yarn/cache文件夹里。每个 zip 文件名带有哈希后缀例如lodash-npm-4.17.21-6382451549.zip这个哈希由包名、版本、解析源等信息计算得出。这些 zip 文件实际上遵循内容寻址的原则如果多个项目使用了同一个版本的同一个包理论上可以共享缓存。Yarn 还有一个全局缓存目录位于系统用户目录下比如 macOS 上是~/.yarn/berry/cache项目里的.yarn/cache可以理解为全局缓存的“快照”或链接副本。在 PnP 模式下如果配置了enableGlobalCache: true项目本地就不再重复存放 zip而是全部通过全局缓存提供。这也是zero-install策略能成立的关键把.yarn/cache连同.pnp.cjs一起提交到 Git克隆仓库后不需要任何 install 步骤直接跑命令就能启动项目。2.2.pnp.cjs就是那张“地图”你可能会问没有 node_modulesNode 在require(lodash)的时候怎么知道要加载哪个 zip 文件答案就在.pnp.cjs文件里。.pnp.cjs是一个由 Yarn 生成的 JavaScript 文件本质上是一个自执行的运行时解析器。它包含两部分内容第一部分是完整的依赖关系表记录了每个包的位置、版本、依赖项以及彼此之间的引用关系第二部分是一个自定义的 resolve 函数它接管了 Node 的原生模块查找逻辑。当你在 PnP 项目里执行node server.jsYarn 会通过 Node 的--require机制预加载.pnp.cjs把模块解析器替换掉。此时遇到require(lodash)解析器直接查表得到 lodash 对应的 zip 路径以及它依赖的子包列表然后通过虚拟文件系统接口读取 zip 内部的index.js。这套设计的精髓在于依赖解析从“文件系统搜索”变成了“哈希表查询”。前者是 O(n) 级别的路径遍历后者是 O(1) 的确定性查找性能和稳定性都有了质的变化。2.3 严格限制你的包只能访问它声明过的依赖PnP 在解析规则上和 node_modules 最大的区别是严格的依赖隔离。传统模式下A 包的代码里可以require(anything)只要文件系统里存在就行PnP 模式下解析器会检查“当前正在执行的这个包”到底在 package.json 里声明了哪些依赖只在这些声明过的依赖范围内查找。换句话说A 包声明了 B 和 C那它的代码就能 require B 和 C如果 A 的代码试图 require 根本没声明过的 D解析器直接抛错“Your application tried to access D but it isnt declared in your dependencies。”我发现这个报错非常有价值它直接把无数潜伏的问题暴露在了编译期和启动期。很多人第一次接触 PnP 时会被这个报错激怒觉得它“太严格了”。实际上这种严格才是正确的。你想想运行时依赖一个没声明过的包本就不该被允许。它就像你搬家时发现一个箱子里的东西不属于自己与其等到新家拆箱才发现缺东西不如在装箱时就分类清楚。2.4 zip 文件是怎么变成可执行代码的zip 存的是压缩包Node 不可能直接执行压缩包里的文件所以 Yarn 在解析器里内置了一层虚拟文件系统叫做 ZipFS。它本质上是一个基于 memory 的只读文件系统读取 zip 中的文件时会先把文件流解压到内存再提供给 Node 使用。这里有一个性能细节对于源码文件按需解压的开销非常小因为开发时被读到的文件就那么几十个但对于某些依赖比如需要读取整个目录列表的 glob 场景频繁的目录遍历会带来一些性能损耗。遇到这种情况Yarn 会把 zip 解压到系统临时目录通过fs的真实路径进行操作。如果你在代码里使用__dirname做路径拼接在 PnP 模式下可能会得到.yarn/cache/xxx.zip/node_modules/...这样的虚拟路径。官方提供了pnpapi模块可以帮助你获取真实的物理路径或者使用require.resolve拿到实际文件位置。3. 从零启动一个 PnP 项目Yarn Berry 实操全流程3.1 先装 Yarn Berry 并切到目标版本这里的“Berry”指的是 Yarn 2.x/3.x/4.x 的代号。很多朋友在系统里安装的还是 Yarn 1.x跑yarn -v出来是 1.22.x那 PnP 也能用但 1.x 的 PnP 属于实验功能体验远不如 Berry 成熟。我建议直接使用 Berry。最快的安装方式是用 CorepackNode.js 16.10 以上自带corepack enable corepack prepare yarnstable --activate如果你的 Node 版本比较老也可以用 npm 全局安装npm install -g yarn这个命令默认装的是 Yarn 1.x装完后再在项目目录里执行yarn set version stable运行完之后项目里会生成一个.yarn/releases/yarn-3.x.x.cjs文件之后在这个项目里执行的所有yarn命令都会由这个项目级 Yarn 来处理而不是依赖全局版本。这种“项目即编译器”的思路我很喜欢团队里所有人用到的 Yarn 版本完全一致不会出现“我这台机器能跑你不行”的情况。3.2 初始化项目和第一次安装在空目录里执行yarn init -y yarn add react react-dom如果你用的是稳定版 Berry默认nodeLinker就是pnp所以执行完yarn add之后你会看到一个神奇的现象项目目录下没有 node_modules取而代之的是.pnp.cjs文件以及.yarn/cache目录里多出的几个 zip。这个时刻其实是整个流程里最“反直觉”的一刻。我以前培训团队时做过一个实验用 PnP 模式初始化一个 Vite React 项目然后执行yarn dev浏览器正常弹出页面。有同事第一反应是“它在骗我肯定还有别的隐藏依赖”。没有隐藏依赖就是一张地图加一堆 zip运行得干干净净。如果你希望显式声明使用 PnP可以在项目根目录的.yarnrc.yml文件中写上nodeLinker: pnp3.3 三个关键文件的职责划分PnP 项目下这几个文件的角色需要搞清楚.yarnrc.ymlYarn Berry 的配置文件包括 nodeLinker、enableGlobalCache、packageExtensions、allowedBuildScripts 等核心设置。.pnp.cjs依赖解析地图由 Yarn 自动生成建议提交到 Git。.pnp.loader.mjs当使用 ESM 模式运行 Node 时需要的加载器同样建议提交。.yarn/cachezip 依赖包存放目录。如果走 zero-install 路线就提交如果团队习惯传统 node_modules 就加入 .gitignore。很多团队会对“提交.pnp.cjs” 有顾虑觉得这类生成文件不该进版本库。我的经验是如果不是做 zero-install也可以不提交让每个成员各自生成一次就行。但提交的好处非常明显CI 里不需要重新解析 lockfile克隆完直接跑测试速度肉眼可见地快。3.4 老项目迁移的完整路径与注意点把一个传统 Yarn 1 npm/Yarn 项目迁移到 PnP最大的障碍不是配置而是依赖兼容性。建议按照下面的顺序操作先确保所有依赖已经在 package.json 里声明清楚消除幽灵依赖。删除node_modules和旧的yarn.lock。执行yarn set version stable。执行yarn install观察报错。此时大概率会遇到两类报错一类是某些包没有正确声明 peerDependencies另一类是原生模块或 postinstall 脚本需要额外签名确认。逐个处理即可不必惊慌。迁移过程中我强烈建议先把nodeLinker: node-modules临时打开让项目跑起来然后再切换到pnp。这样可以区分“代码本身的问题”和“PnP 模式导致的兼容性问题”避免一次性面对太多变量。4. 编辑器与工程链适配IDEA、WebStorm、VSCode 的 PnP 配置4.1 为什么 IDE 经常报“模块找不到”PnP 模式下没有 node_modulesIDE 自带的模块解析器不知道去哪里找依赖因此会出现一个奇特的现象命令行里项目跑得好好的编辑器里却到处飘红。这是因为 IDE 默认用自己的解析逻辑扫描文件系统而不使用 Yarn 的.pnp.cjs解析器。解决办法不是让 IDE 去构建一套新的解析器而是让 IDE 复用 Yarn 提供的 SDK。Yarn 官方专门做了一套虚拟 SDK 生成机制可以在项目里生成自定义的 TypeScript、ESLint、Jest 等工具链入口。4.2 VSCode 的配置步骤在项目根目录执行yarn sdks vscode这个命令会在.yarn/sdks目录下生成一系列 SDK 文件同时修改.vscode/settings.json把 TypeScript、ESLint、Prettier 等工具的路径指到 SDK 目录。生成的 settings.json 类似这样{ typescript.tsdk: .yarn/sdks/typescript/bin, search.exclude: { **/.yarn: true, **/.pnp.*: true }, eslint.nodePath: .yarn/sdks }之后重启 VSCode打开一个 TS 文件右下角选择 “Use Workspace Version” 的 TypeScript编辑器就能准确识别所有类型。4.3 WebStorm / IntelliJ IDEA 里的正确姿势JetBrains 系从 2020.2 版本开始就对 Yarn 2 PnP 提供了专门支持。操作路径是打开 Settings → Languages Frameworks → Node.js把 Package manager 选成 Yarn。然后把 Yarn 的路径指向项目.yarn/releases/yarn-3.x.x.cjs这个文件而不是全局 yarn。这样做的好处是 IDEA 使用的 Yarn 版本和你的项目一致。接着在 TypeScript 设置里把 TypeScript 版本指向.yarn/sdks/typescript/bin下的 tsc 入口。不同 IDEA 版本界面名称略有差异但关键词就是“TypeScript”、“SDK”、“Yarn”。如果你需要给团队统一配置可以把这些选项写入项目根目录的.idea文件夹配合.editorconfig一起提交方便新人一键同步。4.4 TypeScript、ESLint、Jest 的适配细节TypeScript 在 PnP 模式下有一个官方支持的问题TS 的模块解析算法和 Node 原生解析算法不完全一致所以必须用 Yarn 生成的 SDK 版 TypeScript它内部接入了pnpapi解析器才能正确识别 zip 内的声明文件。ESLint 同理。如果你用的还是老式的.eslintrc没有提供nodePath配置大概率会报错找不到插件。建议在.yarnrc.yml里通过packageExtensions把 ESLint 的插件依赖关系补全或者在 ESLint 的配置里指定settings里的node解析路径。Jest 需要额外安装一个适配层yarn add -D yarnpkg/pnpify然后在 Jest 配置里使用yarnpkg/pnpify提供的jest入口或者使用 Yarn 3 自带的yarnpkg/plugin-jest。更简单的方案是使用官方提供的yarn sdks jest自动配置。5. 容易“爆雷”的几个场景与处理经验5.1 原生模块和 postinstall 脚本的“不信任”机制PnP 模式默认对依赖包的 postinstall 脚本采取“不信任”策略。理由很简单因为不再生成 node_modules所有包都集中在缓存里如果允许任意包执行 postinstall那这些脚本就有机会污染全局缓存或者执行任意代码安全风险太高。因此像esbuild、sharp、node-sass这类依赖原生模块或 postinstall 脚本的包在 PnP 模式下安装时会提示需要手动确认。处理方式是在.yarnrc.yml里声明允许构建的包allowedBuildScripts: - esbuild - sharp - core-js声明之后Yarn 才会运行这些包的 postinstall 脚本从全局缓存里下载对应平台的二进制文件。我在实战中遇到的另一个坑是某些包的 postinstall 脚本在 PnP 虚拟文件系统下会尝试写入node_modules由于目录不存在而报错。这时可以通过packageExtensions给该包添加dependencies或者修改peerDependencies迫使 Yarn 在解析时把相关依赖装配好从而绕过脚本本身的逻辑。5.2 包用绝对路径或者 fs 写操作怎么办有少量老旧包会在运行时强制使用fs.realpathSync(__dirname)或fs.readdirSync读取真实目录这类代码在 zip 虚拟文件系统里会失败。遇到这种情况第一选择是检查有没有新版或者替代包第二选择是给该包单独设置installConfig字段让 Yarn 在安装时把它解压到真实的临时目录而不是保留 zip{ dependencies: { legacy-tool: npm:legacy-tool^1.0.0 }, installConfig: { pnp: false } }installConfig.pnp: false的意思是这个包不用 PnP 模式链接还是走物理目录方式。注意这是针对“某个特定包”的例外处理不是全局关闭 PnP。它的原理是让 Yarn 在虚拟文件系统和真实文件系统之间做一层桥接牺牲一些一致性来换取兼容性。5.3 npx、gulp、webpack 等工具的协作问题使用 PnP 后很多全局工具会失灵因为它们不知道如何解析 PnP 项目的依赖。最优解是用yarn exec替代npxyarn exec eslint src/ yarn exec tsc --noEmityarn exec会把项目中的依赖路径正确注入还会自动带上 PnP 解析器比直接调用全局命令要可靠得多。如果你在 CI 里默认运行npx playwright install这类命令改成yarn exec playwright install能避免大量莫名奇妙的问题。对于 webpack、Vite 这类构建工具它们内部依赖的是 Node 标准解析逻辑所以需要在构建入口做一层兼容。Vite 在 3.0 之后对 PnP 的支持已经比较完善直接能跑webpack 4 则需要安装pnp-webpack-plugin并在 webpack.config.js 里显式声明const { PnpWebpackPlugin } require(pnp-webpack-plugin); module.exports { resolve: { plugins: [PnpWebpackPlugin] }, resolveLoader: { plugins: [PnpWebpackPlugin.moduleLoader] } };webpack 5 自身已经带了对 PnP 的 patch一般不需要额外插件。万一遇到问题先查一下该构建工具是否在依赖项里声明了yarnpkg/pnp。5.4 和 pnpm、node-modules 模式来回切换的注意事项有些团队会同时在多个项目里使用 pnpm 和 Yarn或者同一项目从 node-modules 模式切到 pnp 再切回来。这里有一个很关键的点.yarnrc.yml里的nodeLinker决定了安装方式而yarn.lock是共享的。从node-modules切到pnp不需要重新解析 lockfileYarn 会复用原来的 lock 数据重新生成.pnp.cjs。但如果你在 node-modules 模式下的 node_modules 目录没有删干净会存在旧缓存干扰建议先物理删除再切。从 pnpm 切到 yarn则一定要删掉node_modules/.pnpm和pnpm-lock.yaml。pnpm 的硬链接机制在某些情况下会在.yarn目录里留下残留符号链接导致 Yarn 在解析时误判。我在 monorepo 场景里遇到过两次清理后一切正常。6. 到底要不要全面切换 PnP我的选型判断6.1 适合 PnP 的项目画像如果你的项目满足以下特征建议尽快切换依赖数量多、体积大尤其是 monorepoPnP 能大幅降低安装时间和磁盘占用。团队成员激进升级依赖经常出现“本地能跑、CI 挂了”的情况PnP 的确定性可以淘汰一批环境差异问题。希望在 CI 里做到 zero-install减少无谓的网络请求和安装耗时。对依赖安全有要求不希望幽灵依赖悄悄进入生产代码。我的一个数据可视化的项目在切到 PnP 后 CI 构建时间从 11 分钟降到 6 分钟其中安装步骤从 3 分钟变成 20 秒。团队之后的体验是“回不去了”没人愿意再切回 node_modules。6.2 不适合 PnP 的项目画像反过来下面这些项目我建议谨慎依赖大量老旧包而这些包有未修复的 fs 硬编码问题。某些内部私有源包没有正确声明 peerDependencies又无法迅速修改时PnP 的严格解析会频繁报错。团队对 Yarn Berry 的配置体系不熟迁移成本大于收益。项目里混合着 Gradle、Maven 等非 Node 工具链构建脚本深度依赖 node_modules 物理路径。这些都是真实存在的困扰。我也接过一个老项目因为某个核心绘图库在 Linux 下无法正常解压 zip拖了整整两周最后用installConfig.pnp: false绕过。所以千万别把 PnP 当银弹它适合很多场景但不是全部。6.3 折中方案node-modules 链路 PnP 的思路如果你还是不敢全面切换但又想享受 PnP 的部分好处可以用折中方案保留nodeLinker: node-modules但把全局缓存打开让依赖的 zip 缓存共享。这个模式下Yarn 依然会生成.yarn/cache但会额外生成物理 node_modules 目录兼容性和速度都有一定改善。这本质上是把传统模式和 PnP 模式之间的差距缩小到“只差一个解析器”。你可以在一个验收分支上把 nodeLinker 临时改成 pnp跑通主流程后再合并观察一段时间再决定是否全面推进。最后分享一个我自己实际操作中的小技巧启用 PnP 后随手在 package.json 里加一个scripts.postinstall内容写yarn sdks这样每次安装依赖后都会自动同步生成 IDE 的 SDK。这样团队里每个人都少踩一个“编辑器飘红”的坑。说到底PnP 不是一个“必须选”的方案而是一个“有条件时值得选”的方案。它的核心价值在于把依赖管理从体力活变成脑力活把不确定性变成确定性。你不需要理解每一项原理才能开始用但理解了这些细节之后遇到问题时的恐慌感会少很多。
阅读完成 · 觉得有帮助?