详谈pnpm及其治理依赖的最佳实践方法

712 阅读15分钟

书接上文,上篇文章和大家分享到现代各种包管理器的发展以及横空出世的包管理器工具pnpm,今天,小编就和大家详细的来聊一聊pnpm这个包管理器以及它是如何治理幽灵依赖的。

pnpm是什么

pnpm 是一种替代 npm 的包管理工具,具有高效性和节省磁盘空间的特点。它在前端开发中的作用和优势主要体现在以下几个方面:pnpm 中文网站

pnpm 在前端开发中的作用和优势

1. 高效的包管理

  • 快速安装:pnpm 使用内容寻址存储依赖包,允许重复使用相同版本的包,极大地提高了安装速度。
  • 链接的依赖:pnpm 在安装包时,会将包以符号链接的方式存储,减少了文件的重复拷贝。

2. 节省磁盘空间

  • 冻结依赖:pnpm 将依赖包安装到全局存储区,每个项目只存储符号链接而不是复制整个包,这样可以有效节省硬盘空间。
  • 共享依赖:多个项目可以共享相同版本的依赖,避免不必要的磁盘开销。

3. 强制一致性

  • 严格的依赖管理:pnpm 会检查每个包的依赖关系,以确保所有包都正确满足其依赖项。这有助于避免依赖冲突和不一致问题。
  • 使用 shamefully-hoist:pnpm 允许设置 shamefully-hoist 选项,将部分依赖提升至顶层。这在某些特定情况下可以解决依赖问题。

4. 更好的工作区支持

  • 工作区(Workspaces) :pnpm 对工作区有很好的支持,允许在单一的仓库中管理多个包,有助于管理大型项目或微服务架构。

5. 更强的兼容性

  • 兼容 npm Script:pnpm 支持 npm 的所有脚本,可以与现有项目无缝集成。使用 pnpm 安装后,所有 npm 脚本依然可以正常工作。

6. 简单易用

  • 命令行接口:pnpm 的命令行接口与 npm 类似,大多数命令都可以直接替换成 pnpm 使用,例如 pnpm installpnpm add 等,降低了切换的学习成本。

7. 开发体验

  • 锁定文件:pnpm 自动生成 pnpm-lock.yaml 文件以锁定依赖,确保团队成员或 CI/CD 系统在安装时使用相同版本的依赖。

小结

pnpm 在前端开发中的作用主要是提高包管理的效率,节省磁盘空间,确保依赖的一致性,提升开发体验,尤其适合处理大型项目或多包仓库。开发者可以根据项目需求选择使用 pnpm,以享受其带来的优化和便利。

pnpm的安装和一些常用的命令在上文已经和大家介绍分享了,这里我就不在多说了哈。

pnpm解决了什么npm没解决的问题

1. 磁盘空间的浪费

  • 问题:npm 在每个项目中都会单独安装依赖,即使是相同版本的依赖也会重复存储,导致磁盘空间的浪费。
  • 解决方案:pnpm 使用内容寻址存储和符号链接,允许多个项目共享相同版本的依赖。这样,只有一份依赖会被存储在硬盘中,从而显著节省磁盘空间。

2. 安装速度

  • 问题:npm 在处理多个已安装依赖时,安装速度可能较慢,特别是在大项目中。
  • 解决方案:pnpm 在安装依赖时通过使用缓存和符号链接来提高效率,减少安装时间,特别是在安装多个包时。

3. 严格的依赖管理

  • 问题:npm 对于对等依赖的处理较宽松,可能导致不一致性和依赖冲突,尤其是在大型项目中。
  • 解决方案:pnpm 强制执行对等依赖的解析,确保所有依赖都满足其声明的要求,从而减少潜在的版本冲突和不兼容问题。

4. 工作区支持

  • 问题:虽然 npm 在现代版本中增加了工作区的支持,但在处理大型 mono-repo 项目时可能仍然存在一些限制。
  • 解决方案:pnpm 的工作区模式非常强大,能够轻松管理多个包,并且允许共享依赖,使得开发和集成变得更加高效。

5. 安装过程中的一致性

  • 问题:npm 可能在不同的环境(如 CI/CD 系统和开发者本地)中产生不同的结果,特别是在安装依赖的途中。
  • 解决方案:pnpm 生成的 pnpm-lock.yaml 文件能够精确锁定所有依赖及其版本,确保在不同环境中的安装结果一致。

6. 性能

  • 问题:npm 的性能在面对大量依赖时可能会有所下降,特别是在对依赖进行合并和更新时。
  • 解决方案:pnpm 的模式通过避免依赖重复安装,并利用有效的缓存策略,显著提升了性能。

