包管理器的发展史

·  阅读 576
包管理器的发展史

包管理是前端开发必不可少的工具,当下主流包管理工具包括:npmyarnpnpm。每个工具的诞生都是特定环境下的产物,用于帮助我们解决工作中的实际问题,后者站在巨人的肩膀上继续前进

一、npm - 最早的Node包管理工具

npm 作为Node内置包管理工具,出现就备受关注,成为前端开发离不开的利器之一

1. npm instal 做了什么?

npm install 用来给当前项目安装所需依赖包

image-20220503200133706.png

检查配置: 读取 npm config.npmrc 配置

.npmrc是有权重的:项目级 > 用户级 > 全局配置 > npm内置

确定依赖版本,构建依赖树: 检查是否存在 package-lock.json

存在进行版本比对,处理方式和npm版本有关,根据最新npm版本处理规则,版本能兼容按照 package-lock 版本安装 , 反之按照 package.json 版本安装

不存在根据 package.json 确定依赖包信息

检查缓存: 判断是否存在缓存

存在将对应缓存解压到 node_modules 下,生成 package-lock.json

不存在则下载资源包,验证包完整性并添加至缓存,之后解压到 node_modules 下,生成 package-lock.json

2. npm 如何进行依赖管理?

假设当前项目有两个依赖包A、B,且同时都依赖了某两个相同的依赖包C、D,其中D包被依赖版本有所不同

// package.json from my project

"dependencies": {
    A: "1.0.0",
    B: "1.0.0"
}

// package.json from package A
"dependencies": {
    C: "1.0.0",
    D: "1.0.0"
}
// package.json from package B
"dependencies": {
    C: "1.0.0",
    D: "2.0.0"
}
复制代码

嵌套安装

npm 3.x 之前采用嵌套安装,通过递归的形式,根据依赖树的层级结构依次下载,在执行 npm installnode_modules 如下图所示

├── node_modules
│   ├── A@1.0.0
│   │   └── node_modules
│   │   │   └── C@1.0.0
│   │   │   └── D@1.0.0
│   ├── B@1.0.0
│   │   └── node_modules
│   │   │   └── C@1.0.0
│   │   │   └── D@2.0.0
复制代码

对于这种安装方式很好的反映了依赖树的层级结构,且每次安装目录结构相同,但也存在如下缺陷

  1. 依赖地狱,依赖太深导致路径过长,在 Windows 系统中,文件路径最大长度为260个字符,嵌套层级过深可能导致不可预知的问题;
  2. 同一个包被重复安装,导致 node_modules 体积过大,比如上述中所说的 C@1.0.0 包同时被A、B所依赖,会分别在两者的node_modules中下载,导致重复安装;

扁平安装

为了解决上述弊病,从npm 3.x 之后改为扁平化安装,执行 npm install 后,无论是直接依赖还是子依赖,皆优先安装在 node_modules 根目录,安装到相同模块时,根据node require机制,会逐级往上寻找node_modules,判断已安装的模块版本是否符合新模块的版本范围,如果符合则跳过,不符合则在当前模块的node_modules下安装该模块。

基于上述结论我们知道了扁平化安装具有下列优势

  1. 解决了包重复安装的问题;
  2. 依赖层级也不会太深;

但是,这种方式虽然解决了之前的问题,但是扁平化结构也衍生出了新的问题,其中D@1.0.0模块和D@2.0.0模块都有可能被优先安装在node_modules根目录下,如下图所示

// D@2.0.0 被优先安装
├── node_modules
│   ├── A@1.0.0
│   │   └── node_modules 
│   │   │   └── D@1.0.0
│   ├── B@1.0.0
│   ├── C@1.0.0
│   ├── D@2.0.0// D@1.0.0 被优先安装
├── node_modules
│   ├── A@1.0.0
│   ├── B@1.0.0
│   │   └── node_modules 
│   │   │   └── D@2.0.0
│   ├── C@1.0.0
│   ├── D@1.0.0
复制代码

我们得出如下结论:

  1. 依赖结构不确定性,每次执行 npm install 后同一个子依赖包被优先安装版本可能不同;

  2. 幽灵依赖导致非法访问,想必大家做项目开发的时候,都遇到过 package.json 并没有直接依赖某个包,却可以被项目直接引用,这就是所谓的幽灵依赖,幽灵依赖可能导致依赖丢失或者版本兼容差异;

  3. 分身依赖问题

    • 不同版本的依赖被重复打包,增加了产物体积;
    • 无法共享库实例,引用的得到的是两个独立的实例;
    • 重复 TypeScript 类型,可能会造成类型冲突;
  4. 扁平化算法复杂性较高;

