跳到主要内容

前端包管理机制简述

· 阅读需 24 分钟
Jeii Zou

npm, yarn, pnpm 相关机制和原理的介绍

前端包管理机制简述

package.json文件说起

package.json是前端领域中用来描述一个包信息的描述文件. 其中, 只有两个字段是必填的:

  • name: 模块名称, 可以用validate-npm-package-name来检测包名是否合法, 因为包名是唯一的, npm也提供npm view packageName来检测包名是否被占用.
  • version: 模块版本, npm的模块版本遵循SemVer规范, 即主版本号.此版本号.修订号. 可以使用semver包来进行比较版本, 提取版本信息等操作.

依赖管理

package.json中, 另外比较重要的配置就是依赖的管理了.

依赖配置相关的字段主要有下面这些:

  • dependencies: 指定了项目运行所依赖的模块
  • devDependencies: 一些只用于开发时的包, 可以用该字段进行管理, 在运行时不会用到的依赖,比如Eslint等等. 将这些依赖保存在次数,可以避免在生产环境被安装.
  • peerDependencies: 用于指定你正在开发的模块所依赖的模块的版本, 以及用户安装的依赖包版本的兼容性. 在npm3以后就不会强制安装peerDependencies中依赖的版本, 而是抛出一个warn. 不影响继续安装其他的依赖
  • bundledDependencies: 提供一个包名的数组, 会在发布的时候被一起打包
  • optionalDependencies: 如果你的依赖是可有可无的,并且又在安装的时候无法获取到,那么就可以把依赖放在这里,npm在安装的时候就不会报错了.

依赖中的每一项都会按照版本控制的规则进行控制, 我们常见的版本控制的写法有这么几种:

  • "xxx": "1.0.0": 固定版本号
  • "xxx": "*": 任意的版本号
  • "xxx": "16.x": 表示匹配主要的版本号(这里即16.0.0<= version <17.0.0)
  • "xxx": "16.3.x": 表示匹配主要版本和次要版本(这里即16.3.0 <= version <16.4.0)
  • "xxx": "~1.0.0": 保持主版本号和次要版本号不变的情况下, 安装小版本中的最新版本
  • "xxx": "^1.0.0": 保持主版本号不变的情况下, 安装次要版本号和修订版本号为最新的版本

这里^npm install的时候采用的默认的安装版本方式.

另外, 当主版本号是0的时候就会被认为是一个不稳定版本, 版本控制的逻辑有所不同.

  • 如果主要版本和次要版本都是0, 那么~0.0.z^0.0.z都会直接被当做固定版本.
  • 如果主要版本为0, 那么^0.y.z~0.y.z相同, 只保持z为最新的版本

依赖锁定

在一些场景下, 我们不希望依赖被更新, 所以会在package.json的同级目录下, 生成一个lock文件.

这个文件的作用是: 在不执行手动更新的情况下, 每次都安装固定的版本, 从而保证每次都使用一致的依赖.

在使用package-lock的情况下要确保npm的版本在5.6以后以获得比较稳定的版本控制.

升级依赖

使用npm outdated可以列出所有依赖的最新版本情况的表格, 包名会渲染到不同的颜色:

  • 黄色: 不符合我们指定的版本范围, 不需要升级
  • 红色: 符合我们指定的语义化版本范围, 需要升级

然后指定npm update就会自动升级所有的红色依赖.

npm的包管理机制

在早期的npm包管理中, npm会简单粗暴的, 把所有依赖以递归的方式, 严格按照package.json中的结构以及子依赖包的package.json结构安装到他们各自的node_module中. 直到子依赖包不再依赖其他的模块.

alt

虽然这种管理方式, 对于依赖的层级看起来非常清晰, 但是也会存在依赖冗余的问题. 比如两个子依赖都依赖同一个项目, 那么这个项目会被安装两次, 并且整个项目的嵌套层级非常深.

为了解决这个问题, npm在3.x对嵌套结构进行了打平, 也就是说, 安装模块的时候, 会把直接依赖和子依赖以及子依赖的依赖都优先安装在node_modules的根目录下面.

假如, A依赖包和B依赖包都依赖项目C, 并且依赖了不同的版本, 那么在安装到这个模块的时候:

  • npm会判断已经安装的模块是否符合版本范围, 符合则跳过
  • 不符合则在当前模块的node_modules下安装该模块.

此时, 我们可能会得到这样的目录结构:

对应这样的目录结构, 当我们去搜索一个模块的时候, 对应的查找流程就是这样的:

  1. 在当前模块的路径下搜索
  2. 在当前模块的node_modules路径下搜索
  3. 在上级模块的node_modules路径下搜索
  4. ...
  5. 直到在全局路径中的node_modules路径下搜索

