面试官:聊聊包管理工具的发展及pnpm的治理依赖的最佳实践

739 阅读17分钟

前言

目前在前端领域最流行的包管理工具包含了 npm、yarn、pnpm,其中 pnpm 的机制和出现对 yarn 和 npm 堪称降维打击,它通过软硬链接依赖的方式实现了快速安装、去除幽灵依赖,当下各种类库、组件库的最佳实践方案也基本都是 pnpm + monorepo。由于我所在的团队也使用了 pnpm 作为包管理工具,因此想和大家分享一下pnpm这个包管理工具。本文先聊聊包管理工具的发展,下文会和大家具体聊聊pnpm这个包管理器以及它治理依赖的最佳实践

包管理器工具的历史

包管理器是用于自动化处理软件包(库、框架等)的安装、更新、配置和删除的工具。在 JavaScript 和 Node.js 生态系统中,最常用的包管理器包括 npm、Yarn 和 pnpm。它们简化了依赖管理和项目构建过程,使得开发者可以更专注于编写业务逻辑。

npm

npm的诞生

随着 Node.js 的推出,开发者需要一种管理众多 JavaScript 库和模块的方法,npm(Node Package Manager)由此诞生。

npm 于2010年被引入,很快成为 Node.js 生态系统中分享和管理模块的标准方式。其中 node_modules 目录就是npm用来局部安装依赖的地方,使得不同的项目可以使用不同版本的包,而不会互相干扰。随着时间的推移,npm 还成为了一个庞大的开源库生态系统,目前已是世界上最大的软件注册机构。

npm的使用

简介:npm 是 Node.js 的默认包管理器,是世界上最大的软件注册表之一。它提供了丰富的命令行接口来管理依赖项,并且拥有庞大的社区支持。

特点

  • 安装全局或本地的 npm 包。
  • 自动生成 package.json 文件以记录项目的依赖关系。
  • 支持脚本命令,允许你定义和运行自定义任务。
  • 内置安全审计功能,帮助识别潜在的安全问题。

使用示例

# 初始化一个新的 npm 项目
npm init

# 安装一个依赖包到项目中
npm install <package-name>

# 更新所有依赖包到最新版本
npm update

# 运行自定义脚本
npm run <script-name>

npm的嵌套依赖模型

然而,随着 npm 的快速成长,一些问题也随之而来,比如 node_modules 随着依赖的嵌套,体积越来越大。

在 npm2 及以前,每个包会将其依赖安装在自己的 node_modules 目录下,这意味着每个依赖也会带上自己的依赖,形成一个嵌套的结构,结构如下:

image.png

这样的结构虽然解决了版本冲突、依赖隔离等问题,但却有几个致命的缺点:

  • 磁盘空间占用:每个依赖都会安装自己的依赖,导致了大量的重复,特别是在多个包共享同一依赖的场景下。
  • 深层嵌套问题:这种嵌套结构在文件系统中造成了非常长的路径,然而大多数 Windows 工具、实用程序和 shell 最多只能处理长达 260 个字符的文件和文件夹路径。一旦超过,安装脚本就会开始出错,而且无法再使用常规方法删除 node_modules 文件夹。相关 issue:github.com/nodejs/node…
  • 安装和更新缓慢:每次安装或更新依赖时,npm 需要处理和解析整个依赖树,过程非常缓慢。

这里有一张梗图:

image-20250107120344329

npm3架构升级

为解决这些问题,npm 在第三个版本进行了重构:github.com/npm/npm/rel…

通过将依赖扁平化,尽可能地减少了重复的包版本,有效减少了项目的总体积,同时也避免了 npm 早期的深层嵌套问题。

扁平化结构如下:

image.png

可以看到还是会有一定可能产生嵌套问题,因为根目录只能存放某个包的一个版本。

yarn

yarn的出现

yarn 的出现是为了解决 npm 当时存在的一些问题,它由 Facebook、Google、Exponent 和 Tilde 共同开发,于2016年发布,旨在提供一个更快、更安全的 JavaScript 包管理工具。

yarn 的特点:

  • 性能提升:yarn 在发布之初就强调了性能优势,特别是在安装依赖时。它通过并行安装依赖和缓存已下载的包来加速这一过程,减少了安装时间。
  • 更好的依赖管理:yarn 引入了 yarn.lock 文件,这个锁文件确保了依赖的一致性。无论是在哪个环境下运行yarn install,都能确保安装相同版本的依赖,解决了因版本不匹配导致的问题。
  • 更好的安全性:yarn 通过检查安装的每个包的许可证,并提供了一种机制来限制或拒绝具有不安全许可证的包的安装,增强了项目的安全性。