3. lockfile出现

npm 5.x开始,执行npm install时会自动生成一个 package-lock.json 用于记录依赖树信息,它精确描述了node_modules 目录下所有包的树状结构

字段解释

package-lock.json包含了versionresolvedintegritydevrequiresdependencies这几个字段

  1. version:唯一版本号

  2. resolved:包的安装源

  3. integrity:用于验证包是否失效的完整性hash值,由两部分组成: 加密hash函数-摘要dgest,加密函数有两种sha512或者sha1,dgest等于base64(hashfn(content))

  4. dev:是否为开发时依赖项

  5. requires:当前包的dependencies依赖项

  6. dependencies:当前包的node_modules依赖树(比如:某个子依赖包存在多版本时,当前包下生成的node_modules结构)

    验证资源完整性过程:

    • 获取integrity字段,得到加密hash算法fn和摘要dgest
    • 使用fn对获取到的资源内容进行加密,然后对加密后的结果使用base64编码,得到摘要dgest2
    • 如果dgest===dgest2,证明资源没有被篡改

不足之处

  1. 没有解决扁平化带来的算法复杂性、幽灵依赖等本质问题;
  2. npm install时会拉取当前大版本下的最新依赖包,当依赖包有小版本更新时,导致协同开发者依赖树不一致,并造成团队协作时 package-lock.json git冲突问题;

二、yarn - 推动包管理工具的步伐

yarn 的诞生一定程度上解决了历史上 npm 某些能力的不足,包括上述提到的依赖一致性、资源完整性,以及安装过程速度较慢等等问题。yarn 的出现也促进了 npm 继续前进的步伐

1. yarn install 执行过程?

执行 yarn install 后会经过五个阶段:

  • 检查(checking) 检查系统运行环境,包括OS、CPU、engines等信息

  • 解析包(resolving packages) 首先根据项目 package.jsondependenciesdevDependenciesoptionalDependencies 字段形成首层依赖集合,之后对嵌套依赖逐级进行递归解析(将解析过和正在解析的包用一个 Set 数据结构来存储,保证同一个版本范围内的包不会被重复解析),结合 yarn.lock 和 Registry 获取包的具体版本、下载地址、hash值、子依赖等信息(过程中遵循依照 yarn.lock 优先原则)最终确定依赖版本信息、下载地址

  • 获取包(fetching packages) 首先判断缓存目录中有没有缓存资源,其次读取文件系统,都不存在则从Registry进行下载

  • 链接包(linking dependencies) 复制缓存至项目 node_modules 目录

    首先解析 peerDependencies 信息,之后基于扁平化原则(yarn扁平化规则不同于npm,使用频率较大的版本会安装到顶层目录,这个过程称为dedupe),从缓存复制依赖至当前项目 node_modules 目录,

  • 构建包(building fresh package) 依赖包存在二进制文件进行构建

    这个过程会执行 install 相关钩子,包括 preinstallinstallpostinstall

    相信大家都有安装 node-sass 频繁报错的经历,Sass 引擎最初使用 Ruby 实现,所以需要 Ruby 作为运行环境支持,在WebAssembly、Dart没有兴起前,Node 执行原生代码需要 node-gyp 进行本地构建,它可以将 binding.node 格式的二进制文件构建为可被执行的代码。node-sass 就需要对Saas二进制资源进行额外下载,并依赖 node-gyp 进行构建,具体历史不做赘述

2. 对比 npm