在这种模式下, 我们解决了部分的模块依赖问题, 但是也带来了新的问题.

npm install的时候, 是按照package.json中的模块顺序来依次的解析模块和安装依赖的, 也就是说, 模块的排列顺序就决定了最终生成的node_modules的目录结构.

比如, 当你的A依赖包和B依赖包都依赖C的不同版本, 那么node_modules中, 首层的C依赖的版本, 就取决于A和B在package.json中的先后顺序.

如果是先B后A:

如果是先A后B:

此外, 如果我们只在package.json中锁定大版本, 那么某些依赖包的小版本更新后, 同样会造成依赖结构的变化.

这种情况下, 我们就必须借助package-lock.json文件, 把所有的依赖版本确定下来, 这样才能固定node_modules的层级结构.

package-lock.json文件结构

注意, 这里并不是所有子依赖都会有dependencies属性的, 只有子依赖和当前已经安装的依赖发生冲突之后, 才会有这个属性.

这样依赖, lock文件中的层级结构和实际的node_modules文件就是一一对应的, 以保证每次npm install生成的node_modules一样的层级结构.

lock还有一个好处, 因为已经缓存了每个包的具体的版本和下载的链接, 所以就不需要再去远程仓库进行查询, 直接在安装完成后, 进入文件完整性校验即可.

npm本地缓存

在使用npm install安装完依赖后, npm会在本地的缓存目录缓存一遍这些包.

我们可以通过npm config get cache查询到缓存所在的目录.

一般会在用户目录下.npm/_cacache目录下面, 这个目录下面存在两个目录:

  • content-v2: 存储tar包的缓存
  • index-v5: 存储tar包的hash

npm在执行安装的时候, 会根据lock文件中的intergrity, version, name生成一个唯一的key对应到index-v5目录下面的缓存记录, 从而找到tar包的hash, 然后根据hash去缓存中找到对应的tar包直接使用.

npm v5之前, 是直接通过{cache}/{name}/{version}这样的结构表示的.

常用的用于缓存的npm命令有:

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

基于缓存, npm提供了离线安装模式:

  • --perfer-offline: 优先使用缓存, 如果没有则从远程仓库下载
  • --perfer-online: 优先使用网络数据, 如果网络请求失败, 再使用缓存数据
  • --offline: 不请求网络, 直接使用缓存数据, 一旦缓存不存在, 就安装失败

yarn的包管理机制(yarn1)

yarn出现在2016年, 当时npm还处于v3版本, 也就是简单粗暴的递归模式. 没有扁平化依赖, 没有lock文件. yarn针对原来npm的缺陷提出了自己的解决办法, 并对npm后续的更新造成了深刻的影响.

yarn的特性大致有这么一些:

  • 生成yarn.lock: 明确依赖版本和依赖结构, 在任何环境下都能得到一致的依赖结构
  • 扁平化安装: 将依赖包的不同版本, 按照一定策略归化为一定的版本范围
  • 使用并发请求: 类似并发连接池
  • 引入了包缓存机制.

我们这里讨论的是yarn1的版本管理模式.

yarn.lock版本锁定

npmpackage-lock.json不同, yarn.lock使用的是一种自定义的文件格式.

除此之外, yarn.lock中子依赖的版本号是不固定的, 这就意味着单独的yarn.lock是没有办法确定node_modules的目录结构的, 还需要和package.json文件组合. 而package-lock.json则只需要它自己就可以了.

yarn.lock的文件内容看起来像这样:

# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1

"@tootallnate/once@1":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82"
integrity sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==

address@>=0.0.1, address@^1.0.0:
version "1.1.2"
resolved "https://registry.yarnpkg.com/address/-/address-1.1.2.tgz#bf1116c9c758c51b7a933d296b72c221ed9428b6"
integrity sha512-aT6camzM4xEA54YVJYSqxz1kv4IHnQZRtThJJHhUMRExaU5spC7jX5ugSwTaTgJliIgs4VhZOk7htClvQ/LmRA==

你可以发现, 在yarn.lock文件中, 每个包的版本是一个逗号拼接的数组, 他表示所有的依赖该包的范围. 每个包下面都有对应的字段, 这些字段的含义和package-lock.json中的定义是类似的:

  • version: 符合包的语义化版本的确切的一个版本
  • resolved: 记录包的地址, 此外, hash中的值是shasum
  • dependencies: 记录当前包的依赖

yarn会把所有包的依赖提升到顶层去. 对于可以合并的同名依赖, 就用逗号分隔, 作为顶层的一个key, 对于不能合并的, 则视为两个独立的顶层Key.