总结

pnpm 显著解决了幽灵依赖的问题,这也是它相较于 npm 的一个显著优点。

什么是幽灵依赖?

幽灵依赖是指某个包在使用时,它的代码依赖于另一个包,但该包并没有明确列在 dependenciesdevDependencies 中。当这个依赖(通常是直接依赖的间接依赖)未被安装在项目的 node_modules 目录中时,可能会导致运行时错误,特别是在某些情况下,代码可能会假设这个依赖是存在的。

举个例子

假设你有以下两个包:

1.package-a

{
  "name": "package-a",
  "version": "1.0.0",
  "dependencies": {
    "package-b": "^1.0.0"
  }
}

2.package-b

{
  "name": "package-b",
  "version": "1.0.0"
}

幽灵依赖的示例

现在,假设有一个第三个包 package-c,使用了 package-a

{
  "name": "package-c",
  "version": "1.0.0",
  "dependencies": {
    "package-a": "^1.0.0"
  }
}

在这个例子中,package-c 依赖于 package-a,而 package-a 又依赖于 package-b。正常情况下,如果你运行 npm installpackage-b 会被安装在 package-anode_modules 中。

假设 package-b 作为一个对 package-c 至关重要的依赖,它在 package-a 的实现中被使用,但在 package-cpackage.json 中没有明确列出 package-b

运行时错误

如果 package-c 的代码中直接调用了 package-b 的功能,代码会抛出一个错误,因为在 package-c 中并没有安装 package-b,尽管它的某个依赖(package-a)实际上依赖了 package-b。运行时可能会出现像这样的错误:

Error: Cannot find module 'package-b'

解决幽灵依赖

为了解决这个问题,应该在 package-cpackage.json 中明确列出对 package-b 的依赖:

{
  "name": "package-c",
  "version": "1.0.0",
  "dependencies": {
    "package-a": "^1.0.0",
    "package-b": "^1.0.0"  // 明确添加对 package-b 的依赖
  }
}

现在,任何使用 package-c 的人都能确保他们的环境里有 package-b,从而避免幻影依赖引起的运行时错误。

pnpm 如何处理幽灵依赖的问题?

  1. 严格的依赖管理
    • pnpm 强制要求所有的依赖都必须在 package.json 中进行显式声明。这意味着如果某个包依赖于其他包,那么这些包也需要在相应的 dependenciesdevDependencies 中列明,避免出现没有明确定义的依赖。
  2. 建立符号链接
    • 与 npm 使用传统的 node_modules 结构不同,pnpm 使用符号链接和内容地址存储。即使是对等依赖,pnpm 也会记录并确保依赖关系的完整性,从而减少或消除幻影依赖的风险。
  3. 对等依赖的严格解析
    • pnpm 在安装过程中将检查对等依赖(peer dependencies),如果未满足,npm 会发出警告。这样可以确保在使用某个依赖时,其所需的对等依赖在正确的位置和版本中安装。

但是大家有没有注意到我刚刚说的也是显著解决,为啥,就算使用 pnpm,幽灵依赖还是难以根除,接下来和大家分享分享聊聊幽灵依赖产生的根本原因。

幽灵依赖产生的根本原因

包管理器工具的依赖解析机制

幽灵依赖产生的根本原因之一是现代包管理器(如 npm 和 Yarn)的依赖扁平化机制(hoisting),这就是上文介绍的平铺式带来的问题,这里我就不在过多的讲述了。

第三方库历史问题

由于历史原因或开发者的疏忽,有些项目可能没有正确地声明所有直接使用的依赖。对于三方依赖,幽灵依赖已经被当做了默认的一种功能来使用,提 issue 修复的话,周期很长,对此 pnpm 也没有任何办法,只能做出妥协。

下面是 pnpm 的处理方式:

  • 对直接依赖严格管理:对于项目的直接依赖,pnpm 保持严格的依赖隔离,确保项目只能访问到它在package.json 中声明的依赖。
  • 对间接依赖妥协处理:考虑到一些第三方库可能依赖于未直接声明的包(幽灵依赖),pnpm 默认启用了 hoist 配置。这个配置会将一些间接依赖提升(hoist)到一个特殊的目录 node_modules/.pnpm/node_modules中。这样做的目的是在保持依赖隔离的同时,允许某些特殊情况下的间接依赖被访问。

image.png

javaScript模块解析策略

Node.js 的模块解析策略允许从当前文件夹的 node_modules 开始,向上遍历文件系统,直到找到所需模块。

这种解析策略,虽然提供了灵活性,也使得幽灵依赖更容易产生,因为它允许模块加载那些未直接声明在项目package.json 中的依赖。