介绍完有关 npmyarn 的基础知识,我们对比二者之间使用差异,做一个直观的总结

  • 采用扁平化结构,不同的是对于重复模块,yarn默认会基于使用频率决定谁该被安装在顶层目录(npm可以通过 npm dedupe 实现该功能)

  • lockfile文件, package-lock.json 自带版本锁定和依赖结构,一旦依赖改动影响范围较大,yarn.lock 只包含版本锁定,并不确定依赖结构,需要结合 package.json 确定依赖结构

  • 缓存机制

    • 缓存结构

      npm 安装依赖后通常会在用户目录下 .npm/_cacache 里进行缓存,内容如下:

      • content-v2 存储tar包的缓存
      • index-v5 存储tar包的hash,根据lock文件中 intergrity, version, name 生成唯一key,对应该目录中缓存记录,从而找到 tar 包的 hash

      yarn 缓存按照平铺形式生成,可以通过 yarn cache dir 查询缓存目录,内容包括压缩包、.yarn-metadata.json、解压文件、bin文件等,条目命名规则遵循 {slug}/node_modules/{packageName} ,其中 slug版本哈希值uid构成

    • 缓存命令

      npm 命令

      • npm cache clean: 清除缓存, 为了保证缓存数据的完整性, 一般会加上--force参数
      • npm cache verify: 验证缓存数据的有效性和完整性, 清理垃圾数据

      yarn 命令

      • yarn cache clean: 清理缓存
      • yarn cache ls: 列出当前缓存的包的列表
      • yarn cache dir: 显示缓存的目录
      • yarn config set yarn-offline-mirror ./npm-packages-offline-cache 设置离线镜像目录,通过设置 yarn config set yarn-offline-mirror-pruning true 保持目录及时更新
    • 缓存规则

      npmyarn 提供以下模式

      • --perfer-offline: 优先使用缓存, 如果没有则从远程仓库下载
      • --perfer-online: 优先使用网络数据, 如果网络请求失败, 再使用缓存数据
      • --offline: 不请求网络, 直接使用缓存数据, 一旦缓存不存在, 就安装失败
  • Workspace 概念yarn 提出工作区的概念,更方便的实现Monorepo 仓库管理,工作区内链接所有包方便直接调用,并统一维护依赖树,代码结构清晰、易于维护

  • 包下载机制yarn 缓存了每个下载过的包,再次使用时无需重复下载。 同时利用并行下载以最大化资源利用率,安装速度更快

三、pnpm - 更现代的包管理工具

pnpm 代表 performant(高性能的)npm,如pnpm 官方介绍,它是:速度快、节省磁盘空间的软件包管理器,可以看出pnpm 在解决依赖包的安装效率、节省磁盘空间利用都有显著提升,同时它也支持workspace满足Monorepo包管理方式。

pnpm

我们可以先看下来自 pnpm benchmarks的对比数据,感受下pnpm带来的性能提升

actioncachelockfilenode_modulesnpmpnpmYarnYarn PnP
install48.4s14.7s16.6s23.1s
install2s1.2s2.3sn/a
install10.4s3.7s6.5s1.5s
install15.6s6.6s11.1s5.9s
install27.8s12.6s11.6s17.1s
install2.5s2.8s6.8sn/a
install2s1.2s7.3sn/a
install2.5s8s11.8sn/a
updaten/an/an/a2s9.2s15.1s28.9s

性能对比

1.硬链接与软链接

避免幽灵依赖问题,访问更加安全

pnpm基于CAS 内容寻址存储的方式管理依赖,pnpm 会将依赖储存在目录 ~/.pnpm-store 下,之后项目安装时pnpm会检查全局stroe目录下是否已经存在对应依赖,存在即创建从全局存储到项目 node_modules/.pnpm 文件夹的硬链接,硬链接指向磁盘上原始文件所在的同一位置,然后通过软链接的方式在node_modules下创建package.json指定的依赖。

node_modules
└─ .pnpm
    └─ ...
└─ react
复制代码

如上图,假设我们有一个项目依赖了react包,其中react是指向.pnpm 的软链接,.pnpm中每个文件都是来自于公共store的硬链接,链接到真实文件资源,打开目录你会发现它们都携带着各自的版本号,这样设计的目的是为了解决分身依赖的问题。

基于上述包管理方式,我们可以得出:

  • 节省磁盘空间

    当使用 npm 或 Yarn 时,如果你有 100 个项目使用了某个依赖(dependency),就会有 100 份该依赖的副本保存在硬盘上。 而在使用 pnpm 时,依赖会被存储在内容可寻址的存储中;

  • 下载速度更快

    基于硬链接与软链接的包管理方式,pnpm可以更加高效快速的下载依赖包

  • 避免幽灵依赖问题,访问更加安全

    使用 npm 或 Yarn Classic 安装依赖项时,所有包都被提升到模块目录的根目录。 因此,项目可以访问到未被添加进当前项目的依赖。未在package.json声明的依赖理论是无法访问的,它属于幽灵依赖,一旦使用后面可能产生不可估量的副作用,pnpm 使用软链的方式将项目的直接依赖添加进模块文件夹的根目录,让包的使用更加安全可控;

2.Workspace