yarn的使用

简介:由 Facebook 开发并维护,旨在解决 npm 在速度和安全性方面的一些局限性。Yarn 通过锁文件 (yarn.lock) 来确保不同环境中依赖的一致性。

特点

  • 更快的安装速度,得益于并行化下载和缓存机制。
  • 确定性的安装,保证每次安装的结果相同。
  • 提供了更好的离线模式支持。

使用示例

# 初始化一个新的 Yarn 项目
yarn init

# 安装一个依赖包到项目中
yarn add <package-name>

# 更新所有依赖包到最新版本
yarn upgrade

# 运行自定义脚本
yarn run <script-name>

仍然存在的问题

其实扁平化的结构还是存在一些问题的,那就是幽灵依赖。

我们假设 B 并没有在 package.json 中注册,但由于 A 依赖 B,B会被提取到 node_moduls 顶层,那么在项目中就可以直接引用 B,这就是幽灵依赖,当 A 出现一些变动时(升级、删除),会导致出现几个问题:

  • 环境不一致:由于该模块未在 package.json 文件中声明,当在其他环境(如测试、生产环境或者其他人的开发环境)中部署应用时将无法知道需要包含那些模块。这将导致环境之间存在不一致,可能会导致在其他环境中运行时出现错误。
  • 版本控制问题:由于未明确声明依赖,可能会出现不同环境中使用的模块版本不一致的问题。这可能导致某些功能在某些环境中无法正常工作,或者出现不可预见的行为。
  • 代码可读性和可维护性降低:开发人员无法清楚地了解应用程序的依赖项,导致代码理解困难。

而 pnpm 就是为了解决这个问题而出现的。

pnpm

pnpm横空出世

pnpm(performant npm)旨在解决 npm 和 yarn 在某些方面存在的效率和存储问题,同时通过引入一种独特的链接方式有效地解决了大部分幽灵依赖的问题。

image.png

pnpm的使用

简介:pnpm 是一种新型的包管理器,它解决了传统 npm 和 Yarn 在磁盘空间利用率上的不足,采用了一种称为“内容可寻址存储”的方法来存储依赖包。

特点

  • 极大地节省磁盘空间,因为相同的依赖只保存一份副本。
  • 快速安装速度,同样利用了并行化下载。
  • 与 npm 和 Yarn 兼容,可以直接替换现有工作流。

使用示例

# 初始化一个新的 pnpm 项目
pnpm init

# 安装一个依赖包到项目中
pnpm add <package-name>

# 更新所有依赖包到最新版本
pnpm update

# 运行自定义脚本
pnpm run <script-name>

硬链接和软链接(符号链接)

在了解 pnpm 具体机制之前,我们先了解一下硬链接和软链接(符号链接)的概念:

  • 硬链接(Hard Link)
    • 概念:硬链接是文件系统中的一个链接,它指向磁盘上的数据。当创建一个硬链接时,实际上是在创建一个和原始文件相同的入口点,但是不占用额外的磁盘空间。这个新的链接和原始文件共享相同的数据块,任何一个文件的修改都会反映在另一个上。
    • 特点:硬链接不能跨文件系统创建,也不能用于链接目录,但如果原始文件被删除,硬链接依然可以访问数据。
    • 使用场景:当你想要在不同位置访问同一个文件内容,而又不想占用额外磁盘空间时,可以使用硬链接。比如,在多个项目中共享相同的库文件,但不需要复制这个文件多份。
  • 软链接(符号链接,Symbolic Link)
    • 概念:软链接是一个特殊类型的文件,它包含了另一个文件的路径。类似于 Windows 系统中的快捷方式。与硬链接不同,软链接可以指向目录,也可以跨文件系统。
    • 特点:软链接指向文件或目录的路径,如果原始文件被删除,软链接就会失效,因为它的指向已经不存在了。
    • 使用场景:软链接适用于需要引用特定位置的文件或目录时,特别是当这些文件或目录可能会移动或变化时。它允许链接到另一个文件系统中的文件或目录。

pnpm中的硬软链接应用

硬链接

pnpm 通过使用全局的 .pnpm-store 来存储下载的包,使用硬链接来重用存储在全局存储中的包文件,这样不同项目中相同的包无需重复下载,节约磁盘空间。

image.png

软链接(符号链接)

pnpm 将各类包的不同版本平铺在 node_modules/.pnpm 下,对于那些需要构建的包,它使用符号链接连接到存储在项目中的实际位置。这种方式使得包的安装非常快速,并且节约磁盘空间。

image.png

