跳到主要内容

认识

2023年02月06日
柏拉文
越努力,越幸运

一、认识


NPM Monorepo npm install 之前:

npm-monorepo/
├── apps/
│ └── appA/
│ └── package.json // lodash@4.17.21 axios@1.7.8 react
│ └── appB/
│ └── package.json // lodash@4.17.15 axios@1.7.0 react
└── package.json // lodash@4.17.9 axios@1.6.5 react, 将 appA 和 appB 加入工作空间

NPM Monorepo npm install 完成:

npm-monorepo/
├── node_modules/
│ └── appA/ // 指向 appA
│ └── node_modules
│ └── package.json
│ └── appB/ // 指向 appB
│ └── node_modules
│ └── package.json
│ └── asynckit // 没有下一级 node_modules
│ └── axios // 没有下一级 node_modules
│ └── combined-stream // 没有下一级 node_modules
│ └── delayed-stream // 没有下一级 node_modules
│ └── follow-redirects // 没有下一级 node_modules
│ └── form-data // 没有下一级 node_modules
│ └── lodash // 没有下一级 node_modules
│ └── mime-db // 没有下一级 node_modules
│ └── mime-types // 没有下一级 node_modules
│ └── proxy-from-env // 没有下一级 node_modules
│ └── react // 没有下一级 node_modules
├── apps/
│ └── appA/
│ └── node_modules/
│ └── axios // axios 没有下一级 node_modules
│ └── lodash // lodash 没有下一级 node_modules
│ └── package.json // lodash@4.17.21 axios@1.7.8 react
│ └── appB/
│ └── node_modules/
│ └── axios // axios 没有下一级 node_modules
│ └── lodash // lodash 没有下一级 node_modules
│ └── package.json // lodash@4.17.15 axios@1.7.0 react
└── package.json // lodash@4.17.9 axios@1.6.5 react, 将 appA 和 appB 加入工作空间

因此 npm install 过程如下:

  1. 解析工作空间: NPMMonorepo 项目中通过 package.json workspaces 定义哪些子项目属于工作空间, 确保依赖共享。

  2. 解析依赖树: NPM 解析根 package.json 和各子项目的 package.json,并分析每个依赖的版本需求, 它会解析出这些依赖项的依赖树,构建整个项目的全局依赖树, 确保所有的依赖项及其子依赖项都被正确解析和记录。

  3. 安装依赖: NPM 默认将所有依赖提升到根级 node_modules, 如果子项目的依赖版本与根项目的依赖冲突, NPM 会在子项目的 node_modules 中直接安装该版本,保证依赖的隔离性。所有依赖不再具有嵌套的 node_modules 文件夹,完全扁平化。根级的 node_modules 是全局共享的, 相同版本的依赖在整个 Monorepo 中只安装一次,极大地减少了磁盘空间浪费。子项目的 node_modules 文件夹只包含对其独有依赖的软链接(与根项目的依赖冲突的依赖)。

  4. 生成 package-lock.json 文件: 创建或更新 yarn-lock.json 文件,记录所有安装的依赖项及其精确版本和依赖关系。

  5. 最终扁平的 node_modules 结构: NPM 的设计理念是将依赖尽可能提升到根级,减少冗余, 如果子项目需要特定版本的依赖,NPM 会为其单独安装,保证隔离性。所有依赖不再具有嵌套的 node_modules 文件夹,完全扁平化。扁平化的结构避免了传统 NPM 中依赖深层嵌套的问题,依赖查找更加高效,避免了传统 NPM 中长路径导致的问题。所有的包都被提升到模块目录的根目录。 这样就导致了一个问题,源码可以直接访问和修改依赖,而不是作为只读的项目依赖, 造成了幽灵依赖的问题。

NPM 发展史:

NPM V1/V2: 最开始其实没有注重 npm 包的管理,只是简单的嵌套依赖,这种方式层级依赖结构清晰,但是随着 npm 包的增多,项目的迭代扩展,重复包越下载越多,造成了空间浪费,导致前端本地项目 node_modules 动辄上百 M。在业务开发中,安装几个项目,项目体积好几G,对使用者们极其不友好。入下图所示,依赖包 CAB 中都被引用了, 被重复下载了两次,其实是两个完全相同的东西。因此, NPM V1/V2 具有嵌套依赖、重复依赖, node_modules 体积过大, 嵌套过深

Preview

NPM V3: 从 v3 开始, 维护 扁平化依赖关系树。 这可以减少磁盘空间占用, 但却导致 node_modules 目录的混乱。由于 v1/v2 版本存在 嵌套依赖、重复依赖, node_modules 体积过大, 嵌套过深 的问题, 所以 npm 团队也意识到这个问题,通过 扁平化 的方式,将子依赖安装到了主依赖 所在项目中,以 减少依赖嵌套太深,和重复下载安装 的问题。如下图所示,A 的依赖项C 被提升到了顶层,如果后续有安装包,也依赖C,会去上一级的node_modules查找,如果有相同版本的包,则不会再去重复下载,直接从上一层拿到需要的依赖包C。扁平化方式 解决了相同包重复安装的问题, 也 一定程度上解决了依赖层级太深 的问题。为什么说是 一定程度 上呢? 因为如上图所示,B 依赖的C v2.0.0,并没有提升,依然是嵌套依赖。因为在两个依赖包 C 的版本号不一致,只能保证一个在顶层,上图所示 C v1.0.0 被提升了,v2.0.0 没有被提升,后续 v2.0.0 还是会被重复下载,所以当出现 多重依赖 时,依然会出现 重复安装 的问题。而且这个 提升 的顺序,也不是根据使用量优先提升,而是根据先来先服务原则,先安装的先提升。这会导致不确定性问题,随着项目迭代,npm i 之后得到的 node_modules 目录结构,有可能不一样。如优化方案、优化图解、优化效果所述, 通过 扁平化 的方式, 将 重复的子依赖依赖的某一个版本 提升至了 主依赖所在项目, 提升到了顶层。如上所示, 我们把C,提升到了顶层。 即使项目 package.json,没有声明过C,但是也可以在项目中引用到C,这就是幽灵依赖问题。

