十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从npm到pnpm:硬链接、幽灵依赖与前端工程化实践

从npm到pnpm:硬链接、幽灵依赖与前端工程化实践 上个月我接手了一个中型后台管理项目node_modules 占了 2.3GB。每次 npm install 都要等接近四分钟npm run dev 冷启动动不动就十几秒。团队里有人建议换 pnpm我当时第一反应是拒绝的——都是包管理器能翻出什么花来后来被拉去把 pnpm 的设计文档翻了一遍又在自己电脑上实测了一把才发现我对“包管理器”这个角色的理解确实太粗糙了。如果你也在纠结 pnpm 和 npm 到底选哪个这篇文章把我调研、实测、踩坑的完整过程都写出来应该能帮你省掉不少试错时间。1. 先搞清楚npm 的扁平化 node_modules 到底解决了什么又留下了什么聊 pnpm 之前得先把 npm 走过的路梳理一遍。不搞清楚 npm 的 node_modules 为什么会长成现在这样就很难理解 pnpm 为什么要反着来。1.1 从“依赖地狱”到扁平化npm 的两次关键抉择用过老 Node 的朋友应该记得 npm 2 时代的“依赖地狱”。那时候 npm 采用严格的嵌套安装策略每个依赖的依赖都会塞进自己私有的 node_modules 里。一个稍微大点的项目依赖树深度能到十几层。后果是什么呢Windows 上路径超长直接装不上同一个包的多个副本反复下载解压安装一次要跑好几分钟。npm 3 之后换了思路改成尽量“扁平化”hoisting安装所有依赖尽可能提升到第一层 node_modules 下只有版本冲突的包才会被嵌套到某个依赖自己的 node_modules 目录里。这个改动在当时是革命性的装包速度大幅提升路径过长问题也缓解了。后来 npm 5 引入了 package-lock.json 锁版本npm 7 开始自动安装 peerDependenciesnpm 本身一直在进化。1.2 幽灵依赖那个“看起来能跑”的隐患但扁平化有一个副作用可能大多数人都没意识到——它让项目里的代码能访问到很多 package.json 里根本没有声明过的包。我举一个自己实际遇到过的例子。以前有个项目package.json 里只声明了 vue 和 vue-router但代码里直接 import axios居然能跑。原因很简单某依赖把 axios 提升到了顶层 node_modules我的代码就顺手偷到了。这种现象在业界叫“幽灵依赖”phantom dependency。问题在于这种“能用”是非常脆弱的。哪天那个中间依赖不依赖 axios 了或者版本结构发生变化导致 axios 被收进嵌套目录你的代码瞬间就报 Module not found。工程上最怕的就是这种没有明确边界的隐式依赖它会让排查问题变得特别痛苦。1.3 即便扁平化了大量重复拷贝依然存在再一个绕不开的问题就是重复。假设你电脑上有 5 个项目都用到 lodash 的同一版本npm 会老老实实往每个项目里都拷贝一份 lodash 代码。5 个项目就是 5 份彼此完全独立也互不感知。单个项目内部也没好到哪去。一个大型后台管理项目node_modules 动辄几个 GB里面的 .bin 文件、依赖副本、临时文件都散落各处。再加上 CI 机器上每次干净安装都要重新下载一遍磁盘、时间、带宽全都在烧钱。我见过一个团队 5 个前端项目共用一台 200GB 的构建机最后磁盘被 node_modules 塞满只能写脚本定时清理治标不治本。这些就是 npm 体系里实实在在的痛点。pnpm 所做的恰恰是针对这些问题逐个击破。2. pnpm 的底层逻辑硬链接、内容寻址存储与更干净的 node_modulespnpm 全称是“Performant npm”设计目标很直接更快、更省磁盘、更严格的依赖边界。它靠的不是小修小补而是重构了包管理器的底层存储模型。2.1 硬链接同一份内容多个目录入口pnpm 的核心之一就是全局“内容寻址存储”Content-addressable Store类似 Git 的对象仓库。所有下载过的包都会按照文件内容的哈希值统一存放进一个全局 store 目录里你可以用命令查看它的位置pnpm store path有了这个全局 storepnpm 安装依赖时做的事情和 npm 完全不同。它不需要把包文件复制进项目而是直接对 store 里的文件做硬链接hard link链接到项目的 node_modules/.pnpm 目录下。硬链接这个概念对没接触过 UNIX 文件系统的同学可能有点抽象。打个比方你图书馆里馆藏了一本书所有读者拿到的都是指向这本书的索书号而不是各自复印一份。硬链接就是多个目录条目指向磁盘上同一个物理文件。在 pnpm 的场景下项目里链接过去的依赖和 store 里的源文件是同一份数据磁盘上只存一份但各个项目都能“看到”它。这意味着三件事同一个版本的依赖无论装到多少个项目里磁盘上始终只有一份新项目装依赖时如果 store 已经命中安装速度会快得离谱升级依赖时新版和旧版如果文件内容有差异只有真正不同的部分才会额外占用空间。我在 Windows 和 Linux 上挂载的 NTFS / ext4 文件系统都跑过 pnpm 的硬链接机制表现稳定没有遇到权限问题。Windows 上如果碰到符号链接相关的奇怪报错通常打开“开发者模式”就能解决。2.2 .pnpm 目录结构pnpm 的 node_modules 长什么样pnpm 安装完成后项目的 node_modules 比 npm 的清爽得多。外层只有一个符号链接目录 .pnpm以及你在 package.json 里声明的那几个直接依赖。实际结构大概是这样的node_modules/ └── .pnpm/ ├── vue3.4.21/ │ └── node_modules/ │ └── vue/ ├── vue-router4.3.0/ │ └── node_modules/ │ └── vue-router/ └── ... └── vue - .pnpm/vue3.4.21/node_modules/vue └── vue-router - .pnpm/vue-router4.3.0/node_modules/vue-router你的代码 import vue 时Node 解析到项目根 node_modules/vue它其实是一个符号链接指向 .pnpm/vue3.4.21/node_modules/vue。vue 自己的依赖则被安置在 .pnpm/vue3.4.21/node_modules 这个私有目录里不会提升到顶层。这种布局带来一个很关键的差异依赖的依赖对项目代码不可见。你的代码只能 import package.json 里声明过的直接依赖。缺失的依赖不会悄悄“捡到”。2.3 严格隔离带来的工程红利在真实的项目里我感受到的“严格隔离”不仅仅是安全感的提升。以前用 npm 时我喜欢用那些“隐式提升”的技巧比如不声明依赖就能 import 包写起来确实快但给后来维护的人埋了雷。换到 pnpm 后任何未声明的依赖都会直接报错代码里的依赖关系被迫变得透明和完整。刚开始觉得“多此一举”习惯之后才发现这是对工程质量的一种强制约束——package.json 真正成为了项目的完整清单。另外pnpm 对 workspacemonorepo的支持也是选择它的重要理由。传统的多包仓库用 npm workspace 虽然能用但大量包的依赖重复安装、hoisting 顺序不稳定经常会冒出来“为什么这里能 require 到那个包”的灵异问题。pnpm workspace 的每个子包都严格继承 pnpm 的符号链接模型依赖边界清晰很多。3. 同一套项目实测安装速度、磁盘占用与部署差异光聊理论总感觉有点虚。当时我做决定之前用个人电脑上一个真实的业务项目跑了几轮对比。这个项目是 Vue3 Vite直接依赖 130 个左右算上传递依赖总共差不多 1600 个包属于比较有代表性的中型前端项目。3.1 测试方法与结果测试方式很简单先把 node_modules 和本地缓存全部清干净测一遍“冷安装”再删掉 node_modules 但保留本地缓存测一遍“热安装”。结果如下表场景npm installpnpm install冷缓存安装约 238 秒约 86 秒热缓存安装约 34 秒约 11 秒首次安装后 node_modules 体积约 2.1 GB约 1.3 GB复制一份项目重新安装后总占用约 4.2 GB约 1.4 GB两个显著差异点冷安装时pnpm 的并发下载能力和内容寻址机制决定了它不需要像 npm 一样逐层解析、重复解压时间差距非常大复制项目后的总占用更能看出 store 共享的价值——npm 是实打实再拷贝一份pnpm 因为硬链接的存在增量占用几乎可以忽略。需要说明的是这只是我机器上的数据不同网络环境、不同依赖规模数字会有浮动但差距的“量级”基本是稳定的。如果你的项目依赖特别多这个差距还会被进一步拉大。3.2 构建产物与部署不受包管理器影响很多人问“pnpm run build 的包怎么 nginx 启动”其实这里存在一个误解包管理器只负责“依赖安装”和“脚本执行”构建产物本身与 npm 或 pnpm 没有直接关系。不管是 pnpm run build 还是 npm run build只要构建脚本一致产物都是 dist 目录下的一组静态文件。部署 nginx 时只需要按常规方式指向 dist 目录即可。一个适合 Vue Router history 模式的基础配置长这样server { listen 80; server_name your-domain.com; root /data/www/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /assets/ { expires 7d; add_header Cache-Control public; } }关键在于try_files那一行当用户访问/user/123这类前端路由时nginx 找不到对应文件就回退到index.html交给前端路由处理。如果项目用的是 hash 模式路由不需要这个回退配置。CI 这边其实也没什么可担心的。我在 GitHub Actions 和自建的 Jenkins 上都跑过把原本的npm install换成pnpm install再配置一下pnpm store的全局缓存构建速度反而更快了因为同一台构建机上多个任务可以直接命中同一个 store。4. 换到 pnpm 后我遇到的高频报错和解决办法坦白说从 npm 切换到 pnpm 的过程不是零摩擦的。尤其在我升级到 pnpm 10 之后遇到了一堆网上能搜到的新问题。把这些常见报错集中整理一下你大概率也会碰上。4.1 “pnpm 不是内部或外部命令”与环境变量这类报错几乎都发生在 Windows 环境典型提示有两种pnpm 不是内部或外部命令,也不是可运行的程序 或批处理文件。 pnpm : 无法将“pnpm”项识别为 cmdlet、函数、脚本文件或可运行程序的名称第一个是 cmd 环境变量没配好第二个是 PowerShell 找不到命令。排查思路很简单先看 pnpm 被装到哪了。如果你用的是 npm 全局安装 pnpmnpm config get prefix命令输出的目录就是全局包安装位置比如C:\Users\你的用户名\AppData\Roaming\npm。你需要把这个目录加入系统 PATH。具体操作就是从“系统设置 - 环境变量”里把路径追加到 Path 变量中然后重新打开终端。PowerShell 特有的“无法加载文件 npm.ps1因为在此系统上禁止运行脚本”是另一种独立问题。这是 PowerShell 执行策略Execution Policy默认限制运行.ps1脚本导致的。解决办法是用管理员权限执行一次Set-ExecutionPolicy RemoteSigned -Scope CurrentUser再重新打开 PowerShell 就正常了。我有时候会临时想只改当前会话也可以直接在当前终端里先执行Set-ExecutionPolicy -Scope Process Bypass。4.2 Node 版本门槛this version of pnpm requires at least node.js v22.13pnpm 的大版本更新经常拉高 Node.js 版本门槛。你装了个新版 pnpm跑命令时突然报error: this version of pnpm requires at least node.js v22.13这就是本机 Node 版本太旧不满足 pnpm 的运行要求。解决方案也不是只有一个用 nvm / nvm-windows 切换到一个最新的 LTS 版本或者降低 pnpm 主版本匹配当前 Node 环境。这里我更推荐前者。包管理器保持偏新版本能更快获得安全修复和功能更新。Node 老版本维护成本只会越来越高迟换不如早换。另外提醒一句安装 pnpm 推荐用npm i -g pnpm或者 Node 自带的 corepackcorepack enable后就能用pnpm命令。corepack 的好处是每个项目可以在 package.json 里的packageManager字段锁定 pnpm 版本团队所有人都统一避免“我这能跑你那报错”的尴尬。4.3 pnpm 10 不再信任依赖的构建脚本approve-builds升级到 pnpm 10 之后我遇到最典型的问题是这个警告run pnpm approve-builds to pick which dependencies should be allowed to build原因是 pnpm 10 默认不再执行依赖包里的 postinstall 等构建脚本。这是官方为安全考虑做的重要改动——毕竟安装一个包就让它执行任意脚本本质上等同于把系统交给对方。但这也带来一个麻烦像 esbuild、sharp、unrs-resolver 这类需要 postinstall 下载二进制或构建原生模块的包会在构建时莫名报错或者运行起来找不到 binding。解决方法是先用pnpm approve-builds交互式地选择放行哪些包也可以直接把它写入配置文件# pnpm-workspace.yaml onlyBuiltDependencies: - esbuild - sharp顺带说一个相关的配置变化pnpm 10 之前pnpm.overrides是写在 package.json 里的{ pnpm: { overrides: { lodash: 4.17.21 } } }但现在 pnpm 10 会直接忽略 package.json 里的这个配置字段并输出提示the pnpm field in package.json is no longer read by pnpm. the following keys were ignored: pnpm.overridesoverride 得迁到 pnpm-workspace.yaml 里packages: - . overrides: lodash: 4.17.21我最初升级时也被这个改动卡了一下当时还以为是 pnpm 坏了。理解了 10 的配置模型之后发现把所有 pnpm 相关配置统一放进 pnpm-workspace.yaml 反而更清晰。4.4 镜像证书过期与国内源的选择安装依赖报证书过期的历史问题主要集中在老淘宝源上。如果你在配置里用了https://registry.npm.taobao.org很容易碰到npm ERR! code CERT_HAS_EXPIRED npm ERR! request to https://registry.npm.taobao.org/... failed, reason: certificate has expired这个域名早期证书已经过期正确做法是换成官方维护的 npmmirror 镜像源npm config set registry https://registry.npmmirror.com pnpm config set registry https://registry.npmmirror.com检查当前源用npm config get registry pnpm config get registry如果是企业内部网络通常会把 Nexus 或私有仓库作为 npm 代理。pnpm 同样支持指向内网 registry配置方式与上面一致。对于没法直接访问外网的构建机内网源几乎是唯一可靠的依赖获取方式。4.5 离线或受限网络环境的安装方案有些生产环境是完全离线的这时候安装 pnpm 本身就成了一个先有鸡还是先有蛋的问题。我的做法是在一台有网的机器上执行npm pack pnpm把打包好的 pnpm 文件拷贝进内网再通过npm i -g ./pnpm-xxx.tgz离线安装。依赖文件也同理可以利用pnpm fetchpnpm install --offline的组合在 CI 上实现离线构建核心思路是提前准备好 pnpm store 或 lockfile 对应的离线缓存。如果你只是想在离线环境复用某台机器上已经下载过的 npm 包npm install --offline也能通过本地缓存工作但整体体验不如 pnpm 的 store 方案干净。5. 什么场景坚持 npm什么场景果断换 pnpm做了这么多对比最后落到一个很实际的问题上我到底该不该换5.1 我建议继续用 npm 的场景老项目已经稳定运行没有强烈的性能痛点团队也没有人有精力折腾迁移。这时候“不换”就是一种最优解毕竟 pnpm 带来的收益在超大依赖树和持续迭代的项目上才最明显。项目依赖里存在大量未经现代包管理器规范约束的旧包迁移过程中容易出现构建脚本不执行、peerDependency 缺失等新问题。如果团队没时间排雷强切只会增加摩擦。分发 CLI 工具给外部用户时npm 作为事实标准生态配套文档最全用户的接受度最高。pnpm 也兼容但没必要给用户引入额外的“这工具要怎么装”的疑惑。5.2 我建议果断换 pnpm 的场景业务项目依赖树庞大安装一次要几分钟CI 排队时间肉眼可见地增长。团队有多个项目并行开发互相之间大量复用相同版本的依赖。pnpm 的全局 store 是真正的省钱利器。正在搭建或重构 monorepo。pnpm workspace 的依赖隔离设计能省掉很多 hoisting 相关的灵异问题。对依赖安全和完整性有要求想杜绝幽灵依赖。别小看这一点严格模式对工程质量的影响是长期且潜移默化的。维度建议选择老项目稳定运行、迁移成本高npm依赖树庞大、安装时间长pnpm单机多项目并行开发pnpmmonorepo / workspacepnpm对外分发 CLI 工具、追求最大兼容npm团队刚入门前端、希望开箱即用npm5.3 团队迁移的平稳路径如果决定要换我建议按这个步骤走避免直接推倒重来选一个中等复杂度的项目做试点不要一上来就动核心应用。统一本地安装方式推荐用 corepack 锁定版本提交到仓库的packageManager字段。执行pnpm install生成pnpm-lock.yaml随后跑一遍项目的完整构建和测试套件。检查并迁移overrides、onlyBuiltDependencies这类存在于 package.json 或旧版配置文件中的 pnpm 相关配置。把 CI 脚本里的npm install改成pnpm install同时记得将 pnpm store 路径纳入 CI 缓存。为团队写一页纸的切换文档重点记录 Windows 环境变量和 PowerShell 执行策略的解法。这套流程走下来我遇到最大的阻力不是技术而是团队习惯。好在 pnpm 的 CLI 命令设计和 npm 高度一致install、run、add、remove基本都能无缝替换学习成本极低。最后再分享一点使用上的主观体会用了 pnpm 半年多最直观的改善是重装依赖的等待时间从“可以去倒杯水”变成“还没来得及起身就跑完了”。磁盘碎片化的问题也消失了几台开发机和 CI 机器因为共享 store整体体验提升了一截。如果你正在 npm 和 pnpm 之间摇摆我的建议是新项目直接上 pnpm老项目找一个非关键业务先做试点。以 Node 生态的演进速度pnpm 的安装模型大概率会是未来几年前端工程化的默认选项。与其等别人踩完坑你再上不如早点上车。切换过程中的这些小报错刚好可以当成一次全团队工程化基建的梳理。
返回列表