专栏名称: 前端大全
分享 Web 前端相关的技术文章、工具资源、精选课程、热点资讯
目录
相关文章推荐
前端早读课  ·  【第3452期】React 开发中使用开闭原则 ·  6 小时前  
启四说  ·  启四VIP策略网站,有哪些功能?如何使用? ·  16 小时前  
启四说  ·  启四VIP策略网站,有哪些功能?如何使用? ·  16 小时前  
前端早读课  ·  【第3451期】前端 TypeError ... ·  昨天  
江苏司法行政在线  ·  宿迁司法行政人、江苏监狱戒毒民警,给您拜年啦! ·  3 天前  
江苏司法行政在线  ·  宿迁司法行政人、江苏监狱戒毒民警,给您拜年啦! ·  3 天前  
51好读  ›  专栏  ›  前端大全

NPM 依赖管理的复杂性

前端大全  · 公众号  · 前端  · 2024-08-26 11:50

正文


这是一个很少被提及的话题 —— 「依赖管理」(Dependencies Management) 。

在开源文化盛行的现代,多数时候我们都不必从零开始搭建一套软件系统,转而可以借助诸多开放的代码片段及其他资源更快速高效开发软件应用,这算的上软件工程发展史上一次巨大革命,因为它能大幅提升软件工业的生产效率,我们不必再从底层开始编写所有代码,大部分问题与常见的编程模式都能在社区找到相应的解决方案,且这些被反复消费锤炼的软件包通常有更高的稳定性与性能,你需要做的只是花一些时间了解学习这些开源资源,并在项目使用它们,“ 「依赖」 ”它们即可,这已经是一种被不断实践,不断被验证为行之有效的开发模式。

但现实比预想的复杂许多,如果你开发的只是规模较小,或生命周期非常短的项目时,依赖的状态并不会造成多大问题,你只要确保当下执行 ok,功能符合预期即可。但,任何软件项目一旦叠加“ 「规模」 ”与“ 「时间」 ”两个变量之后,依赖网络就很容易变得复杂混乱,如果不及时施加恰当的管理手段则迟早会引发诸多晦涩难懂的稳定性、性能、安全等诸多方面的问题。因此,我们需要学习了解“依赖管理”的基本含义与潜在风险,需要掌握一些软件工程管理方面的方式方法,确保依赖网络及其随时间发生的变化都在可控范围内,让依赖网络尽可能保持一个清晰有条理,且具备一定程度的健壮性。

在展开具体内容之前,我们先明确一下“依赖”这个概念,严格来说,你的代码所需要消费的任何直接与间接资源都归属于“依赖”范畴,往小了说,包括系统时间、语言特性(如事件循环、闭包)、执行环境(如浏览器接口、node 接口)等;往大了说还包括:操作系统、网络,甚至硬件设备(如 GPU),但这些并不在本文讨论范围内,诚然这些要素也可能带来性能、安全性各方面的影响,但多数属于基础设施,稳定性还是比较有保障的。

本文希望更聚焦讨论 Node 场景下的依赖 —— 或者更直观的说是 NPM Package 结构的不稳定性所带来的被严重低估的质量风险,以及相应的应对策略。

第三方依赖带来的问题

09 年 NodeJS + NPM 的出现,不仅让 JavaScript 拥有了脱离浏览器环境执行的能力,也带来一套相对体系化的依赖管理方案,在此之前的依赖管理多数由“人”手工完成,需要用到什么就手动 copy 代码进仓库,或者 copy cdn 链接到 HTML 页面。

而 NPM(Node Package Manager) 让这件事情 「尽可能」 做到了自动化,我们只需要执行 npm install 命令即可自动完成下述工作:

  1. 解析依赖树 :根据项目 package.json 文件中的依赖项列表,递归 检查每个依赖项及子依赖项的名称和版本要求 ,构建出依赖树并计算每一个依赖需要安装的确切版本(这个并不容易做到,参考:Version SAT);