举个例子,项目中依赖了 A,这时候可以通过创建软链接,在 node_modules 根目录下创建 A 软链指向了 node_modules/.pnpm/A/node_modules/A。此时如果 A 依赖 B,pnpm 同样会把 B 放置在 .pnpm 中,A 同样可以通过 软链接依赖到 B,避免了嵌套过深的情况。

image.png

可以来看看依赖软链的体现:

image.png

image.png

这个时候再回去看看官网提供的图片,应该就清晰很多了。

可以得知,这种巧妙的结构解决了很多问题:

  1. 节省磁盘空间:由于使用硬链接,相同的包不需要被重复存储,大大减少了磁盘空间的需求。
  2. 提高安装速度:安装包时,pnpm 通过创建链接而非复制文件,这使得安装过程非常快速。
  3. 确保依赖隔离:通过软链接有效减少了幽灵依赖产生的可能,同时保证了依赖的隔离。

依赖安装优化

与此同时,pnpm 在依赖安装的速度上也有显著的提升,这得益于 pnpm 将依赖的安装从串行改为了并行执行。

传统安装

image.png

传统方法中,包的安装分为三个主要阶段,线性执行:

  1. 解析(Resolving):解析依赖树,确定需要安装哪些包和版本。
  2. 获取(Fetching):下载包的压缩文件(tar 格式),这个阶段支持并行下载。
  3. 写入(Writing):将包解压,构建依赖树,并放置在 node_modules 目录。
pnpm 安装

image.png 可以看到在 pnpm 中(第二幅图),这些阶段对于每个包是同时进行的。一旦一个包被解析,它就开始下载,下载完毕后就立即开始写入。这样的并行处理显著提高了效率。

此外,pnpm增加了一个额外的步骤:

  • 链接(Linking):由于 pnpm 使用硬链接和符号链接来引用存储在全局 .pnpm-store 中的包,所以在写入阶段完成后,它还需要创建这些链接,以形成项目的 node_modules 目录结构。

这个链接过程很快,因为它避免了复制文件的开销。与传统方式相比,pnpm 通过这种方式有效地减少了文件的重复写入,并使得多个项目能够共享同一份物理拷贝的包,这在多个项目以及 Monorepo 环境下特别有利。

pnpm安装

1. 安装 Node.js

确保你已经安装了 Node.js。你可以通过以下命令检查是否已经安装:

node -v

如果尚未安装 Node.js,可以从 Node.js 官网 下载并安装。

2. 使用 npm 安装 pnpm

在终端中运行以下命令以全局安装 pnpm:

npm install -g pnpm
3. 验证安装

安装完成后,可以通过以下命令验证 pnpm 是否成功安装:

pnpm -v

如果返回 pnpm 的版本号,则说明安装成功。

4. 使用 pnpm

一旦安装完成,你可以开始使用 pnpm 来管理你的项目依赖。例如:

  • 初始化新项目

    pnpm init
    
  • 安装依赖

    pnpm install <package-name>
    
  • 安装开发依赖

    pnpm add <package-name> --save-dev
    
  • 卸载依赖

    pnpm remove <package-name>
    
  • 更新依赖

    pnpm update
    
5. 工作区支持

如果你在一个多包项目(工作区)中使用 pnpm,可以通过以下命令来初始化工作区:

pnpm init -w

pnpm 常用命令

基本命令
  1. 初始化项目

    # 创建一个新的 `package.json` 文件
    pnpm init
    
  2. 安装依赖

    # 安装 `package.json` 中列出的所有依赖
    pnpm install
    
  3. 安装特定依赖

    pnpm add <package-name>
    

    安装指定的包,并将其添加到 dependencies 中。

  4. 安装开发依赖

    # 安装指定的包,并将其添加到 `devDependencies` 中
    pnpm add <package-name> --save-dev
    
  5. 卸载依赖

    # 卸载指定的依赖包
    pnpm remove <package-name>
    
  6. 更新依赖

    # 更新项目中所有依赖到最新版本
    pnpm update
    
  7. 更新特定依赖

    # 更新指定的依赖包
    pnpm update <package-name>
    
锁定和查看
  1. 查看已安装的依赖

    # 列出项目中已安装的所有依赖
    pnpm list
    
  2. 查看全局安装的包

    # 列出全局安装的依赖,不显示子依赖
    pnpm list -g --depth 0
    
  3. 生成锁定文件

     # 只生成或更新 `pnpm-lock.yaml` 文件,不实际安装依赖 (一般都是 pnpm i/add 后自动生成即可)
     pnpm install --lockfile-only
    
工作区支持
  1. 初始化一个工作区

    # 创建一个工作区的 `package.json` 文件
    pnpm init -w
    
  2. 在工作区中添加包

    # 在工作区中添加指定的包
    pnpm add <package-name> --workspace
    