Preview

NPM V5: 对于 v3 存在的不确定性提升子依赖的问题, npm v5 借鉴 yarn 的思想,新增了 package-lock.json。该文件里面记录了 package.json 依赖的模块,以及模块的子依赖。并且给每个依赖标明了版本、获取地址和验证模块完整性哈希值。通过 package-lock.json,保障了依赖包安装的确定性与兼容性,使得每次安装都会出现相同的结果。这个就解决了不确定性的问题。

二、问题


4.1 npm vs cnpm vs yarn vs pnpm

npm: NPM 默认将所有依赖提升到根级 node_modules, 如果子项目的依赖版本与根项目的依赖冲突, NPM 会在子项目的 node_modules 中直接安装该版本,保证依赖的隔离性。所有依赖不再具有嵌套的 node_modules 文件夹,完全扁平化。根级的 node_modules 是全局共享的, 相同版本的依赖在整个 Monorepo 中只安装一次,极大地减少了磁盘空间浪费。子项目的 node_modules 文件夹只包含对其独有依赖的软链接(与根项目的依赖冲突的依赖)。扁平化的结构避免了传统 NPM 中依赖深层嵌套的问题,依赖查找更加高效,避免了传统 NPM 中长路径导致的问题。所有的包都被提升到模块目录的根目录。 这样就导致了一个问题,源码可以直接访问和修改依赖,而不是作为只读的项目依赖, 造成了幽灵依赖的问题。

cnpm: cnpm 不生成 lock 文件,也不会识别项目中的lock文件

yarn: Yarn 默认将所有依赖提升到根级 node_modules, 如果子项目的依赖版本与根项目的依赖冲突, Yarn 会在子项目的 node_modules 中直接安装该版本,保证依赖的隔离性。所有依赖不再具有嵌套的 node_modules 文件夹,完全扁平化。根级的 node_modules 是全局共享的, 相同版本的依赖在整个 Monorepo 中只安装一次,极大地减少了磁盘空间浪费。子项目的 node_modules 文件夹只包含对其独有依赖的软链接(与根项目的依赖冲突的依赖)。扁平化的结构避免了传统 NPM 中依赖深层嵌套的问题,依赖查找更加高效,避免了传统 NPM 中长路径导致的问题。所有的包都被提升到模块目录的根目录。 这样就导致了一个问题,源码可以直接访问和修改依赖,而不是作为只读的项目依赖, 造成了幽灵依赖的问题。

pnpm: PNPM 使用了一种非扁平结构,PNPM 创建 符号链接 来构建嵌套的依赖关系图过程: 1. 直接依赖, 依赖首先 硬链接node_modules/.pnpm, 随后 符号链接node_modules 下, node_modules/.pnpm 是依赖的实际存放位置, 所以, node_modules 下只会存在 直接依赖 (package.json 中指定的依赖), 所以, 项目中不能直接访问 非直接依赖, 有效的解决了 幽灵依赖问题。2. 子级依赖(次级依赖): 将 直接依赖 依赖的 子级依赖(次级依赖) 硬链接node_modules/.pnpm, 然后 软链接 到与 直接依赖 同一级别 node_modules/.pnpm/直接依赖/node_modules/ 直接依赖,子级依赖……。所以, PNPM 使用软链与平铺目录来构建一个嵌套结构。而且, 直接依赖直接依赖的次级依赖(子依赖) 一起放到一个 ``node_modules/.pnpm/直接依赖/node_modules/ 直接依赖,子级依赖…… node_modules 文件夹, 它们处于同级目录, 可以避免循环符号链接。**PNPM** 通过 **分离式依赖树 (Flat Dependency Tree)** 通过符号链接实现不同版本的依赖共存, 实现了 **依赖隔离**, 比如 lodash@4.17.9lodash@4.17.15lodash@4.17.21 都被存储,因为它们的版本不同。相同的依赖内容只存储一次,实现了 **依赖共享**, 例如,follow-redirectsaxios的依赖,它虽然出现在多个axios版本中,但在.pnpm里只存储一次(如follow-redirects@1.15.9)。 **PNPM** 会生成 pnpm-lock.json` 文件。

4.2 为什么自己的 node_modules 没有C,也能在上层访问到 C 呢?

答: require 寻找第三方包,会每层级依次去寻找 node_modules,所以即便本层级没有node_moudles,上层有,也能找到

4.3 如果要更新某个依赖,直接修改 package.json 里的依赖版本,然后删除node_modules重新安装所有依赖可以吗?

通常情况下,这样操作是可以的

一、package.json 里面修改某个依赖版本

二、删除 node_modules

三、重新安装依赖包 yarn

某个情况下的操作

首先: 直接修改 package.json 里面的依赖版本,然后删除,这样操作的话是不会修改 package-lock.json 里面的版本的,再一次安装的时候有可能之前的版本和之后的版本都安装了

一、npm uni 依赖 删除指定依赖 二、npm i 依赖 重现安装指定全新依赖

参考资料


从npm发展历程看pnpm的高效