在具体的node_modules文件中, 则只会出现最新版本的包.

比如说, 我们现在的项目中依赖同一个包的两个版本:

fs-extra@^8.1.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0"
integrity sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==
dependencies:
graceful-fs "^4.2.0"
jsonfile "^4.0.0"
universalify "^0.1.0"

fs-extra@^9.0.1:
version "9.1.0"
resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d"
integrity sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==
dependencies:
at-least-node "^1.0.0"
graceful-fs "^4.2.0"
jsonfile "^6.0.1"
universalify "^2.0.0"

这两个版本不可合并, 所以yarn会把它们两个都放在yarn.lock文件中.

但是在node_modules文件夹下, 默认会放置fs-extra@9.0.1版本的模块, 而fs-extra@8.1.0这个包会放在对应引用它的依赖的node_modules文件夹中.

这里和npm的处理逻辑有所不同.

yarn缓存管理

类似于npmcache命令, yarn也提供了几个缓存管理命令:

  • yarn cache ls: 列出当前缓存的包的列表
  • yarn cache dir: 显示缓存的目录
  • yarn cache clean: 清理缓存

一般, yarn的缓存目录默认放在Library/Caches/Yarn/的目录下面, 具体的你可以通过命令调用查看.

Yarn的缓存管理要显得更加语义化一些:

- npm-yorkie-2.0.0-92411912d435214e12c51c2ae1093e54b6bb83d9-integrity
- npm-zeromq-5.2.8-94b0b85e4152e98b8bb163f1db4a34280d44d9d0-integrity
- npm-zrender-5.1.1-0515f4f8cc0f4742f02a6b8819550a6d13d64c5c-integrity
- npm-zscroller-0.4.8-69eed68690808eedf81f9714014356b36cdd20f4-integrity
- npm-zwitch-1.0.5-d11d7381ffed16b742f6af7b3f223d5cd9fe9920-integrity

它的缓存目录下面都是类似这样的文件夹, 文件的的名称以npm-开头, 以包名, 版本号以及hash值组成.

yarn默认使用的是perfer-online模式, 也就是说, 它会先尝试从远程仓库下载, 如果连接失败则在尝试从缓存中读取. 如果你希望采用离线优先策略, 也提供了--offline参数:

yarn add --offline

此外, yarn是支持配置离线镜像的:

yarn config set yarn-offline-mirror ./npm-packages-offline-cache

离线镜像不同于缓存. 缓存是从注册中心下载的解压缩的tar包, 这些缓存是基于特性的工具实现的, npm有npm的缓存, yarn有yarn的缓存, 并且还有可能在npm的不同版本下机制也是不同的. 而离线镜像中的tar包不会受到工具和工具版本的影响, 并且存储后的文件也有利于存储. 更详细的步骤可以参照官方文档

(实际上, 我们看到在npm的缓存机制下, 缓存包是以tar形式保存的)

pnpm的包管理机制

npm的v3以后的版本管理和yarn1的版本管理都采用了将依赖打平的方式, 这么做的好处在于减少层级深度和依赖冗余, 但是也带来的新的问题:

  1. 最严重的问题就是幽灵依赖的问题, 模块可以访问它们不依赖的包
  2. 压平依赖的树算法非常的复杂
  3. 一些包必须复制到一个项目的node_modules文件中

我们回顾之所以要将包进行扁平化的理由:

  1. 在原来的npm的包管理机制下, node_modules会发生很深的嵌套.
  2. 大量的相同依赖冗余在不同的依赖包中.

pnpm实际上是提出了另一种解决这个问题的方法, 即利用硬链接将所有的包集中到全局管理.

也就是说, node_module文件夹中还是保持了和依赖图谱完全一致的文件目录结构, 但是又能避免依赖冗余和层级过深的问题.

linux中, 硬链接就是指通过索引节点来进行链接. 在linux的文件系统中, 保存在磁盘分区中的文件不论是什么类型都会给它分配一个编号, 称为索引节点号. 在Linux中, 多个文件名指向同一个索引节点号是被允许的. 也就是说, linux允许文件拥有多个有效路径.

基于这种机制, pnpm把所有的依赖包都防止在.pnpm文件下面, 在.pnpm中平铺的存储这所有的包, 所以每个包都可以在以这种命名模式的文件夹中被找到:

.pnpm/<name>@<version>/node_modules/<name>

官方称之为: 虚拟存储目录.

这样的存储结构避免的npm-v3之前的版本中嵌套node_modules引起的长路径的问题, 也与npm-v3之后的版本中的平铺结构不同, 它保留了依赖包之间的真实的拓扑结构.