脚本命令
  1. 运行脚本

    # 运行在 `package.json` 中定义的脚本
    pnpm run <script-name>
    
其他常用选项
  • 清除缓存

    # 清理不再使用的缓存包
    pnpm store prune
    
  • 查看帮助信息

    # 显示所有 pnpm 命令的帮助信息
    pnpm help
    

其他包管理器

虽然 npm、Yarn 和 pnpm 是目前最流行的 JavaScript/Node.js 包管理器,但也存在一些其他的选项:

  • Bower(已废弃):曾经是一个流行的前端包管理器,但由于其设计限制(如缺乏对嵌套依赖的支持),已经被社区广泛弃用。
  • Volta:专门为 Node.js 应用提供一致的开发环境,能够自动切换 Node.js 版本。

选择合适的包管理器

选择哪种包管理器取决于你的具体需求和技术栈偏好。对于大多数新项目来说,npm 和 pnpm 是非常好的选择,因为它们都提供了出色的性能和广泛的社区支持。如果你需要特别关注依赖一致性或更快的安装速度,那么 Yarn 或 pnpm 可能更适合你。此外,随着 pnpm 的快速发展及其独特的内容可寻址存储特性,在大型项目或多团队协作环境中,pnpm 正变得越来越受欢迎。

无论选择哪一个包管理器,确保遵循最佳实践,例如使用锁文件来固定依赖版本、定期检查和更新依赖以保持安全性和兼容性。

确实,选择哪种包管理器应该基于项目的具体需求和技术栈偏好。以下是更详细的分析,帮助你更好地理解如何根据这些因素做出选择:

1. npm 和 pnpm 的出色性能与广泛支持

  • npm:作为 Node.js 的默认包管理器,npm 拥有一个庞大的生态系统和活跃的社区。它不仅提供了丰富的命令行接口来管理依赖项,还拥有内置的安全审计功能,帮助识别潜在的安全问题。npm 的 package-lock.json 文件确保了不同环境中依赖的一致性。
  • pnpm:pnpm 是一个快速且节省磁盘空间的包管理工具,它通过内容可寻址存储(CAS)机制来避免重复下载相同的依赖包,从而提高了安装速度并减少了磁盘使用量。对于大型项目或多团队协作环境,pnpm 的优势尤为明显,因为它能够有效减少多项目间的依赖冗余,并且对 monorepos 提供了良好的支持。

2. Yarn 和 pnpm 的依赖一致性及安装速度

  • Yarn:Yarn 引入了 yarn.lock 文件,确保了不同环境中依赖的一致性。它的并行化下载机制显著提升了安装速度,尤其是在网络条件较好的情况下。此外,Yarn 支持离线模式,即使没有互联网连接也能安装之前缓存过的依赖包。
  • pnpm:除了上述提到的优点外,pnpm 还以其高效的磁盘空间管理和安装性能著称。它采用了硬链接和符号链接的方式将依赖包安装到每个项目的 node_modules 目录下,这不仅加快了安装过程,也保证了依赖关系的清晰度,避免了嵌套过深的问题。

3. pnpm 在大型项目中的应用

随着 pnpm 的快速发展,越来越多的企业级项目开始采用它作为首选的包管理工具。pnpm 的独特之处在于它可以有效地处理复杂的依赖关系,并且在多项目环境下表现出色。例如,在 monorepo 架构中,pnpm 能够很好地管理多个包之间的依赖,同时保持高效的工作流。

4. 最佳实践建议

无论选择了哪一种包管理器,遵循以下最佳实践都是非常重要的:

  • 使用锁文件固定依赖版本:无论是 npm 的 package-lock.json 还是 Yarn 的 yarn.lock,锁文件都能确保每次安装的结果一致,这对于维护项目的稳定性和可重复构建至关重要。
  • 定期检查和更新依赖:随着时间推移,新的安全补丁和功能改进会被添加到各个库中。因此,定期运行如 npm audit 或者 yarn upgrade 来查找并修复可能存在的漏洞或兼容性问题是必要的。
  • 关注安全性:所有现代包管理器都提供了一定程度的安全保障措施,比如加密哈希验证、安全警告等。确保你的开发流程中包含了这些特性,以保护应用程序免受恶意软件的影响。

总结

虽然 npm 和 pnpm 都是非常优秀的选项,但在特定场景下,Yarn 或 pnpm 可能会更适合某些开发者的需求。特别是当涉及到依赖一致性和安装速度时,Yarn 和 pnpm 各有千秋;而对于大型项目或多团队协作环境,则应优先考虑 pnpm 的优势。最终的选择应当结合自身的技术栈以及项目的实际需求来决定。