过去我们可能通过yarn wrokspace或者 Lerna 来管理 monorepo 仓库,pnpm 内置了对单体仓库的支持, 你可以创建一个 workspace 以将多个项目合并到一个仓库中。一个 workspace 的根目录下必须有 pnpm-workspace.yaml 文件, 也可能会有 .npmrc 文件。

  • workspace协议

    默认情况下,如果可用的 packages 与已声明的可用范围相匹配,pnpm 将从工作区链接这些 packages。 例如,如果 bar 中有 "foo":"^1.0.0" 的这个依赖项,则 foo@1.0.0 链接到 bar。 但是,如果 bar 的依赖项中有 "foo": "2.0.0",而 foo@2.0.0 在工作空间中并不存在,则将从 npm registry 安装 foo@2.0.0 。 这种行为带来了一些不确定性。

    幸运的是,pnpm 支持 workspace 协议 workspace: 。 当使用此协议时,pnpm 将拒绝解析除本地 workspace 包含的 package 之外的任何内容。 因此,如果您设置为 "foo": "workspace:2.0.0" 时,安装将会失败,因为 "foo@2.0.0" 不存在于此 workspace 中。

    link-workspace-packages 选项 设置为 false 时,这个协议尤其有用。 在这种情况下,仅当使用 workspace: 协议声明依赖,pnpm 才会从此 workspace 链接所需的包。

  • 通过别名引用

    假设你在 workspace 中有一个名为 foo 的包, 通常你会像这样引用:"foo": "workspace:*"

    如果要使用其他别名,那么以下语法也将起作用: "bar": "workspace:foo@*"

    在发布之前,别名被转换为常规名称。 上面的示例将变为:"bar": "npm:foo@1.0.0"

  • 通过相对路径

    假如 workspace 中有两个包:

    + packages
        + foo
        + bar
    复制代码

    bar 中可能有 foo 的依赖: "foo": "workspace:../foo", 在发布之前,这些将转换为所有包管理器支持的常规版本规范。

  • 发布workspace

    workspace 包打包到归档(无论它是通过 pnpm pack ,还是 pnpm publish 之类的发布命令)时,我们将动态替换这些 workspace: 依赖:

    • 目标 workspace 中的对应版本(如果使用 workspace:*, workspace:~, or workspace:^
    • 相关的 semver 范围(对于任何其他范围类型)

    看一个例子,假设我们的 workspace 中有 foobarqarzoo 并且它们的版本都是 1.5.0,如下:

    {
        "dependencies": {
            "foo": "workspace:*",
            "bar": "workspace:~",
            "qar": "workspace:^",
            "zoo": "workspace:^1.5.0"
        }
    }
    复制代码

    将会被转化为:

    {
        "dependencies": {
            "foo": "1.5.0",
            "bar": "~1.5.0",
            "qar": "^1.5.0",
            "zoo": "^1.5.0"
        }
    }
    复制代码

    这个功能允许你发布转化之后的包到远端,并且可以正常使用本地 workspace 中的 packages,而不需要其它中间步骤。包的使用者也可以像常规的包那样正常使用,且仍然可以受益于语义化版本。

3.与npm、yarn功能对比

通过下图我们可以清晰地看到pnpmnpmyarn的差异

功能pnpmYarnnpm
工作空间支持(monorepo)✔️✔️✔️
隔离的 node_modules✔️ - 默认✔️
提升的 node_modules✔️✔️✔️ - 默认
Plug'n'Play✔️✔️ - 默认
零安装✔️
修补依赖项✔️
管理 Node.js 版本✔️
有锁文件✔️ - pnpm-lock.yaml✔️ - yarn.lock✔️ - package-lock.json
支持覆盖✔️✔️ - 通过 resolutions✔️
内容可寻址存储✔️
动态包执行✔️ - 通过 pnpm dlx✔️ - 通过 yarn dlx✔️ - 通过 npx

4.pnpm的局限

下面是来自pnpm官网的描述

  • npm-shrinkwrap.jsonpackage-lock.json 被忽略。 与 pnpm 不同,npm可以多次安装相同的 name@version ,并且具有不同的依赖项组合。 npm 的锁文件旨在反映平铺的 node_modules 布局,但是,由于 pnpm 默认创建隔离布局,它无法由 npm 的锁文件格式反映出来。 但是,如果您希望将锁定文件转换为 pnpm 的格式,请看 pnpm import
  • Binstubs(在 node_modules/.bin中的文件)总是 shell 文件,而不是指向 JS 文件的符号链接。 创建 shell 文件是为了帮助支持插件的 CLI 的程序在特殊的 node_modules 结构中能够正确地找到它们的插件。 这是很少有的问题,如果您希望文件是 JS 文件,请直接引用原始文件,如 #736 所示。
分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改