这么处理依赖, 带来了几个好处:

  1. 保留了严格对应的node_modules层级关系, node_modules文件夹中只会存在真实依赖的依赖项
  2. 由于模块是全局共享的, 这解决了项目包的大小问题, 并且由于pnpm采用缓存优先的策略, 使得包复用的概率变的很高, 项目依赖的二次安装速度大大提升.
  3. 没有打平依赖带来幽灵依赖的问题

pnpm中的peerDep依赖问题

假设, 我们现在有两个项目package-apackage-b:

  • package-a依赖lib-a@1.0.0lib-b@1.0.0
  • package-b依赖lib-a@1.0.0lib-b@1.1.0
  • lib-a存在一个peerDepslib-b@^1.0.0

如果我们现在在项目中要同时安装package-a以及package-b, 在.pnpm中, 需要把所有依赖铺平:

package-a
package-b
lib-a@1.0.0
lib-b@1.0.0
lib-b@1.1.0

显然, package-apackage-b是分别依赖lib-b的两个版本的, 但是lib-b同时是lib-a的依赖, 那么导入lib-a的时候应该处理哪一个lib-b呢? 显然不能随便选择一个, 因为package-apackage-b都是依赖lib-a的, 随便挑一个另一个就会报错.

所以这种情况下, lib-a就会在pnpm.pnpm里面出现两次, 一次链接到lib-b@1.0.0, 一次链接到lib-b@1.1.0.

也就是说, 我们得到了以下的.pnpm目录:

package-a
package-b
lib-a@1.0.0+lib-b@1.0.0
lib-a@1.0.0+lib-b@1.1.0
lib-b@1.0.0
lib-b@1.1.0

yarn2, PnP模式

PnP模式比起pnpm的全局硬链接模式更加激进一些, 它直接把node_modules给干掉了.

在PnP模式下, Yarn会维护一张静态的映射表, 这个表中会包含:

  • 当前依赖树中包含了哪些依赖包以及对应的版本组
  • 这些依赖包的依赖关系
  • 这些依赖包在文件系统中的具体位置

这个映射表以.pnp.js文件的形式保存在项目的根目录下.

在项目进行依赖安装的时候, yarn不会把依赖拷贝到node_modules目录, 而是会在.pnp.js中记录下该依赖在缓存中的具体位置. 这样就避免了大量的I/O操作, 同时在项目中也不会生成node_modules目录.

PnP模式的优点在于:

  • 完全摆脱了node_modules的限制
  • 提高了模块的加载效率: 通过.pnp.js文件就能直接定位到具体的模块的地址, 省去了一层层查找模块位置的过程
  • 不再受同名模块的不同版本不能在同一个目录的限制(npm中对这种情况就会创建出比较复杂的层级结构)

PnP模式的最大问题就是兼容性问题, 比如目前来说, 前端的很多工具都依赖于node_modules的模块查找机制, 例如:

  • Node的require
  • Webpack的模块查找
  • TypeScript的类型声明文件
  • Babel, ESLint的插件的定位机制

等等

Deno的包管理

Deno不是包管理工具, 可以理解为一个新的JS运行时框架, 目的是为了解决原来node_modules带来的种种问题.

Deno的依赖引入是直接引入资源的可访问路径, 不论是url或者本地路径, 不做任何的模块化的处理. 类似于这样:

import { serve } from "https://deno.land/std@0.54.0/http/server.ts";
const s = serve({ port: 8000 });
console.log("http://localhost:8000/");
for await (const req of s) {
req.respond({ body: "Hello World\n" });
}

每个包的路径中包含了引入源+包名+版本号+模块, 全都塞进了一个Url中.

但是这种引入方式在工程量增大的时候, 可预见的会变的非常的复杂和难以管理, 势必要引入一套依赖管理的机制.

官方推荐的做法是, 在项目本地建立一个dep.ts的文件, 然后通过dep.ts文件去引入各种本地或者远程的项目依赖.

比如像这样:

/**
* deps.ts
*
* This module re-exports the required methods from the dependant remote Ramda module.
*/
export {
add,
multiply,
} from "https://x.nest.land/ramda@0.27.0/source/index.js";

然后在项目中这样引用:

/**
* example.ts
*/

import { add, multiply } from "./deps.ts";

function totalCost(outbound: number, inbound: number, tax: number): number {
return multiply(add(outbound, inbound), tax);
}

console.log(totalCost(19, 31, 1.2));
console.log(totalCost(45, 27, 1.15));

/**
* Output
*
* 60
* 82.8
*/

也就是说, 移除了包管理工具的概念, 直接在代码层面上组织和管理模块依赖. 对于这种为了去中心化而设计的依赖管理模式, 则需要见仁见智了.

参考链接