参考:https://research.swtch.com/version-sat

  1. 下载依赖项 :构建出完整的依赖树后,npm 会根据依赖项的名称和版本,下载相应的依赖包,下载过程还会对依赖包做一系列安全检查,防止被篡改;
  2. 安装依赖项 :当依赖项下载完成后,npm 将它们安装到项目的 node_modules 目录中。它会在该目录下创建一个与依赖项名称相对应的文件夹,并将软件包的文件和目录解压复制到相应的位置(不同包管理器最终产出的包结构不同);
  3. 解决依赖冲突 :在安装依赖项的过程中,可能会出现依赖冲突,即不同依赖项对同一软件包的版本有不同的要求。npm 会尝试解决这些冲突,通常采用版本回退或更新来满足所有依赖项的要求;
  4. 更新 package-lock.json :在安装完成后,npm 会更新项目目录下的 package-lock.json 文件。该文件记录了实际安装的软件包和版本信息,以及确切的依赖关系树,可用于确保在后续安装过程中保持一致的依赖项状态( npm ci );

PS: 本文仅以 NPM 举例,yarn、pnpm 的执行算法虽差异较大,但整体遵循上述过程,因此不再赘述。

相比于过往人工管理的各种低效且容易出错的骚操作,NPM 这类包管理器能以极低的成本,更规范化、自动化完成依赖包的检索、安装、更新等管理动作,更容易搭建出一个相对稳定且安全可靠的工程环境,也更容易复用外部那些经过良好封装、充分测试的代码片段。

But,伴随着工程能力的提升,依赖之间的复杂度也在急剧增长,当前我们正面临着更多依赖管理相关的工程问题,例如:幽灵依赖、版本冲突、依赖地狱等等,这些问题很少被讨论却时时刻刻影响着工程项目的稳定性、开发效率、性能等要素,接下来我会尽可能完整讨论依赖管理的方方面面,帮助大家更深入了解这些潜藏在日常开发之下很少被察觉的各类问题,并讨论相关的应对方案。

依赖管理潜在的问题

1. semver 并不稳定

先从依赖管理中最浅显直观的视角讲起,当我们决定使用某一个 NPM 包时,需要做的第一件事就是在项目 package.json 文件中定义 dependencies ,类似于:

{
  "name""foo",
  "dependencies": {
    "lodash""^1.0.0"
  }
}

这似乎已经是一种简单而自然,不需要过多讨论的常识,but,我们应该依赖于 Package 的那些版本呢?

答案取决于具体的功能需求、稳定性、性能等诸多因素,但一个大致通用的实践是: 「尽可能使用最新版本的范围版本」 ,例如假定 React 最新版本为 18.2.0 ,在项目中可以声明依赖为 "react": "^18.2.0" ,这种方法一方面能够应用最新版本 —— 这可能意味着更多的功能,以及更好的性能等;另一方面,借助 ^ 声明该依赖接受 >= 18.2.0 < 19 的版本范围,在 React 下次发布 18.2.1 或更大版本时都能自动匹配应用,以此获得一定范围内动态更新依赖的能力。