综合来看,幽灵依赖在目前是无法根除的,只能通过一些额外的处理进行管控,比如 eslint 对幽灵依赖的检查规则、pnpm 的 hoist 配置等。

pnpm 项目的依赖治理方案

对于依赖治理,大概涉及到以下几个部分:

  • 冗余依赖治理:某些包可能用不到,但还保留着,导致 package.json 愈发混乱。
  • 重叠依赖治理:monorepo 中 case 较多,比如根目录与子项目声明了相同的包,加大了 package.json 的管理成本,还有可能出现同一包多版本的问题。
  • 锁文件保护:要保证 package.json 与锁文件(pnpm-lock)的统一,防止其他开发者拉下代码后,因不统一造成一些功能差异。

冗余依赖治理

对于冗余的情况,可以按照如下顺序检查:

  1. 执行 pnpm why <package-name>,用来找出项目中一个特定的包被谁所依赖,给出包的依赖来源。
  2. 全局搜索包名,检查是否有被引入。
  3. 了解包的作用,判断项目中是否存在包的引用。
  4. 删除包,执行 pnpm i 后,分别运行、打包项目,查看是否有明显问题。

按照顺序执行完毕后,仍然可能存在问题,这是没法完全避免的,可以进一步通过测试进行排查。

重叠依赖的治理

重叠依赖(也称为重复依赖或版本冲突)是指项目中多个依赖包引用了同一个包的不同版本。pnpm 通过其独特的依赖管理机制,可以有效地治理重叠依赖问题。

pnpm 如何治理重叠依赖

  1. 符号链接和全局存储
    • pnpm 使用符号链接将依赖包链接到项目的 node_modules,而不是直接复制文件。
    • 所有依赖包都存储在全局存储中(通常位于 ~/.pnpm-store),多个项目可以共享相同的依赖包,减少磁盘空间占用。
  2. 隔离的依赖树
    • pnpm 为每个包创建独立的依赖树,确保每个包只能访问其直接依赖,而不会受到其他包的影响。
    • 如果多个包依赖同一个包的不同版本,pnpm 会将不同版本分别安装,并通过符号链接正确引用。
  3. 避免依赖提升
    • 与 npm 和 Yarn 不同,pnpm 不会将依赖提升到根目录的 node_modules,从而避免了版本冲突。

重叠依赖的治理最佳实践

以下是如何在 pnpm 中最佳治理重叠依赖的具体实践:

1. 使用 pnpm 的默认行为

  • pnpm 的默认行为已经能够很好地处理重叠依赖。它会为每个包创建独立的依赖树,并将不同版本的依赖包分别存储和链接。
  • 例如,如果包 A 依赖 lodash@^4.0.0,而包 B 依赖 lodash@^3.0.0,pnpm 会将两个版本的 lodash 分别安装,并通过符号链接正确引用。

2. 使用 pnpm dedupe

  • 如果项目中存在多个版本的相同依赖,可以使用 pnpm dedupe 命令尝试减少重复依赖。
  • 该命令会尝试将依赖树中重复的包合并为单个版本(如果版本兼容)。
pnpm dedupe

3. 显式声明依赖版本

  • package.json 中显式声明依赖版本,避免依赖版本冲突。

  • 例如,如果项目依赖 lodash,可以在 package.json 中明确指定版本:

    {
      "dependencies": {
        "lodash": "^4.0.0"
      }
    }
    

4. 使用 resolutions 字段(可选)

  • 如果某些依赖包的版本冲突无法通过默认机制解决,可以在 package.json 中使用 resolutions 字段强制指定依赖版本。

  • 例如,强制所有依赖使用 lodash@^4.0.0

    {
      "resolutions": {
        "lodash": "^4.0.0"
      }
    }
    

5. 检查依赖树

  • 使用 pnpm why <package> 命令检查某个依赖包在项目中的使用情况。

  • 例如,检查 lodash 的依赖关系:

    pnpm why lodash
    

6. 使用 pnpm overrides

  • 如果某些依赖包的版本冲突无法通过其他方式解决,可以使用 pnpm overrides 字段覆盖依赖版本。

  • 例如,强制将 lodash 的版本锁定为 4.17.21

    {
      "pnpm": {
        "overrides": {
          "lodash": "4.17.21"
        }
      }
    }
    

7. 定期更新依赖

  • 使用 pnpm update 定期更新依赖包,确保依赖版本保持最新,减少版本冲突的可能性。

  • 例如:

    pnpm update
    

8. 使用 Monorepo 管理多项目

  • 如果项目是一个 Monorepo,可以使用 pnpm workspace 功能管理多个子项目的依赖。
  • 在 Monorepo 中,可以通过共享依赖减少重复安装,同时避免版本冲突。

