首页 / 资讯中心 / 文章详情

npm、yarn、pnpm深度对比:包管理器底层原理与迁移实践

npm、yarn、pnpm深度对比:包管理器底层原理与迁移实践 ★ FEATURED ARTICLE
很多人聊到前端包管理器的时候第一反应永远停留在哪个安装更快哪个更省磁盘网上都在吹pnpm是不是无脑换就完了。说实话我自己在npm、yarn、pnpm之间反复横跳了好几年一开始也是看各类对比文章直到在维护一个后台管理系统、依赖数量一涨再涨的时候才把三者的底层差异真正摸清楚。npm、yarn、pnpm这三个工具表面上都是装依赖的入口实际上它们管理node_modules的方式、锁文件的策略、依赖提升的规则完全不同而这些差异会直接决定你在开发、CI、多人协作里碰到哪些坑。这篇文章我不想再做枯燥的参数对比。我会从包管理器到底解决什么问题讲起再深入到node_modules的结构差异、锁文件机制、磁盘和安装速度的体感变化最后给一份我自己做依赖迁移时的完整步骤和坑点记录。不管你是刚入门、还在纠结npm和yarn有什么区别的新手还是准备把团队项目迁到pnpm的老手这篇应该都能给你一套可以照着走的思路。1. 包管理器到底替前端项目管了些什么1.1 没有包管理器的项目会变成什么样很多人觉得装依赖嘛不就是install一下但真让你手工维护一个上百个依赖的项目马上就会感受到什么叫绝望。前端依赖有一个典型特征传递依赖非常深。你安装一个axios它依赖follow-redirectsfollow-redirects又依赖别的东西这棵树往下延伸个四五层是很正常的事。如果没有包管理器你得自己记住每个包需要哪个版本、谁和谁冲突、升级时哪些包会被连带影响这几乎是不可能完成的任务。包管理器实际做了四件事。第一解析依赖关系根据package.json里的声明和semver语义化版本规则算出每个包到底该装哪个版本。第二下载并安装这些包同时处理它们的子依赖形成一棵完整的依赖树。第三通过锁文件把整棵依赖树固定下来保证今天机器上装的版本和三个月后、同事电脑上装的版本一致。第四提供统一的脚本运行入口和缓存机制比如npm run会把node_modules/.bin注入到环境变量PATH里你才能直接跑eslint、vite这些命令。这些工作听起来平平无奇但每一个环节都会在项目变大后暴露问题。安装慢、空间膨胀、版本错乱、环境不一致这四个问题几乎是所有团队绕不过去的坎而后来的yarn、pnpm也都是在解决这些具体痛点的过程中出现的。1.2 为什么市面上同时存在三个主流工具这就得回到历史原因说起了。npm从Node.js诞生起就捆绑分发早期npm 2时代的node_modules是严格嵌套的一个项目可以嵌套几百层目录不仅安装慢还会因为路径过长在Windows上报错。2016年yarn带着扁平化依赖提升并行安装离线缓存这些改进出现很快吸引了一批被npm 2折磨的团队。npm v3随后也引入了扁平化并慢慢吸收了不少yarn的思路两者差距逐步缩小。pnpm走的是另一条路。它没有跟npm、yarn在扁平化这个方向上死磕而是用内容寻址存储加符号链接的方式把依赖真正变成多项目共享一份同时从结构上杜绝了扁平化带来的隐式依赖问题。所以你可以理解为npm和yarn更像是同一条路线上的两代产品而pnpm是换了一条思路的解决方案。理解这个背景之后再看npm 7之后也推出workspace、也融入类似yarn的能力就不会觉得奇怪——工具之间的竞争本质上是在互相吸收对方的优点最终拼的是谁的工程化方案更省心。2. node_modules的物理结构三个工具最根本的分歧2.1 npm和yarn的扁平化提升为什么能解决问题又制造问题npm 2时代的node_modules长这样node_modules └── A └── node_modules └── B └── node_modules └── C一层套一层路径越来越深。假设A和D都依赖C的各自版本你还得放两份不同版本的C磁盘浪费极其严重。于是npm v3和yarn都采用依赖提升策略安装时尽量把所有依赖都平铺到顶层node_modules遇到版本冲突时才把冲突的包嵌套在需要它的父包下面。node_modules ├── A ├── D └── C // C被提升到顶层路径短了处理也简单了但随之而来的副作用就是幽灵依赖。因为C被提升到了顶层你的业务代码即使package.json里压根没声明C也可以直接import(C)。开发环境一切正常C确实就在node_modules根目录里躺着可一旦某个间接依赖升级后不再依赖C或者C的版本变化你的代码就会在毫无提示的情况下崩溃。我在实际项目里就见过这种情况某次升级后线上报错module not found查了半天才发现是业务代码偷偷依赖了一个没在package.json里声明的传递依赖。这就是扁平化结构最大的隐患——node_modules的真实内容和package.json的声明内容出现了偏差。2.2 pnpm的符号链接方案用虚拟商店解决隔离与共享pnpm不把依赖平铺到项目根目录的node_modules里而是维护一个全局的内容寻址存储。所有包的文件都按内容哈希存放在这个全局store里安装时在node_modules/.pnpm这个虚拟商店里创建硬链接再把项目真正能访问的直接依赖通过软链接指过去。从业务代码里看到的简化结构大概是这样node_modules ├── .pnpm │ ├── axios1.x │ │ └── node_modules │ │ └── axios - 指向全局store中的axios文件 │ └── ... └── axios - .pnpm/axios1.x/node_modules/axios每个包只有在自己的依赖目录里才能看到自己的子依赖项目根目录里只有package.json里显式声明的直接依赖。这意味着你想import一个没声明过的包在默认配置下会直接报错。依赖关系变得可预测、可控制。这里要区分一下硬链接和软链接很多读者一听符号链接就发怵。软链接类似Windows的快捷方式指向的是某个路径硬链接则是同一个文件在磁盘上多了一个目录入口文件内容在全局store里只存一份项目里的硬链接相当于同一个文件的多重门牌。所以在pnpm方案下多个项目共用全局store不会出现同一份依赖被复制好几份的情况。2.3 结构差异带来的行为变化严格性与兼容性的权衡扁平化结构的好处是宽松你总是能访问到很多没声明的东西这在老项目里反而成了能跑起来的原因坏处是依赖关系不透明项目随时可能因为环境差异而炸掉。pnpm的严格隔离则是收紧开发体验更干净但也会让一些写得不规范的老包出问题——比如某个包的postinstall脚本默认假设自己的依赖一定在顶层node_modules里被pnpm隔离后找不到就会装到一半退出。用一张表概括三者的差异特性npm / yarnpnpm依赖存储方式扁平化提升到项目node_modules全局store 符号链接未声明的依赖能否访问可能可以幽灵依赖默认不能多项目共享依赖每个项目独立复制全局共享一份安装速度较快更快硬链接 并行下载对不规范老包的兼容性较高需要针对性配置所以选型不完全是哪个更快而是看你愿不愿意为依赖关系的确定性付出一些额外的配置成本。这个问题到第五章迁移部分会具体展开。3. 安装速度与磁盘占用内容寻址存储带来的体感差异3.1 内容寻址存储到底省了什么pnpm的存储机制核心是内容寻址每个文件根据内容计算出一个哈希值哈希即文件名全局store里同一个文件永远只存在一份。新项目安装依赖时如果store里已经有了对应文件pnpm直接建立硬链接不需要重新下载和解压升级依赖时哪怕旧版本只改了几行代码也只有被改动的那几个文件需要重新下载。yarn和npm也有缓存但缓存的是下载下来的包归档文件安装时还是要完整解压再复制进项目的node_modules。打个最简单的比方npm和yarn像是每个家庭都各自买一套完整家具放自己家pnpm像是小区里建了一个公共仓库每个家庭需要哪个柜子就直接从仓库搬同一个实体进门只是这个搬进家门在技术上叫硬链接。理解这一点后你就能明白为什么pnpm在多个项目并存、反复切换分支的机器上优势特别明显。3.2 一个中型项目的实际体感对比我之前接手某个后台管理系统时依赖总数不算夸张但用npm安装完node_modules加上各类缓存动辄要占两三个G的磁盘。第一次用pnpm重新安装时pnpm下载完一算项目里实际占用的磁盘从原来的2.4G左右降到不到1G安装时间也从往常的几分钟缩短到一分钟以内。不同项目差异很大几百个依赖和只有几十个依赖的项目体感完全不同。所以我建议别只看网上的评测数据拿你自己真实的package.json锁文件跑一次看pnpm在你项目里的安装耗时和磁盘占用比任何benchmark都可信。3.3 CI和团队协作里的隐藏收益本地安装速度只是最直观的一部分CI环境才是容易被忽略的隐形收益来源。CI机器上经常是全新环境npm装一次依赖要花很久换pnpm后很多依赖可以直接命中CI缓存构建时间能明显缩短。另一个容易被忽略的点是磁盘占用CI上同时跑多个项目时npm会为每个项目复制一份node_modulespnpm却可以用同一个全局store这对磁盘配额紧张的构建机非常友好。如果你所在的团队有统一的构建平台这个因素可能比本地安装速度更值得纳入选型考虑。3.4 store的日常管理不用频繁清理用pnpm之后很多人会担心全局store越攒越大总想着清理。实际不必太焦虑store里的文件是按内容哈希去重的同一个版本同一个文件只存在一份占用增长是线性且可控的。你可以用pnpm store path查看store所在位置用pnpm store prune清理那些已经没有项目引用的孤儿文件。需要注意的是prune会扫描当前所有引用该store的项目如果你好几个项目共用一台机器最好先把它们在本地都安装一遍避免误删还需要的文件。清理store这件事我个人的经验是一个季度做一次就够不需要当作日常运维任务。4. 锁文件的真相可复现安装比版本号重要得多4.1 package-lock.json、yarn.lock、pnpm-lock.yaml到底锁定了什么先纠正一个常见误解锁文件锁的不是package.json里那几个直接依赖的版本号而是整棵依赖树的精确版本。你在package.json里写axios: ^1.7.0这个^表示允许安装1.x系列里的最新版本。axios又依赖了其他包那些包也有自己的semver范围整棵树装下来具体是哪几个版本完全取决于安装那一刻解析器得到的结果。如果不锁定今天装出来的树和三个月后装出来的树哪怕是同一个package.json也可能不一样。锁文件就是把安装解析后的精确版本和依赖关系固化下来npm对应package-lock.jsonyarn对应yarn.lockpnpm对应pnpm-lock.yaml。它们记录了每个包从哪里下载、解析后的具体版本、依赖了谁。提交到git之后团队所有人、CI机器、部署环境都共用同一份依赖树这才叫可复现安装。这个文件如果你不提交前面加锁的意义就全部白费。4.2 常见的锁文件误操作我自己踩过、也在别人项目里见过的坑主要有这几类。第一不把锁文件提交到版本库。锁文件不提交等于团队每个人都在碰运气解析依赖树今天你机器上能构建明天同事机器上就报版本兼容错误。尤其是新入职的同事和CI环境最容易暴露这个问题。第二CI里用npm install而不是npm ci。npm install会重新解析依赖范围还有可能顺手改掉锁文件npm ci是严格按锁文件安装锁文件里是什么就装什么。yarn对应的是yarn install --frozen-lockfilepnpm对应的是pnpm install --frozen-lockfile。CI环境里必须用带冻结语义的命令否则可复现性无从谈起。第三一个项目里同时存在多个锁文件。有人用npm装有人用yarn装package-lock.json和yarn.lock并存或者从npm切换到pnpm时没有删掉旧的package-lock.json。锁文件一多等于告诉所有人我不知道该信谁排查问题时会非常混乱。4.3 升级依赖时如何稳妥地操作升级依赖不是直接改一下package.json里的版本号就完事。我习惯的做法是先用npm outdated或yarn outdated查看有哪些包的更新确认要升级的包和范围后再执行对应的upgrade命令。命令会重新解析那部分依赖并更新锁文件升级完成后立刻跑一遍测试和生产构建确认没有版本破坏。pnpm下可以用pnpm outdated查看状态用pnpm upgrade --latest做交互式升级。这里特别提醒升级某个小版本后如果发现锁文件里大量包版本被连带更新了先别慌这是正常的依赖树重新解析结果。但如果发现某个间接依赖跳过了大版本就要多留个心眼重点排查这个包是否影响当前业务代码。锁文件的变更最好单独作为一个commit提交方便之后git blame和回滚追溯。5. 从npm切换到pnpm的完整迁移记录5.1 先判断你的项目到底该不该换pnpm不是包治百病迁移前先做一次诊断。依赖数量少、团队规模小、构建链路简单的项目npm完全够用没必要为了新潮增加学习成本。但如果你的项目经常出现幽灵依赖问题、node_modules占用太大、有多个子包共享依赖的需求那pnpm的优势就非常值得兑现。同时也要评估老项目的包袱。如果项目里大量使用了依赖老式原生模块的包或者postinstall脚本写死了自己去node_modules某个深层路径找文件的假设迁移成本会偏高。遇到这种情况可以先在个人分支上把pnpm装起来跑通一遍把能解决的坑解决掉再和团队商量是否整体切换。贸然在多人仓库里直接换包管理器很容易陷入构建能过但运行时出问题的尴尬。5.2 迁移步骤从清理到构建通过我一般按这个流程走删掉旧的node_modules和package-lock.json或yarn.lock。旧锁文件必须清干净否则pnpm可能被旧的解析结果干扰。确认Node.js版本和pnpm版本。推荐用corepack来管理当前使用的pnpm版本团队里也好统一。在项目根目录执行pnpm install生成pnpm-lock.yaml。检查package.json里的scripts是否需要调整。多数情况脚本不用改pnpm会用类似npm的方式运行scripts。依次跑lint、单元测试、生产构建。任何一个环节报错按第五章第三节的坑点逐项排查。把pnpm-lock.yaml提交到git之后团队统一使用pnpm。这里有个容易忽略的细节pnpm对未在package.json声明就直接使用的依赖会给出警告或不安装所以迁移后第一次构建报module not found先想想这个包是不是你的幽灵依赖。相比npm的静默继承这个报错反而是帮你补课依赖声明的好机会。5.3 迁移中容易踩的坑与对应解法第一postinstall脚本失效或执行权限问题。pnpm默认会执行依赖包里的postinstall脚本但如果整个安装命令带了--ignore-scripts需要编译的包装完就缺文件了。我的做法是明确列出哪些包需要允许脚本再单独放行而不是全局开白名单。第二Electron这类需要下载二进制文件的包。pnpm的符号链接结构可能让Electron安装后找不到二进制文件的实际路径这是迁移到pnpm时非常常见的坑。解决办法是给Electron单独配置镜像源或者让pnpm对它使用hoisted模式安置。提前知道这一点能帮你省下一整天的排查时间。第三peerDependencies的严格检查。pnpm默认对peerDependencies的处理比npm严格得多一个包声明了peer依赖但没有显式安装安装过程可能直接报错。解决办法是在package.json里显式声明对应的peer依赖或者用pnpm的配置项放宽检查。反过来想npm通常是在安装时静默跳过运行时才炸锅pnpm只是把问题提前暴露了。第四老包依赖顶层node_modules里恰好有其他依赖的投机行为被打破。如果某个老包确实需要传统的扁平化结构可以在.npmrc或项目配置里给它设置nohoist让它保持传统的安装方式也可以针对整个项目使用node-linkerhoisted换回与npm类似的扁平结构。这两个逃生舱的存在说明pnpm并没有打算把真实世界的兼容性需求拒之门外。这些坑提前摆上台面是因为pnpm迁移失败的大部分案例根本不是pnpm自己的bug而是项目里那些约定俗成的坏习惯被严格的依赖隔离逼了出来。6. Monorepo场景下pnpm为什么越来越成为默认选择6.1 workspace一个仓库管理多个包的核心能力大型前端项目越来越倾向monorepo也就是一个git仓库里放多个独立发布、独立构建的包把公共逻辑抽成内部共享包。npm和yarn本身也支持workspace但pnpm对workspace的处理更贴合依赖共享需求。pnpm的workspace协议可以直接用workspace:*来引用本地包内部包之间的依赖复用全局store里的同一份文件不会因为维护多份副本导致改动时漏掉一处。如果你还在用把所有公共代码放到一个共享包里每次靠版本号联动的方式维护多项目建议认真了解一下workspace方案。版本号联动的问题是发一个新版本、再让下游包升级整个链路很长而workspace协议让本地依赖直接指向源码目录改完即生效开发成本低很多。6.2 filter精确到包粒度的只跑一步命令在monorepo里最烦人的场景是改了一个底层库整个仓库里一堆包都要跟着构建。pnpm提供了--filter参数可以精确选择要执行的子包。比如pnpm --filter api-server build只构建api-server这个包pnpm --filter ./packages/* test可以选中多个包一起跑测试。这在npm workspace里写脚本会比较繁琐pnpm的命令粒度更细、更直观。配合CI使用时filter还能结合锁文件里的依赖关系自动确定这次改动会影响哪些包把没受影响的子包跳过构建时间能省一大截。这种能力在包数量多起来以后价值会越来越明显。6.3 决策建议什么时候用pnpm workspace什么时候用独立仓库如果你的项目只是几个包偶尔共用几个工具函数而且各自独立部署pnpm workspace可以先小范围用起来。如果包之间耦合较深、发布节奏一致monorepo加pnpm基本是目前前端工程化里很稳妥的组合之一。反过来如果团队连统一包管理器锁文件提交CI冻结安装这些基础规范都还没有达成共识我觉得先把这些基本功打好比为了上monorepo强行换工具更重要。我在实际项目里的体会是pnpm workspace的价值不只是安装变快而是它逼着团队把依赖关系理清楚每个包必须显式声明自己依赖什么内部包的版本联动必须通过workspace协议体现。这种约束在团队越来越大的时候带来的收益远大于安装省下的那几秒钟。
阅读完成 · 觉得有帮助?
咨询建站