PS:补充一个知识点,当前多数框架都遵循 semver 版本号(https://semver.org/)规则,即包含 Major.Minor.Patch 三段版本号,Major 代表较大范围的功能迭代,通常意味着破坏性更新;Minor 代表小版本迭代,可能带来若干新接口但“承诺”向后兼容;Patch 代表补丁版本,通常意味着没有明显的接口变化。

这看似很完美,但实践却漏洞百出。首先,部分 NPM 包作者并没有严格遵守 semver 定义的规则迭代版本号,特别是许多公司内部依赖的版本管理更是混乱不堪, Patch 可能破坏原本的接口定义(多一个参数少一个参数), Minor 可能导致向后不兼容,等等,致使旧代码无法正常执行。

其次,即使完全按照 semver 语义严格管理版本号,谁又能保证每次版本迭代都能完美符合用户预期呢?例如按照 semver 语义, Patch 只用作 bug 修复,但难保会在一些边界情况发生变化,比如: 「日志」 ,讲道理用户不应该直接依赖代码包的日志输出,但可能有一些输入输出没有覆盖用户需求,或者用户没有了解到正确的使用方式,致使消费者倾向于直接从运行日志解读信息(只要用户体量足够大,总会出现一些意料之外的使用方法),若此时 Patch 版本更改了日志内容 —— 这看似很合理,却可能导致日志解析失败。从这个示例来说,日志算不算补丁更新呢?这种情况下, Patch 还是安全的吗?

那么,能不能放弃范围版本,写死版本号呢?例如上例中只要把依赖关系写死成 "react": "18.2.0" 似乎就能规避版本变化带来的不确定性?某种程度上确实如此,但这又会带来新的风险:版本累积可能带来更大的破坏性更新!我们必须承认一个事实:无论你有多强的惰性, 「软件项目只要存活的时间足够长,就总会有一天需要升级依赖」 ,升级的主因可能是:安全、合规、性能、架构调整等等,如果你从一开始就在使用某个固定版本,直到不得不更新的时刻到来时,新版本的使用方案、功能表现等可能都已经发生了剧变(例如,从 React 17 => 18 ),很可能会导致你原本运行良好的程序漏洞百出,质量风险、回归成本都很高。

因此, 「良好的依赖管理策略应该在保证稳定的前提下,定期跟进依赖包的更新」 ,小步快进将升级风险分摊到每一次小版本迭代中,为达成这一效果,一个比较 「常见」 的实践是在开发环境中使用适当的范围版本,在测试 & 生产环境使用固定版本,以 NPM 为例,可以继续沿用 "react": "^18.2.0" ,在开发态中使用 npm install 安装依赖,在测试 & 生产环境则使用 npm ci 命令,两者区别在于 npm install 会尝试更新依赖,触发依赖结构树变化并记录到 package-lock.json 文件;而 npm ci 则严格按照 package-lock.json 内容准确安装各个依赖版本,在 CI/CD 环境中能获得更强的稳定性,确保代码行为与开发环境尽可能一致。

2. 依赖类型

在确定依赖版本之后,接下来需要决定将依赖注册到那个 dependencies 节点,按 package.json 规则,可选类型有:

  • dependencies :生产依赖,指在软件包执行时必需的依赖项。这些依赖项是你的应用程序或模块的核心组成部分,当你部署到生产或测试环境时,这些依赖项都需要被安装消费;
  • devDependencies :开发依赖,仅在开发过程中需要使用的依赖项,通常包括测试框架、构建工具、代码检查器、TS 类型库等。开发依赖项不需要在生产环境安装;
  • peerDependencies :对等依赖,用于指定当前 package 希望宿主环境提供的依赖,这解释有点绕,下面我们会展开解释;
  • optionalDependencies :可选依赖,当满足特定条件时可以选择性安装的依赖,且即使安装失败,安装命令也不会中断。可选依赖项通常用于提供额外的功能或优化,并不是必需的;
  • bundledDependencies :捆绑依赖,用于指定需要一同打包发布的依赖项,用的比较少。

根据我们正在开发的软件包的用途及对依赖的使用方式,这里会有不同的决策逻辑。

假设正在开发的是“顶层”应用(Web APP、Service、CLI 等),那么多数依赖都可以注册到 devDependencies dependencies 节点,这也是我们日常应用比较多的依赖类型。两者主要差异在于: dependencies 是生产环境依赖,是确保软件包正常运行的必要依赖项;而 devDependencies 则是仅在开发阶段需要使用的依赖项。

举个例子,假设你应用逻辑中直接使用了 lodash 的方法,那么 lodash 必然是 dependencies ;但假设你只是在一些构建脚本之类的非应用逻辑中使用了 lodash ,那么应该将其注册到 devDependencies 中。

PS:对于需要将代码和依赖全部打包在一起的应用 —— 例如常见的基于 Webpack 的 web 应用,从功效上 dependencies 与 devDependencies 并无差别,但建议还是根据语义对依赖做好分类管理。

换个视角,假设正在编写的代码最终会被发布成 NPM Package 供其他方消费,那么我们必须慎重许多,因为你的决策会深刻影响消费者的使用体验。首先,你必须非常谨慎地使用 dependencies ,因为 NPM 在安装你这个 Package 会顺带将你的 package.json 中的 dependencies 也都安装一遍,错误的依赖分类可能会带来一些影响开发体验的 Bad Case:

  • 需要占用更多的安装依赖的时间;
  • 依赖结构更复杂,容易导致“菱形依赖”(后面会会展开解释)问题;

举个例子, @vue/cli package.json 部分内容如下:

{
  "name""@vue/cli",
  "version""5.0.8",
  ...
  "dependencies": {
    ...
    "vue""^2.6.14",
    ...
  },
  ...
}

那么使用者安装 @vue/cli 之后,还会强制安装 vue@^2.6.14 版本 —— 即使用户消费的可能是其他 Vue 版本,这种行为无疑都会给用户增加不必要的负担,因此,在开发 Package 时,除非有非常明确且强烈的诉求,否则都应该优先使用 devDependencies


那么,假设你的 Package 确实存在一些必要,但又不适合注册到 dependencies 的依赖,该怎么办呢?这种 Case 也非常常见,例如 Webpack 插件通常对 Webpack 存在强依赖,但并不适合直接使用 dependencies ,否则可能导致用户安装多份 Webpack 副本。针对这种情况 NPM 提供了另外一种依赖类型: peerDependencies ,语义上可以理解为:Package 希望宿主环境提供的“对等”依赖,NPM 对这种类型的处理逻辑稍微有点复杂:

  1. 若宿主提供了对等依赖声明(无论是 dependencies 还是 devDependencies ),则优先使用宿主版本,若版本冲突则报出警告:
  1. 若宿主未提供对等依赖,则尝试自动安装对应依赖版本(NPM 7.0 之后支持)。

PS:正是因为 peerDependencies 的复杂性,不同包管理器,甚至同一包管理器的不同版本对其处理逻辑都有所不同,例如 NPM 在 3.0 之前支持自动安装 peerDependencies ,但这一特性带来的问题比较多,3.0 之后取消了自动下载,交由消费者自行维护,一直到 7.0 版本设计了一种更高效的依赖推算算法之后,才又重新引入这一特性。

peerDependencies 能帮助我们实现:“ 「即要」 ”确保 Package 能正常运行,“ 「又要」 ”避免给用户带来额外的依赖结构复杂性,在开发 NPM Package,特别是一些“框架”插件、组件时可以多加使用,实践中通常还会:

  1. 使用 peerDependencies 声明 Wepack 为对等依赖,要求宿主环境安装对应依赖副本;
  2. 同时使用 devDependencies 声明 Wepack 为开发依赖,确保开发过程中能正确安装必要依赖项;

接下来聊一个相对冷门的类型: optionalDependencies ,也就是“可选”依赖,虽然多数时候我们对 Package 的依赖应该是比较明确的:要么有要么没有,但某些特定场景下也可能是“可以有也可以没有”。

举个例子, fsevents 是一个针对 「Mac OSX」 系统的文件系统事件监控库 —— 注意啊,它只适用于 「Mac OSX」 系统,因此在其他操作系统上都不能使用 —— 自自然然的也不需要安装这个 Package,因此可以是一个“可选”依赖,实际上在知名构建工具 rollup 中就是以 optionalDependencies 方式引入 fsevents 的:

{
  "name""rollup",
  "version""4.1.4",
  // ...
  "optionalDependencies": {
    "fsevents""~2.3.2"
  },
  // ...
}

需要注意, optionalDependencies 意味着“可能有也可能没有”,因此消费方式上也需要加以区分,例如 rollup 是这么导入 fsevents 的:

import type FsEvents from 'fsevents';

export async function loadFsEvents(): Promise {
  try {
    // 使用 `import` 函数异步导入,并做好异常判断
    ({ default: fsEvents } = await import('fsevents'));
  } catch (error: any) {
    fsEventsImportError = error;
  }
}

// ...

代码位置: rollup/src/watch/fsevents-importer.ts

optionalDependencies 非常适合用作处理“平台”强相关的依赖,除此之外还可用于性能兜底、交互功能兜底等场景,这里就不一一赘述了。


简单总结下, package.json 提供了若干影响安装行为的依赖类型属性,以应对不同场景的管理需求,开发者需要基于性能、可用性、稳定性等角度考虑谨慎判断依赖类型。当然,也有一些基本规则能帮助我们快速识别依赖类型,包括:

  • 常见的各类工程化工具,如 eslint、vitest、vite、jest、webpack 等等都适合放在 devDependencies
  • 各类 TS 类型包,例如 @types/react、@types/react-dom 一般也可以放在 devDependencies 中;
  • 开发框架插件时,尽可能将框架声明为 peerDependencies ,例如 webpack cache-loader
  • 平台强相关的依赖,可以考虑使用 optionalDependencies ,之后配合 postinstall 钩子执行平台相关的依赖安装 or 编译动作;
  • 等等。

3. 失控的依赖结构

思考一下: 「安装某个依赖时,需要附带安装多少子孙依赖」 ?很多同学此前可能没关注过这一块,这个问题并没有具体的通用答案,取决于你实际安装的包,但这个数量通常都不会很小。举个例子,知名的 React 组件库 antd 的依赖结构是这样的:

这张图肉眼可见的复杂。。。一旦我们决定使用 antd 则必须引入这一坨复杂的依赖结构,而这并不是孤例,不少知名框架都有类似问题,包括 jest、webpack、http-parser 等等,当我们依赖这些 Package 时,依赖结构最终会合并成一张庞大、复杂,且冲突不断的网络。

造成这一现象的原因其实不难理解,在当下开源文化环境下,跨组织的代码共享变得如此简单平常,即使是非常小的代码片段都可以以极低的成本贡献到社区供人使用。举个例子,在 NPM 上有一个这么一个 Package: escape-string-regexp ,它的核心代码算上注释才不到十行,但周下载量达到惊人的一亿次:

export default function escapeStringRegexp(string) {
        if (typeof string !== 'string') {
                throw new TypeError('Expected a string');
        }

        // Escape characters with special meaning either inside or outside character sets.// Use a simple backslash escape when it’s always valid, and a `\xnn` escape when the simpler form would be disallowed by Unicode patterns’ stricter grammar.return string
                .replace(/[|\\{}()[\]^$+*?.]/g, '\\$&')
                .replace(/-/g, '\\x2d');
}

在此背景下,多数时候 —— 包括开发 Package 时,多数开发者在解决特定需求时自然都会倾向于使用开源代码片段,这可以帮助作者跳过代码 「设计、开发、测试、调试、维护」 等步骤,进而提升开发效率。

这本是一种良好实践,但当它被广泛采用时,不可避免的会带来一个副作用:依赖粒度变得非常细小,依赖网络结构变得无比复杂庞大,而这又容易(或者说必然)触发更多负面效应,包括:

  • 需要计算依赖包之间的关系并下载大量依赖包,CPU 与 IO 占用都非常高,导致项目初始化与更新性能都比较差,我就曾经历过初始 yarn install 需要跑两个小时,加一个依赖需要跑半个小时的巨石项目。。。开发体验一言难尽;
  • 多个 Package 的依赖网络可能存在版本冲突,轻则导致重复安装,或重复打包,严重时可能导致 Package 执行逻辑与预期不符,引入一些非常难以定位的 bug,这个问题比较隐晦却重要,后面我们还会展开细讲;
  • 由于可能存在大量冲突,项目的依赖网络可能变得非常脆弱,某些边缘节点的微小变化可能触发依赖链条上层大量 Package 的版本发生变化,引起雪崩效应,进而影响软件最终执行效果,这同样可能引入一些隐晦的 bug;
  • 等等吧。

那么,如何应对这些问题呢?先说结论:没有一劳永逸完美方案,只能尽力降低问题出现的范围和影响。首先我们不应该因噎废食,即使存在上述问题,依赖外置更有助于提升模块之间的低耦合高内聚,保持更佳的可维护性,在此基础上,可以适当引入一些管理措施缓解症状,包括:

  1. 设定更严格的开源包审核规则 :除了周下载量、Star 数这些指标外,可以适当打开仓库看看代码结构是否合理,是否有单测,单测覆盖率多少,是否能通过单测,Issue 持续时间,二级依赖网络结构是否合理等等,确保依赖的质量是稳定可信赖的;
  2. 尽可能减少不必要的依赖 :在引入第三方库时,仔细审查其功能,看看是否真的需要使用整个库,或者我们仅需要其中的部分功能,有时我们可能自己实现(甚至 Copy)这些功能更为快速、更为简单,同时也减少了对第三方库的依赖;
  3. 分层依赖 :如果项目较大(monorepo?),可以将项目分层,每一层只能依赖相同层级或更基础的层级的库,这样可以降低各层之间的相互依赖,也有助于分层级管理依赖结构,减小变动对上游的影响;
  4. 避免循环依赖 :循环依赖绝对是一种可怕的灾难!它不仅会急剧提升依赖网络的结构复杂度,还很可能导致一些难以预料的问题,因此在做依赖结构审计时务必尽可能规避这类情况。

4. 幽灵依赖

“幽灵依赖”是指我们明明没有在 package.json 中注册声明某个依赖包,却能在代码中引用消费该 Package,之所以出现这个问题,归根到底主要是两个因素引起的:

  • NodeJS 的模块寻址逻辑;
  • 包管理器执行 install 命令后,安装下来的 node_modules 文件目录结构。

众所周知(吧?),在 NodeJS 以及 Webpack、Babel 等常见工程化工具中,当我们使用 require/import 导入外部依赖包时,NodeJS 会首先尝试在当前目录下的 node_modules 寻找同名模块,若未找到则沿着目录结构逐级向上递归查找 node_modules 直至系统根目录,例如在 /home/user/project/foo.js 文件中查找模块时,可能会在如下目录尝试寻找 Package:

/home/user/project/node_module
/home/user/node_module
/home/node_module
/node_module

若此时某些 Package 被安装在项目 project 路径的上层,则必然会被寻址逻辑命中,导致代码中能够“错误”引用到这个包。其次,即使不考虑这个目录递归寻址逻辑,NPM 与 Yarn 的扁平化 node_modules 结构也非常容易引起幽灵依赖问题。这里补充点历史知识,在 NPM@3 之前,每个模块的依赖项都会被放置在自己专属的 node_modules 文件夹内,即所谓的" 「嵌套依赖」 ",例如:

  • 依赖结构:
- A
  - B 
    - C 
- D
  - C
  • node_module 结构:
- node_modules
  - A
    - node_modules
      - B
        - node_modules
          - C
  - D
    - node_modules
      - C

这种方案非常容易导致依赖结构深度过大,最终可能导致文件路径超过了一些系统的最大文件路径长度限制(主要是Windows 系统),导致奔溃。这就引入 NPM@3 的优化策略:扁平化依赖结构,也就是将所有的模块 —— 无论是顶层依赖还是子依赖,都会直接写入到在项目顶层的 node_modules 目录中,例如:

  • 依赖结构:
- A
  - B 
    - C 
- D
  - C
  • node_module 结构:
- node_modules
  - A
  - B
  - C
  - D

这种目录结构看起来更简洁清晰,也确实解决了目录过深的问题。 「但是」 ,根据 NodeJS 的寻址逻辑,这也就意味着我们可以引用到任意子孙依赖!这种不明确的依赖关系是非常不稳定的,可能触发很多问题:

  • 不一致性 :幽灵依赖可能导致应用程序的行为在不同的环境中表现不一致,因为不同环境中可能缺少或包含不同版本的幽灵依赖;
  • 不可预测性 :本质上,幽灵依赖的是顶层依赖的依赖网络的一部分,你很难精细控制这些子孙依赖的版本,完全随缘;
  • 难以维护 :若你的代码中存在幽灵依赖,在依赖库升级或迁移时,幽灵依赖可能导致意外的兼容性问题或升级困难。

那么如何解决幽灵依赖问题呢?其实也比较简单,核心准则:请务必确保依赖关系是清晰明确的,一旦消费则必须在项目工程内注册依赖!有许多工具能帮我们达成这一点:

  1. 使用 pnpm :与 yarn、npm 不同,pnpm 不是简单的扁平化结构,而是使用符号链接将物理存储的依赖链接到项目的 node_modules 目录,确保每个项目只能访问在其 package.json 中明确声明的依赖;
  2. 使用 ESLint:ESLint 提供了不少规则用于检测幽灵依赖,例如 import/no-extraneous-dependencies ,只需要在项目中启用即可;
  3. 使用 depcheck:这是一个用于检测未使用的或缺失的 npm 包依赖,可以协助发现现存代码可能存在的幽灵依赖,类似的还有: npm-check 等。

5. 依赖冲突

依赖冲突通常发生在两个或多个包依赖不同版本的同一库时。设想这样一个场景:包 app 依赖了 lib-a、lib-b,而 lib-a、lib-b 又依赖了 lib-d,此时这几个实体之间之间形成了一种菱形依赖关系:

图解:菱形依赖

ok,菱形依赖本身是一种非常常见且合理的依赖结构,这不是问题,真正的问题出现在若此时 lib-a/lib-b 所依赖的 lib-d 版本不一致时,就会产生依赖冲突现象:







请到「今天看啥」查看全文