锁文件保护

核心目的:保证任何开发者在拉取代码后,执行 pnpm i 不会导致 pnpm-lock 发生更新。

核心目的:保证任何开发者在拉取代码后,执行 pnpm i 不会导致 pnpm-lock 发生更新。

初步方案

  1. 限制 pnpm、node 版本
  2. 本地在 lint-stage 中新增脚本检查是否存在意外更新
  3. CI 流水线上检查是否存在意外更新

具体实现

核心思路:检测 pacakge.json 和 pnpm-lock 的依赖是否对等。

我们可以先观察一下 pnpm i 的 options:

Version 7.26.3
Usage: pnpm install [options]

Options:
      --[no-]color                      Controls colors in the output. By default, output is always colored when
                                        it goes directly to a terminal
      --[no-]frozen-lockfile            Don't generate a lockfile and fail if an update is needed. This setting
                                        is on by default in CI environments, so use --no-frozen-lockfile if you
                                        need to disable it for some reason
      --[no-]verify-store-integrity     If false, doesn't check whether packages in the store were mutated
      --aggregate-output                Aggregate output from child processes that are run in parallel, and only
                                        print output when child process is finished. It makes reading large logs
                                        after running `pnpm recursive` with `--parallel` or with
                                        `--workspace-concurrency` much easier (especially on CI). Only
                                        `--reporter=append-only` is supported.
      --child-concurrency <number>      Controls the number of child processes run parallelly to build node
                                        modules
  -D, --dev                             Only `devDependencies` are installed regardless of the `NODE_ENV`
  -C, --dir <dir>                       Change to directory <dir> (default:
                                        /Users/bytedance/Desktop/项目/byteview-mm-we
      --fix-lockfile                    Fix broken lockfile entries automatically
      ……
      --lockfile-dir <dir>              The directory in which the pnpm-lock.yaml of the package will be created.
                                        Several projects may share a single lockfile.
      --lockfile-only                   Dependencies are not downloaded. Only `pnpm-lock.yaml` is updated
      ……
Visit https://pnpm.io/7.x/cli/install for documentation about this command.

从中可以提取出一些可能有用的 option:

  • -- frozen-lockfile:检查 package.jsonpnpm-lock.yaml 文件是否一致(即如果依赖项更新是必要的),命令会直接失败。
  • --fix-lockfile:检查当前项目的依赖关系,并更新 pnpm-lock.yaml 以确保其中记录的包版本与 package.json 中的声明一致。
  • --lockfile-dir :网上找不到相关的文档,根据说明可以判断是控制 pnpm-lock.yaml 的生成路径。
  • --lockfile-only:只更新 pnpm-lock.yamlpackage.json。 不写入 node_modules 目录。

首先我们可以排除 --lockfile-dir <dir>,由于我们只需要检查问题而不用修复问题,剩余的 option 中 -- frozen-lockfile 是最符合预期的,在 github 上也有相关需求的讨论,基于这个 option,我实现了一个基础脚本:

import { execSync } from 'child_process';
import { chalk } from 'zx';

try {
  // 尝试使用 --frozen-lockfile 选项来更新依赖
  execSync('pnpm install --frozen-lockfile', { stdio: 'inherit' });
  console.log(
    chalk.green('✅ The pnpm-lock.yaml is up-to-date. No updates are needed.')
  );
} catch (error) {
  // 如果命令失败,则可能是因为需要更新 lockfile
  console.error(
    chalk.red(
      '🚨 Detected that pnpm-lock.yaml needs an update. Please run pnpm install and commit the updated lockfile.'
    )
  );
  process.exit(1);
}

基于这个脚本,我们还可以加入一些额外的判断工作,如:pnpm、node 版本不一致判断。

如果还要扩展,可以考虑使用 pnpm hooks 或者相关的工具,比如:lfx.rushstack.io/,感兴趣的可以进行查阅…

最后

通过这些机制,pnpm 使得项目的依赖关系更加清晰和可靠,显著减少了幽灵依赖的问题。这种严格的依赖管理策略有助于提升代码的稳定性和可维护性。因而依据上面各种分析,pnpm优势瞬间突出,也是目前pnpm这么多人喜欢使用的原因。虽然 pnpm 的优势非常明显,但目前 pnpm 的生态还在成长阶段,一些功能还没法在网络上找到最佳实践,这需要一定的时间去沉淀,但经过权衡,拥抱 pnpm 无疑是一个非常好的选择!好啦,小编今天的分享就到这里啦,觉得文章对你有帮助的话还请大家一键三连哈,感谢感谢!