我刚帮一个项目从 npm 切到 pnpm历时两天中间差点被团队骂死。倒不是说切换本身多难——删 node_modules、装一遍、跑测试半天就完事。真正折腾的是切换后发现一堆代码在 npm 下能跑、到 pnpm 下就报Cannot find module那种问题一多团队第一反应永远是新工具不行而不是我们项目里的依赖声明一直不干净。这件事让我想认真写一篇对比。网上讲 npm、Yarn、pnpm 的文章不少但大多是罗列功能点比如npm 有锁文件pnpm 磁盘占用小。真正搞清楚这三者为什么会有这些差别的其实很少。而我认为搞懂了它们对 node_modules 的组织哲学功能差异根本不用背。如果你正准备在团队里换包管理工具或者想弄明白为什么同事苦口婆心劝你弃用 npm这篇内容应该能帮到你。我会从目录结构的历史演变讲到三种工具的安装机制再结合我在真实项目里踩过的坑最后给一套可落地的选型建议。1. 都是安装依赖为什么能争十年1.1 包管理器的本质解析、获取、链接很多人把包管理器理解成下载依赖的工具这是最浅的一层。往前再走一步包管理器其实干三件事解析resolution、获取fetch、链接link。解析是根据 package.json 里声明的语义化版本范围比如^18.2.0结合当前环境和锁文件决定到底该装哪个精确版本。获取是把选定的包从镜像源下载到本地。链接则是把下载下来的包放进项目的 node_modules并处理包与包之间的相互依赖关系。这三件事里npm、Yarn、pnpm 的差异主要集中在第三件——链接。说句可能有点绝对的话把链接方式搞懂了工具之间的对比你基本就通了。下载速度、磁盘占用、幽灵依赖、安全隔离全部根源于怎么把包放进 node_modules这个设计决策。1.2 为什么 node_modules 的摆放方式这么重要这就要说到 Node.js 的模块解析机制。你在代码里写require(react)Node 会从当前文件所在目录开始向上逐级查找 node_modules 目录。当前目录没有就往上一级找再没有继续往上直到文件系统根目录。这个逐级向上查找的机制意味着只要依赖被放在了路径上的任意一个 node_modules 里require就可能找到它。所以问题的关键不在于能不能找到而在于能找到哪些、找不到哪些。npm、Yarn、pnpm 的争论本质上是三种哲学:npm 早期追求只要能装上就行结果装出了嵌套地狱npm v3 和 Yarn 追求尽量扁平化、减少重复结果留下了幽灵依赖pnpm 干脆回到非扁平结构但用符号链接绕开了嵌套的低效用一个生活化的比喻来帮助理解。npm v2 时期是每家店各自建仓库货送到店门口家家各囤一份。npm v3 之后是几家店共用一个公共仓库货统一放谁要用谁去拿。pnpm 则是全城只有一个总仓每家门牌上挂一个牌子牌子直接指向总仓里那一份货。牌子就是符号链接总仓就是全局内容寻址存储。三种方案谁优谁劣取决于你怎么衡量空间换时间和隔离换便利。下面我分别拆开讲。2. npm 的两次转身从嵌套地狱到扁平化治理2.1 v2 时代的嵌套地狱如果你是近几年才入行的前端可能没经历过 npm 最黑暗的时期。npm v2 及更早版本node_modules 是完全嵌套的项目依赖 AA 依赖 BB 依赖 C那么物理目录就是node_modules/A/node_modules/B/node_modules/C。如果项目同时依赖 A 和 B而 A 依赖 C 的 1.x 版本B 依赖 C 的 2.x 版本C 就会被装两份分别嵌套在 A 和 B 下面。只声明了几十个直接依赖的项目装完 node_modules 里可能冒出一两千个目录因为传递依赖是层层嵌套、指数级增长的。这个方案的致命问题做过那年头 Windows 前端开发的人一定深有体会目录层级极深require查找要走很多层启动慢同一个包的不同版本被反复复制到磁盘体积巨大Windows 的MAX_PATH限制是 260 个字符路径一深删除 node_modules 都会报路径太长无法删除安装结果受安装顺序影响同一份 package.json 在不同机器上可能装出不同结构的依赖树最后一点尤其坑。A 和 B 都依赖 C 的不同版本时谁先被安装C1 就会嵌套在谁下面。你根本没法预测最终目录长什么样。这就是当时社区常说的依赖地狱。2.2 v3 的扁平化妥协hoisting 与幽灵依赖的种子npm v3 在 2015 年推出了扁平化安装机制核心思路是依赖提升hoisting。能提到顶层就尽量提到顶层安装每个包的时候如果它的某个传递依赖已经以兼容版本存在于顶层 node_modules就直接复用只有版本冲突时才把不兼容的那个嵌套在对应父级下面。带来的好处很直接目录深度大幅减小重复安装减少require查找命中率提升。当时大家普遍觉得问题解决了。但实际上它只是把问题从看得见的混乱变成了看不见的隐患。扁平化结构里一个依赖被提升到了顶层 node_modules意味着它的位置和你的直接依赖平级。而你并没有在 package.json 里声明它——于是你的业务代码可以直接require一个你从未声明过的包。这就是幽灵依赖phantom dependency的由来。典型场景是这样项目依赖了 reactreact 依赖了 loose-envify在 npm 的扁平化安装下loose-envify 被提升到顶层。你的代码写require(loose-envify)能跑。但哪天 react 升级不再依赖它或者换了安装算法loose-envify 不在顶层了你的代码就莫名报错。这种 bug 排查起来极其痛苦因为问题不在你写的代码里而在你依赖了一个未被声明的东西。而且扁平化不是银弹。依赖图复杂到一定程度时顶层布局并没有严格的确定性规则装出来的结构依然可能因安装顺序产生差异。2.3 迟到的 lockfilepackage-lock.json 与 npm v5 之后npm 一直拖到 2017 年的 v5 才正式引入 package-lock.json。这个迟到非常关键没有锁文件意味着 package.json 里的^1.2.3只是最低要求不是上限一切都以安装那一刻解析出的最新版本为准。这导致老项目隔几个月重新npm install一个传递依赖的小版本升级就可能让项目从能跑到崩。package-lock.json 做的事情其实是把解析阶段的产物固定下来记录每个包最终安装的精确版本、下载地址和校验值。有了它同一份代码在任何机器上、任何时间安装的结果都是一致的。npm v5 还引入了本地缓存后续安装速度也提升不少。但说实话当时的 npm 在缓存粒度、缓存命中率上都不如后来居上的 Yarn。每次回顾这段历史我都会感慨npm 是被 Yarn 逼着补完了确定性安装这门课的。package-lock.json 这个特性从技术上并不难做为什么拖了那么多年因为当时代谢太慢npm 更像是在能跑就行的方向上狂奔直到社区用脚投票。3. Yarn 的进攻与自我分裂当确定性成为第一原则3.1 2016 年前后的三个痛点慢、乱、险2016 年前后的 npm 有几个非常突出的问题。第一是慢网络请求和磁盘 I/O 的并发度都很低一个稍大的项目安装动辄三五分钟起步。第二是乱没有锁文件版本漂移严重今天能装的明天不能装。第三是险印象里是当时一个下载量极高的核心包被恶意版本污染大量项目在不知情的状态下把它装进了生产环境npm 的安全响应机制又慢社区信任度一度掉到谷底。Yarn 就是在这个节骨眼上出现的。它的定位非常精准就三个词快速、可靠、安全。这三个词正好打在 npm 的三个痛点上。3.2 yarn.lock、并行下载、离线缓存的组合拳Yarn 带来的核心变化我认为有三条一是确定性安装。yarn.lock 把所有依赖的解析结果——不只是直接依赖还包括每一个传递依赖——全部锁定为精确版本并记录下载地址和校验和。同一份 yarn.lock 加上同一份 package.json在任何机器上、任何时间安装都能得到完全一致的依赖树。二是并行下载。Yarn 会把需要下载的所有包排成依赖图并行发起请求而不是像 npm 那样分批串行下载。这一步对安装速度的提升是肉眼可见的。三是离线缓存。Yarn 把首次下载的包放进全局缓存目录第二次安装时直接从缓存取不需要再走网络。这和 npm 后来做的本地缓存思路一致但 Yarn 的全局缓存是跨项目的A 项目装过一遍B 项目再装同一个包就直接命中。这三件事合在一起把安装过程从不可预测变成了可预测。我认为这是包管理器历史上的分水岭——之前大家追求能装上之后大家追求装得对、装得一致。3.3 Classic 与 Berry 的分裂一个名字两个物种聊 Yarn 有一个绕不开的坑Yarn 现在其实是两个物种。Yarn 1Classic是 2016 年发布的那个版本普及度极高至今很多老项目的 README 里还写着yarn install。但官方早已停止维护安全补丁和 bug 修复基本不再提供。很多人完全没有意识到这一点还在把 Yarn Classic 当作长期依赖项。Yarn 2 之后的产品线叫 Berry它换了一套截然不同的哲学默认使用 PlugnPlayPnP模式。PnP 不再生成 node_modules而是生成一个.pnp.cjs文件里面记录了项目所有依赖在缓存 zip 包中的精确位置和解析规则。require时通过 Node.js 的解析钩子直接定位根本不需要在磁盘上创建物理目录。安装速度极快磁盘占用极小。但 PnP 的代价是兼容性——它假设生态里所有工具都理解零 node_modules 的世界。编辑器、TypeScript、ESLint 这些都需要额外适配很多旧的构建工具根本不认。因此 Yarn Berry 也保留了 node-modules linker 模式可以随时配置切回传统的 node_modules 方案。所以当有人跟你说Yarn 很强或Yarn 没人用了时最好先确认他说的是哪个 Yarn。这种分裂本身就是工具选型困境的一个缩影历史包袱和创新方向经常被混为一谈。4. pnpm 的非扁平叙事硬链接、符号链接与严格隔离4.1 内容寻址存储同一份依赖全机只有一份pnpm 的设计初衷和 npm、Yarn 都不一样。前两者主要在想依赖树怎么组织而 pnpm 先想到的是同一个版本的包物理上为什么要反复存那么多份。pnpm 的核心是一个全局的内容寻址存储content-addressable store。每个包精确到版本在 store 中的路径是由它自身内容的哈希值决定的。同一个版本的包不管被多少个项目使用物理上全机器只存在一份。安装流程大致是解析依赖图得到所有精确版本之后检查全局 store缺失的部分才从镜像源下载然后在项目的 node_modules/.pnpm 下为每个包创建硬链接最后在正确的位置创建符号链接把依赖关系串起来。这一步直接把下载 复制到 node_modules简化成了下载 创建链接。硬链接的创建是在文件系统层面完成的几乎不占 I/O。这也是 pnpm 安装速度快、磁盘占用低的根本原因——不只是 cache 做得好而是它连实体文件都只存一份。4.2 .pnpm 目录结构顶层只放你声明过的依赖pnpm 安装完的项目目录和 npm、Yarn 完全不同。用一张示意图说明node_modules/ ├── .pnpm/ │ ├── express4.18.2/node_modules/ │ │ ├── express/ # 硬链接到全局 store │ │ ├── accepts/ # 符号链接指向 .pnpm/accepts1.x │ │ └── ... │ ├── react18.2.0/node_modules/react/ │ ├── loose-envify1.4.0/node_modules/loose-envify/ │ └── ... ├── express - .pnpm/express4.18.2/node_modules/express └── react - .pnpm/react18.2.0/node_modules/react注意两个关键点第一项目根目录的 node_modules 顶层只有你直接声明的依赖以符号链接形式出现。你没声明过的包根本不会出现在顶层。第二每个真实包实体都放在.pnpm/包名版本/node_modules/包名下。当这个包内部的代码require(accepts)时Node 的向上查找会先从.pnpm/express4.18.2/node_modules开始找。这里放着 express 自己声明的依赖的符号链接。换句话说一个包能看到的世界严格等于它声明过的依赖世界。这就是严格隔离的含义。它同时解决了两件事消灭了幽灵依赖未声明的包你的代码摸不到也消灭了重复安装同一个版本物理上只有一份。4.3 硬链接的物理前提同盘才生效这里必须提醒一个容易被忽略的工程细节硬链接只能在同一个文件系统内创建。pnpm 的全局 store 默认在用户目录下如果你的项目挂在了另一个磁盘分区或另一个挂载卷上硬链接就无法创建。pnpm 会退化成复制文件——当然功能没影响但磁盘占用和速度优势都会大打折扣。这个问题在 CI 环境里尤其常见。很多云端 CI 的工作目录是一个临时挂载卷而 store 还在系统盘结果 pnpm 在 CI 上跑得不快大家还以为是 pnpm 的问题。我见过不少团队的修复方式是把 store 路径显式配置到项目目录下比如.pnpm-store这样既和项目同盘又方便 CI 缓存整个 store 目录。另一个硬链接相关的坑是硬链接让项目 node_modules 里的文件和全局 store 里的文件是同一个文件实体。如果你手动改了某个项目 node_modules 里的包文件等于改了 store 里的原件其他所有硬链接指向它的项目都会跟着变化。团队规范上应该明令禁止手动修改 node_modules 里的文件——这条规矩在 npm 时代是改了自己负责在 pnpm 时代是改了全局爆炸。4.4 为严格隔离付出的代价原生模块与构建链兼容严格隔离不是没有代价。最直接的冲突就是安装脚本install scripts。pnpm 出于安全考虑默认不会执行依赖包里的 preinstall 和 postinstall 脚本。npm 和 Yarn 1 默认是执行的。这个设计是为了防供应链攻击——恶意依赖可以通过 postinstall 在你机器上执行任意代码。但很多正经包也需要安装后跑脚本比如 esbuild 要下载自己的二进制文件、sharp 要编译原生模块。pnpm 下这些包会安安静静装完用起来直接报错。解决办法是给它们开白名单在 package.json 里加{ pnpm: { onlyBuiltDependencies: [esbuild, sharp] } }pnpm 也提供了pnpm approve-builds交互式命令来手动批准。新团队切到 pnpm 时这一步几乎是必踩的坑遇到了别慌列一下白名单就行。工具链的兼容性问题也在少数场景存在。某些旧的打包器不理解符号链接式的依赖结构解析时会出错。我在实际项目里遇到的概率不高但一旦遇到排查方向基本都在某个依赖对真实路径做了硬编码假设上。5. 三个工具在真实项目里的差异表现5.1 安装速度、磁盘占用与缓存命中理论讲再多最后还是要落到真实使用感受。我在一个典型的中大型前端项目上做过对比直接说结论。安装速度上冷安装无缓存时 pnpm 明显优于 npm 和 Yarn Classic大型项目可以达到数倍的差距。热安装有缓存时差距缩小但 pnpm 依旧占优。现代 npmv7 之后已经补上了并行下载、本地缓存、确定性锁文件这些短板日常小项目体感并不差。Yarn Berry 使用 PnP 模式时安装极快因为压根不写 node_modules 实体文件但这是建立在不生成 node_modules 的基础上。磁盘占用上pnpm 的优势会随着项目数量增大而放大。单个项目可能只省百分之三四十的空间但如果一个 monorepo 里有二十个子包共享同一批依赖npm 会把这些依赖在每个子包里各复制一遍pnpm 则全部软链到同一个 store——差距是指数级的。对比维度npmv7Yarn ClassicYarn BerryPnPpnpm冷安装速度中等中等快快热安装速度中等中等极快快磁盘占用高高极低低锁文件package-lock.jsonyarn.lock.pnp.cjspnpm-lock.yaml依赖隔离弱弱强强幽灵依赖常见常见不存在不存在兼容性最好较好较弱需适配较好偶有工具链问题5.2 锁文件的格式与语义差异锁文件是避免版本漂移的关键但三种工具的实现差异不小。package-lock.json 是 JSON 格式记录的是实际安装出的依赖树结构包含每个包的位置、版本、integrity 校验值和下载地址。它的结构是树形的和 node_modules 实际布局强相关。yarn.lock 是 YAML 格式扁平列表同一个依赖的多个版本会以不同条目列出。它的核心是保证解析结果的确定性不关心安装时的物理布局。Yarn Berry 的锁文件和 Classic 完全不同这也是两者不能混用的原因之一。pnpm-lock.yaml 是 YAML 格式记录方式接近 pnpm 的非扁平依赖树结构包含每个包经过完整解析后的 resolution 信息。它和 pnpm 安装出的实际目录结构是严格对应的这也是 pnpm 能实现强校验的基础。这里有个容易踩的坑不少团队会 npm 和 Yarn 交替使用以为只要有 lockfile 就安全了。但实际上 package-lock.json 和 yarn.lock 是两套完全独立的解析结果交替使用会让两个文件先后失效最后还是在凭 package.json 重新解析等于回到没有锁文件的状态。选定一个工具就只用它的锁文件。5.3 幽灵依赖实证一次切换引发的连锁故障前面讲了幽灵依赖的原理这里给一个我实际遇到的案例。有个项目 package.json 里只声明了 react 和 react-dom 两个依赖但业务代码里直接写import canUseDOM from exenv。在 npm 的扁平化安装下exenv 作为 react 的传递依赖被提升到了顶层 node_modules代码一直能跑。切换到 pnpm 之后安装一切正常启动时却报了一堆Cannot find module exenv。不是 pnpm 坏了是这段业务代码一直在依赖一个没被声明、也本不该直接使用的内部依赖。npm 的扁平化结构掩盖了这个问题pnpm 的严格隔离把它暴露了出来。团队当时的处理分两步先逐个把报错的模块补充为直接依赖声明然后在 ESLint 配置里加上了import/no-extraneous-dependencies规则强制检查你 import 的东西必须出现在 package.json 里。这类问题几乎每次 npm 切 pnpm 都会遇到属于正常现象。如果你的项目之前一直在 npm 下运行且从未检查过依赖声明切换后可以预期会有一定量的报错——这正是 pnpm 在帮你把账还干净。6. 选型建议什么时候用 npm、Yarn什么时候必须用 pnpm6.1 别让工具绑架项目每种工具的合理归属先说结论。个人项目、临时脚本、快速原型npm 就足够。现代化 npm 的功能并不差能用你熟悉的东西解决问题没必要为了工具而工具。团队新项目没有历史包袱我建议默认 pnpm。理由不是它快而是它的严格隔离能让依赖声明始终保持健康它的全局 store 在大团队多人协作时能明显节约磁盘和网络资源它内置的 workspace 支持是三个工具里最好的未来项目膨胀成 monorepo 时不需要换工具。存量 npm 项目没有强烈驱动因素就别折腾。工具切换本身不复杂但切换过程中暴露出来的历史遗留问题会消耗团队精力。如果一定要切建议选业务低谷期并且把它当成一次依赖健康体检而不是单纯的工具替换。还在用 Yarn Classic 的项目建议认真评估迁移。Classic 已停止维护安全补丁不再更新作为长期依赖项风险会逐渐累积。可以迁到 Yarn Berry也可以迁到 pnpm具体看团队对 PnP 模式的接受度——如果不愿意适配编辑器插件和工具链直接上 pnpm 的 node_modules 模式会更平滑。涉及大量原生模块或老旧构建工具的项目先用 pnpm 装一遍并完整跑通构建链路再决定是否切换。这类项目是 pnpm 兼容性问题的高发区但多数通过 build 白名单或工具升级可以解决。6.2 从 npm 迁移到 pnpm 的完整动作清单如果确定要切我建议按下面的清单操作每一步都确认后再走下一步。第一建一个单独的分支不要直接在主干上做迁移。第二删除 node_modules 和 package-lock.json。不要在旧 lockfile 基础上混合 migration直接让 pnpm 按 package.json 重新解析生成 pnpm-lock.yaml。首次解析可能需要几分钟但这是一次性的。第三执行pnpm install。重点观察两个地方有没有依赖的 install 脚本被阻止执行有没有因为符号链接结构导致的原生模块安装异常。有的话按 4.4 小节的方式配置onlyBuiltDependencies白名单。第四全量运行测试和构建脚本。所有Cannot find module报错先判断是不是未声明依赖然后在 package.json 里补上对应声明。不要为了让报错消失而关掉严格模式那属于饮鸩止渴。第五更新团队脚本里的命令。npm run 换成 pnpm runnpx 换成 pnpm dlxnpm ci 换成 pnpm install --frozen-lockfile。注意 pnpm cli 也支持直接pnpm dev这样的简写但pnpm run dev更直观团队里怎么选统一就好。第六在根目录 package.json 里加packageManager字段比如packageManager: pnpm9.x.y配合 Node.js 自带的 corepack可以让团队所有成员强制使用同一版本的 pnpm避免我本地好好的怎么他的环境就装了别的版本的扯皮。6.3 CI 与 Monorepo 场景下的隐藏注意事项CI 环境里第一优先是用--frozen-lockfile安装。它要求 package.json 和锁文件完全一致否则直接失败。这是防止 CI 和本地安装结果漂移的最稳手段。pnpm 在 CI 里的性能完全取决于 store 的缓存策略。建议同时做两件事把 store 路径显式配置到一个可以被整个 CI 缓存处理的位置并确保该路径和项目目录在同一文件系统内保证硬链接生效把整个 store 目录加入 CI 的缓存 key。这样同一批依赖在不同 CI 任务里反复安装时速度会有量级提升。Monorepo 场景pnpm workspace 是目前我体验最好的方案。项目根目录放一个 pnpm-workspace.yamlpackages: - apps/* - packages/*子包之间互相引用时用workspace:*协议声明依赖pnpm 会正确地把它们链接成 workspace 内置依赖。常用命令也简单想只在某个子包里跑脚本用pnpm --filter 包名 run 脚本想在所有包里执行用pnpm -r run 脚本。最后一个隐藏注意点monorepo 的依赖提升策略。pnpm 默认不像 npm 那样把依赖大规模提升到根 node_modules这会让某些子包访问根依赖时失败。如果确实有提升需求可以通过配置显式声明提升规则但更好的做法是让每个子包把自己用到的依赖声明清楚。这又回到了 pnpm 的核心理念你是谁就用什么别靠别人带给你的依赖活着。我在实际项目里摸索一圈后的体会是新项目默认 pnpm存量 npm 项目尊重历史只要团队能清楚说明自己选型的理由和代价用哪个工具都不算错。真正危险的从来不是选错工具而是在没有任何人说得清为什么换、换了之后要注意什么的情况下稀里糊涂完成了切换。读完这篇希望你在下一次敲下安装命令时能准确说出自己手里的工具正在做什么——以及它为什么值得你信任。
阅读完成 · 觉得有帮助?