• 浅析node_modules如何解决依赖地狱问题、如何从node_modules加载package、目录结构的2种模式、版本重复及可能导致的问题、Semver规范及lock文件、pnpm解决理念介绍


      最近看到涉及 node_modules 的问题比较多,所以决定深入学习一下,正好看到一篇文章,写的还挺详细的

      Ryan对于node.js的十大遗憾之一就是支持了node_modules,node_modules的设计虽然能满足大部分的场景,但是其仍然存在着种种缺陷,尤其在前端工程化领域,造成了不少的问题,本文总结下其存在的一些问题,和可能的改进方式

    一、Dependency Hell 依赖地狱问题

      现在项目里有两个依赖A和C,A和C分别依赖B的不同版本,如何处理

      这里存在两个问题:

      1. 首先是B本身支持多版本共存,只要B本身没有副作用,这是很自然的,但是对于很多库如core-js会污染全局环境,本身就不支持多版本共存,因此我们需要尽早的进行报错提示(conflict的warning和运行时的conflict的check)

      2. 如果B本身支持多版本共存,那么需要保证A正确的加载到B v1.0和C正确的加载到B v2.0

      我们重点考虑第二个问题:node的解决方式是依赖的node加载模块的路径查找算法和node_modules的目录结构来配合解决的

      如何从node_modules加载package?

      核心是递归向上查找node_modules里的package,如果在 '/home/ry/projects/foo.js' 文件里调用了 require('bar.js'),则 Node.js 会按以下顺序查找:

    • /home/ry/projects/node_modules/bar.js
    • /home/ry/node_modules/bar.js
    • /home/node_modules/bar.js
    • /node_modules/bar.js

      该算法有两个核心:(1)优先读取最近的node_modules的依赖(2)递归向上查找node_modules的依赖

      该算法即简化了 Dependency hell 的解决方式,也带来了非常多的问题

    二、node_modules的目录结构

    1、nest mode 嵌套模式

      利用require先在最近的node_module里查找依赖的特性,我们能想到一个很简单的方式,直接在node_module维护原模块的拓扑图即可

      这样根据mod-a就近的使用mod-b的1.0版本,而mod-c就近的使用了mod-b的2.0版本

      但是这样带来了另一个问题,如果我们此时再依赖一个mod-d,该mod-d也同时依赖的mod-b的2.0版本,这时候node_modules就变成下面这样

      我们发现这里存在个问题,虽然 mod-a 和 mod-d 依赖了同一个mod-b的版本,但是mod-b却安装了两遍,如果你的应用了很多的第三方库,同时第三方库共同依赖了一些很基础的第三方库如lodash,你会发现你的node_modules里充满了各种重复版本的lodash,造成了极大的空间浪费,也导致npm install很慢,这既是臭名昭著的 node_modules hell

    2、flat mode 平坦模式

      我们还可以利用向上递归查找依赖的特性,将一些公共依赖放在公共的node_module里

      根据require的查找算法:

      1. A和D首先会去自己的node_module里去查找B,发现不存在B,然后递归的向上查找,此时查找到了B的 v1.0版本,符合预期

      2. C会先查找到自己的node_module里查找到了B v2.0,符合预期

      这时我们发现了即解决了depdency hell也避免了npm2 的nest模式导致的重复依赖问题

    三、doppelgangers 二重身问题

      但是问题并没有结束,如果此时引入的D依赖的是B v2.0,而引入的E依赖的是B v1.0,我们发现无论是把Bv2.0还是Bv1.0放在top level,都会导致任何另一个版本会存在重复的问题,如这里的B的v2.0的重复问题

      你也许会说版本重复不就是浪费一点空间吗,而且这种只有出现版本冲突的时候才会碰到,似乎问题不大,事实的确如此,然而某些情况下这仍然会造成问题

    四、版本重复可能导致的问题

    1、全局types冲突

      虽然各个package之前的代码不会相互污染,但是他们的types仍然可以相互影响,很多的第三方库会修改全局的类型定义,典型的就是@types/react,如下是一个常见的错误

      其错误原因就在于全局的types形成了命名冲突,因此假如版本重复可能会导致全局的类型错误。

      一般的解决方式就是自己控制包含哪些加载的@types/xxx。

    2、破坏单例模式

      require的缓存机制:node会对加载的模块进行缓存,第一次加载某个模块后会将结果缓存下来,后续的require调用都返回同一结果,然而node的require的缓存并非是基于module名,而是基于resolve的文件路径的,且是大小写敏感的,这意味着即使你代码里看起来加载的是同一模块的同一版本,如果解析出来的路径名不一致,那么会被视为不同的module,如果同时对该module同时进行副作用操作,就会产生问题。

    3、Phantom dependency 幻影依赖

      我们发现flat mode相比nest mode节省了很多的空间,然而也带来了一个问题即phantom depdency,考察下如下的项目

      我们编写如下代码

      这里的glob和brace-expansion都不在我们的depdencies里,但是我们开发和运行时都可以正常工作(因为这个是rimraf的依赖),一旦将该库发布,因为用户安装我们的库的时候并不会安装库的devDepdency,这导致在用户的地方会运行报错。

      我们把一个库使用了不属于其depdencies里的package称之为phantom depdencies,phantom depdencies不仅会存在库里,当我们使用monorepo管理项目的情况下,问题更加严重,一个package不但可能引入DevDependency引入的phantom依赖,更很有可能引入其他package的依赖,当我们部署项目或者运行项目的时候就可能出问题。

      在基于yarn或者npm的node_modules的结构下,doppelganger和phantom dependency似乎并没有太好的解决方式。其本质是因为npm和yarn通过 node resolve算法配合node_modules的树形结构对原本depdency graph的模拟,哪有没有更好的模拟方式能够避免上述问题呢。

    五、Semver 当理想遇到现实

      npm对package版本号采用语义化版本,Semver本身也是为了解决Depdency Hell而引入的解决方案,如果你的项目引入的第三方依赖越来越多,你将会面临一个困境。

      如果你为你的每一个版本都写死依赖,那么如果某个底层的依赖需要修复或者升级,你难以评估这个升级会修复的影响范围,这可能导致级联反应,与其协作的任何包都可能会挂掉,导致整个系统都需要全量的测试回归,最后的结果很大可能是整个应用彻底锁死版本,再也不敢做任何升级改动

      因此semver的提出主要是用于控制每个package的影响范围,能够实现系统的平滑升级和过渡,npm每次安装都会按照semver的限制,安装最新的符合约束的依赖。

      这样每次npm install都会安装符合"^4.0.0"约束的最新依赖,可能是4.42.0的版本。

      如果所有的库都能完美的遵守语义化版本,那么世界和平,然而现实是很多库因为种种原因并未遵守semver,原因包括:

    • 不可预知的bug,本来以为某个版本只是bugfix,发布了patch版本,但是该patch却引入了未预料的breaking change导致semver被破坏
    • semver的设计过于理想,实际上即使是最小的bugfix,如果业务方无意中依赖了这个bug,仍然会导致breaking change,bug和breaking change的界限是模糊的
    • 认为semver没有太大意义,例如Typescript官方就承认从未遵循semver语义,实际上typescript经常在minor版本引入各种breaking change.

    1、lock 非灵药

      那么在现实世界该如何处理这种问题,你肯定不希望自己的代码在本地是正常运行的,但是当你上线的时候就挂了吧。

      在你的测试完成和业务上线前的gap期间,如果你的某个依赖不遵循semver,产生了breaking change,那么你可能得半夜上线查bug了。我们发现问题的根源在于如何保证测试时候的代码和上线的代码是完全一致的。

    2、直接写死版本

      一个很自然的想法就是,我直接把我的第三方依赖版本都写死不就行了

      然而问题并没这么简单,虽然你锁定了webpack的版本,但是webpack的依赖却没法锁定,如果webpack的某个依赖法生产不遵循semver的breaking change,我们的应用还是会受到影响,除非我们保证所有的第三方以及第三方的依赖都是写死版本,这即意味着整个社区放弃semver,这显然是不可能的。

    3、yarn lock vs npm lock

      一个更加靠谱的写法是将项目里的依赖和第三方的依赖同时锁定,yarn的lock和npm的lock都支持该功能。

      如我们的项目安装了express的依赖,我们发现express的所有依赖及其依赖的依赖的版本在lock文件里都锁定了,这样另一个用户或者环境,能够凭借lock文件复现node_modules里各个库的版本。

      然而还是有一些场景lock无法覆盖,当我们第一次安装创建项目时或者第一次安装某个依赖的时候,此时即使第三方库里含有lock文件,但是npm install|(yarn install) 并不会去读取第三方依赖的lock,这导致第一次创建项目的时候,用户还是会可能触发bug。这在全局安装cli的场景下非常常见,经常会碰到上一次安装全局cli的时候正常,但是重新安装这个版本的cli却挂了,这很有可能是该cli的版本的某个上游依赖发生了breaking change,因为不存在全局环境的lock,因此目前没有较好的解决方式。

    4、库里应该提交lock文件吗

      前面提到npm和yarn在install的时候并不会读取第三方库里的lock文件,那么我们编写库的时候还有必要提供lock文件吗。

      不知道大家有没有过这种经验,某天发现了某个第三方库存在某个bug,摩拳擦掌的将该库下载下来,准备修复下发个mr,一顿npm install && npm build 操作猛如虎,然后就见到了一堆莫名其妙的编译错误,这些错误很可能个是编译工具的某个上游依赖的breaking change所致,经过一番google + stackoverflow仍然没有修复,这时候就基本上断了提mr的冲动,如果库的开发者将当前的编译环境的lock提交上来,则很大程度上可以避免该问题。

    六、pnpm 介绍

    1、pnpm在解决phantom depdency问题的同时,在此基础上也解决了doopelganger问题

    // 考察如下代码
    // package.json
    { 
      "dependencies": {
        "debug": "3",
        "express": "4.0.0",
        "koa": "^2.11.0"
      }
    }
    // 使用pnpm安装相关依赖后,我们发现项目中存在debug的两个版本
    dependencies:
    debug 3.1.0
    express 4.0.0
    ├── debug 0.8.1
    ├─┬ send 0.2.0
    │ └── debug 1.0.5
    └─┬ serve-static 1.0.1
      └─┬ send 0.1.4
        └── debug 1.0.5
    koa 2.11.0
    └── debug 3.1.0%  

      查看node_modules里的版本,我们发现区别于yarn,pnpm是将不同版本放在同一层级里通过软链选择加载版本,而yarn则是放在不同层级,依赖递归查找算法来选择版本

      我们发现pnpm的node_modules里包含了三个版本,并且不同的模块分别连接到了三个版本

     

      这样即使出现版本冲突,只需要将各个模块进行链接即可,并不需要每个模块再进行重复安装模块。

      我们可以发现pnpm避免直接依赖node_modules的递归查找依赖的性质,而是直接通过软链解决了phantom dependency和doppelgangers 问题。因为彻底的避免了包的重复问题,其节省了大量的空间和加快了安装速度

      以一个monorepo项目为例

      对比一下:

      pnpm: node_modules大小359M,安装耗时20s

      yarn: node_modules大小1.2G,安装耗时173s

      差别非常显著

    2、global store

      pnpm不仅仅能保证一个项目里的所有package的每个版本是唯一的,甚至能保证你使得你不同的项目之间也可以公用唯一的版本(只需要公用store即可),这样可以极大的节省了磁盘空间。核心就在于pnpm不再依赖于node的递归向上查找node_modules的算法,因为该算法强依赖于node_modules的物理拓扑结构,这也是导致不同项目的项目难以复用node_modules的根源。

    学习文章:https://zhuanlan.zhihu.com/p/137535779

  • 相关阅读:
    GISer 应届生找工作历程(完结)
    c#跨窗体调用操作
    c#基础学习笔记-----------委托事件
    c#基础笔记-----------集合
    ArcEngine开发鹰眼实现问题
    Null Object模式
    c#基础------------静态类与非静态类
    GIS初学者
    c#基础学习汇总----------base和this,new和virtual
    用Python编写水仙花数
  • 原文地址:https://www.cnblogs.com/goloving/p/16204049.html
Copyright © 2020-2023  润新知