背景介绍
先驱者 npm
早在 2010 年 1 月 npm 的初代版本就已经发布,它确立了包管理器工作的核心原则,它的发布构成了一场革命,因为在此之前,项目依赖项都是手动下载和管理的。npm 引入了诸如文件及其元数据字段、将依赖项存储在 node_modules, 自定义脚本, 公共和私有包等等功能
2020 年,Github 收购了 npm,所以原则上 npm 现在归微软管理
创新者 yarn
在 2016 年 10 月,Facebook 宣布与 Google 和其他一些公司合作开发一个新的包管理器( engineering.fb.com/2016/10/11/… ),以解决 npm 当时存在的一致性、安全性和性能问题。他们将替代品命名为 Yarn 。
尽管 Yarn 还是基于 npm 的许多概念和流程来架构设计的,但 Yarn 还是对包管理器领域产生了重大影响。与 npm 相比,Yarn 并行化操作以加快安装过程,这一直是 npm 早期版本的主要痛点。
Yarn 为读写、安全性和性能设定了更高的标准,还发明了许多概念(后来 npm 也为此做了很多改进),包括:
monorepo 支持
缓存安装
离线下载
文件锁(Locking)
Yarn v1 于 2020 年进入维护模式 。从那时起,v1.x 系列被认为是旧版,并更名为 Yarn Classic。它的继任者 Yarn v2 (Berry) 现在是更加活跃的开发分支。
原理简析
npm
v1/v2 版本 嵌套安装
npm 最早的版本中使用了很简单的嵌套模式进行依赖管理。比如我们在项目中依赖了 A 模块和 C 模块,而 A 模块和 C 模块依赖了不同版本的 B 模块,此时生成的 node_modules 目录如下:
node_modules
├── A@1.0.0
│ └── node_modules
│ └── B@1.0.0
├── C@1.0.0
│ └── node_modules
│ └── B@2.0.0
└── D@1.0.0
└── node_modules
└── B@1.0.0
这种是嵌套的 node_modules 结构,每个模块的依赖下面还会存在一个 node_modules 目录来存放模块依赖的依赖。这种方式虽然简单明了,但存在一些比较大的问题。如果我们在项目中增加一个同样依赖 1.0 版本 B 的模块 D,此时生成的 node_modules 目录便会如上所示。虽然模块 A、D 依赖同一个版本 B,但 B 却重复下载安装了两遍,造成了重复的空间浪费。这便是依赖地狱问题
v3 版本 扁平化
npm v3 完成重写了依赖安装程序,npm3 通过扁平化的方式将子依赖项安装在主依赖项所在的目录中(hoisting 提升),以减少依赖嵌套导致的深层树和冗余。此时生成的 node_modules 目录如下:
为了确保模块的正确加载,npm 也实现了额外的依赖查找算法,核心是递归向上查找 node_modules。在安装新的包时,会不停往上级 node_modules 中查找。如果找到相同版本的包就不会重新安装,在遇到版本冲突时才会在模块下的 node_modules 目录下存放该模块子依赖,解决了大量包重复安装的问题,依赖的层级也不会太深
扁平化的模式解决了依赖地狱的问题,但也带来了额外的新问题
- 幽灵依赖
是指某个包未在 package.json 中定义,但项目中依然可以引用到的情况
在 index.js 中可以直接 require A,因为在 package.json 声明了该依赖,但是 require B 也是可以正常工作的
因为 B 是 A 的依赖项,在安装过程中,npm 会将依赖 B 平铺到 node_modules 下,因此 require 函数可以查找到它。但这可能会导致意想不到的问题:
1.依赖不兼容:
my-library 库中并没有声明依赖 B 的版本,因此 B 的 major 更新对于 SemVer 体系是完全合法的,这就导致其他用户安装时可能会下载到与当前依赖不兼容的版本。
2.依赖缺失:
我们也可以直接引用项目中 devDepdency 的子依赖,但其他用户安装时并不会 devDepdency,这就可能导致运行时会立刻报错。
- 多重依赖
考虑在项目中继续引入的依赖 2.0 版本 B 的模块 D 与而 1.0 版本 B 的模块 E,此时无论是把 B 2.0 还是 1.0 提升放在顶层,都会导致另一个版本存在重复的问题,比如这里重复的 2.0。此时就会存在以下问题:
1.破坏单例模式:
模块 C、D 中引入了模块 B 中导出的一个单例对象,即使代码里看起来加载的是同一模块的同一版本,但实际解析加载的是不同的 module,引入的也是不同的对象。如果同时对该对象进行副作用操作,就会产生问题。
2.types 冲突:
虽然各个 package 的代码不会相互污染,但是他们的 types 仍然可以相互影响,因此版本重复可能会导致全局的 types 命名冲突。
- 不确定性
在前端包管理的背景下,确定性指在给定 package.json 下,无论在何种环境下执行 npm install 命令都能得到相同的 node_modules 目录结构。然而 npm v3 是不确定性的,它 node_modules 目录以及依赖树结构取决于用户安装的顺序
考虑项目拥有以下依赖树结构,其 npm install 产生的 node_modules 目录结构如下图所示
假设当用户使用 npm 手动升级了模块 A 到 2.0 版本,导致其依赖的模块 B 升级到了 2.0 版本,此时的依赖树结构如下
此时完成开发,将项目部署至服务器,重新执行 npm install,此时提升的子依赖 B 版本发生了变化,产生的 node_modules 目录结构将会与用户本地开发产生的结构不同,如下图所示。如果需要 node_modules 目录结构一致,就需要在 package.json 修改时删除 node_modules 结构并重新执行 npm install
v5 版本 扁平化+lock
在 npm v5 中新增了 package-lock.json。当项目有 package.json 文件并首次执行 npm install 安装后,会自动生成一个 package-lock.json 文件,该文件里面记录了 package.json 依赖的模块,以及模块的子依赖。并且给每个依赖标明了版本、获取地址和验证模块完整性哈希值。通过 package-lock.json,保障了依赖包安装的确定性与兼容性,使得每次安装都会出现相同的结果
这样在 v3 版本的基础上带来两点提升
- 一致性
考虑上文案例,初始时安装生成 package-lock.json 如左图所示,depedencies 对象中列出的依赖都是提升的,每个依赖项中的 requires 对象中为子依赖项。此时更新 A 依赖到 2.0 版本,如右图所示,并不会改变提升的子依赖版本。因此重新生成的 node_modules 目录结构将不会发生变化
- 兼容性
依赖版本兼容性就不得不提到 npm 使用的 SemVer 版本规范,版本格式如下:
- 主版本号:不兼容的 API 修改
- 次版本号:向下兼容的功能性新增
- 修订号:向下兼容的问题修正
在使用第三方依赖时,我们通常会在 package.json 中指定依赖的版本范围,语义化版本范围规定:
- ~:只升级修订号
- ^:升级次版本号和修订号
- *:升级到最新版本
语义化版本规则定义了一种理想的版本号更新规则,希望所有的依赖更新都能遵循这个规则,但是往往会有许多依赖不是严格遵循这些规定的。因此一些依赖模块子依赖不经意的升级,可能就会导致不兼容的问题产生。因此 package-lock.json 给每个模块子依赖标明了确定的版本,避免不兼容问题的产生
Yarn
解决的问题
Yarn 是在 2016 年开源的,是为了了解决 npm v3 中的存在的一些问题(彼时 npm v5 尚未发布),比如 npm 缺乏对于依赖的完整性和一致性保障,以及 npm 安装速度过慢的问题等,尽管 npm 发展至今,已经在很多方面向 yarn 看齐,但 yarn 的安装理念仍然需要我们关注。yarn 提出的安装理念很好的解决了当时 npm 的依赖管理问题:
- 确定性 通过 yarn.lock 等机制,保证了确定性,这里的确定性包括但不限于明确的依赖版本、明确的依赖安装结构等。即在任何机器和环境下,都可以以相同的方式被安装。
- 模块扁平化安装 将依赖包的不同版本,按照一定策略,归结为单个版本,以避免创建多个副本造成冗余。(npm 也有相同的优化)
- 更好的网络性能 Yarn 采用了请求排队的理念,类似并发连接池,能够更好地利用网络资源;同时引入了更好的安装失败时的重试机制。(npm 较早的版本是顺序下载,当第一个包完全下载完成后,才会将下载控制权交给下一个包)
- 引入缓存机制,实现离线策略。(npm v7 之后也有类似的优化)
yarn install 的执行流程
1、检查(checking) 主要是检查项目中是否存在一些 npm 相关的配置文件,如 package-lock.json 等。如果存在,可能会警告提示,因为它们可能会存在冲突。在这一阶段,也会检查系统 OS、CPU 等信息。
2、解析包(resolving packages) 这一步主要是解析依赖树,确定版本信息等。 首先获取项目 package.json 中声明的首层依赖,包括 dependencies, devDependencies, optionalDependencies 声明的依赖。 接着采用遍历首层依赖的方式获取依赖包的版本信息,以及递归查找每个依赖下嵌套依赖的版本信息,并将解析过和正在解析的包用一个 Set 数据结构来存储,这样就能保证同一个版本范围内的包不会被重复解析。
- 对于没有解析过的包,首次尝试从 yarn.lock 中获取到版本信息,并标记为已解析;
- 如果在 yarn.lock 中没有找到包,则向 Registry 发起请求获取满足版本范围的已知最高版本的包信息,获取后将当前包标记为已解析。 总之,在经过复杂的解析算法后,就确定了所有依赖的具体版本信息以及下载地址
3、获取包(fetching packages) 这一步主要是利用系统缓存,到缓存中找到具体的包资源。首先会尝试在缓存中查找依赖包,如果没有命中缓存,则将依赖包下载到缓存中。 对于没有命中缓存的包,Yarn 会维护一个 fetch 队列,按照规则进行网络请求。这里也是 yarn 诞生之初解决 npm v3 安装缓慢问题的优化点,支持并行下载。
如何判断有没有命中缓存?
判断系统中存在符合 "cachefolder+slug+node_modules+pkg.name" 规则的路径,如果存在则判断为命中缓存,否则就会重新下载。值得注意的是,不同版本的包在缓存中是扁平化管理。以下是缓存中 webpack 的依赖缓存,可以通过 yarn cache dir 查看
如下所示
4、链接包(linking dependencies) 这一步主要是将缓存中的依赖,复制到项目目录下,同时遵循扁平化原则。前面说到,npm 优先将依赖安装到项目目录,因此需要将全局缓存中的依赖复制到项目。 在复制依赖前,Yarn 会先解析 peerDependencies,如果找不到符合 peerDependencies 声明的依赖版本,则进行 warning 提示(这并不会影响命令执行),并最终拷贝依赖到项目中。
5、构建包(building fresh package) 如果依赖包中存在二进制包需要进行编译,会在这一步进行。
yarn.lock 文件说明
以实际项目中的 yarn.lock 为例
其中
顶部依赖版本信息和 package.json 中的依赖或依赖的依赖保持一致
version 表示需要锁定的依赖版本
resolved 表示这个依赖的注册地址(registry url)用于依赖包的安装
integrity 是一串 hash 码(用于确保依赖的文件没有被修改)
dependencies(非必须)表示当前依赖的子依赖
扩展
yarn2
Yarn 2 于 2020 年 1 月发布,被宣传为原始 Yarn 的重大升级。Yarn 团队将其称为 Yarn Berry 以更明显地表明它本质上是一个具有新的代码库和新的原则规范的新包管理器
创新
依赖安装
Yarn Berry 的主要创新是其 即插即用 (PnP) 方法,yarn install 不再生成 node_modules 目录,而是生成了.yarn 目录和.pnp.js 文件 。.yarn 目录存放了项目中下载的所有依赖的 zip 包,.pnp.js 的内容是项目的 npm 模块解析规则。
依赖锁定
Yarn 2 认为工程依赖的锁定不仅仅只依靠 yarn.lock 达到,而应该将依赖也提交到仓库,所以.yarn 目录都应该被提交到仓库。而这在传统的 node_modules 方式下是不可行的,不仅是因为更新后产生的变更数量,更因为一个稍微大些的工程 node_moduels 的文件数量和体积都很容易达到 Git 无法处理的地步。而因为 Yarn 2 使用 zip 包的方式管理依赖,大大减少了文件数量和文件体积,故而使得工程的依赖能够进行版本管理,进而能够完美地实现依赖锁定。
在目前的工程协作开发中,协作者拉取代码后,第一步就要进行依赖的安装工作,但 Yarn 2 改变了这个境况,既然工程依赖都被提交到仓库中了,协作者拉取代码后甚至都不再需要执行依赖安装的步骤,因为依赖已经在那里了,这就是 Yarn 2 提出的 零安装 zero-install 。这也顺带解决了诸如服务器网络限制不能下载依赖、新分支升级依赖老分支不能正常运行等问题
如果想要深入了解可到 官方文档 查阅
package.json 常用配置说明
以 finance 项目为例
- name 为项目名称 命名规则遵循如下规则
- 包名长度应大于零
- 包名中的所有字符都必须是小写,即不允许大写或大小写混合的名称
- 包名可以由连字符组成
- 包名不得包含任何非 url 安全字符(因为名称最终成为 URL 的一部分)
- 包名不应以.或开头_
- 包的名称应该不包含任何前导或尾随空格
- 包的名称应该不包含任何下列字符:~)('!*
- 包名称不能与 node.js 中核心模块相同,也不能与保留/列入黑名单的名称相同
- 包名长度不能超过 214
- 不能与其他模块名重复
- version 为包版本 需遵循 semver.规范
- private 如果这个属性被设置为 true,npm 将拒绝发布它,这是为了防止一个私有模块被无意间发布出去
- description 应用描述
- scripts 是一个对象,里边指定了项目的生命周期个各个环节需要执行的命令。key 是生命周期中的事件,value 是要执行的命令
- dependencies 项目依赖。在编码阶段和呈现页面阶段都需要的,也就是说,项目依赖即在开发环境中,又在生产环境中。如 js 框架 vue、页面路由 vue-router,各种 ui 框架 antd、element-ui、vant 等
- devDependencies 开发依赖。仅仅在写代码过程中需要使用,比如 css 预处理器、vue-cli 脚手架、eslint 之类
- eslintConfig eslint 的配置
- browserslist 是在不同的前端工具之间共用目标浏览器和 node 版本的配置工具
它主要被以下工具使用:
- Autoprefixer
- Babel
- post-preset-env
- eslint-plugin-compat
- stylelint-unsupported-browser-features
- postcss-normalize
所有的工具将自动的查找当前工程配置的的目标浏览器范围
常用语法设置
注意:not dead 的含义是指浏览器最新的两个版本中发现其市场份额已经低于 0.5% 并且 24 个月内没有任务官方支持和更新 则不予支持
npm 各版本更新内容
v7
新增功能
- 工作空间
这个功能已经被社区要求了很久。工作空间是 npm CLI 中的一组功能,它支持在一个顶级的根包中管理多个包。Yarn 和 Pnpm 以相同的名称实现类似的功能,这一点一直存在。他们选择重用它是为了简化,以使更大的社区受益。在 npm cli 中,有 2 个主要的实现或变化需要你去实施,以访问能够更好地管理嵌套包的特性集。
让 npm cli 工作空间感知
在 npm 工作空间设置中,用户希望能够从顶层工作空间安装所有嵌套的包并执行相关的生命周期脚本。它还应该意识到相互依赖的工作空间,并适当地进行 symlink(文件之间的符号链接)。
- 自动安装同级项依赖(peer dependencies)
在之前的版本(npm v6)中,npm 默认不安装同级项依赖,取而代之的是,各个消费者不得不自己安装和管理同级项依赖关系。用户会收到一个警告,这往往被误解为一个问题。这就会被报告给软件包维护者,而维护者有时会省略同级项依赖关系,将其视为可选的依赖关系。这并没有对其版本范围或有效性进行任何检查。此外,由于 npm 安装程序不支持同级依赖关系,所以当不支持同级依赖关系存在时,它设计的树会出现问题。这个新版本(npm v7.0.0)使得自动安装同级依赖项变得很容易,而在此之前,开发人员需要手动管理和安装此类依赖项。根据 npm CLI 团队的说法,新的 peer 依赖算法确保在 node_modules 树中 peer-dependent 的位置或上面找到有效匹配的 peer 依赖。该算法解决了之前版本中与同级依赖相关的所有问题,使同级依赖成为一个一流的概念,并满足了包树有效性的要求。
- Package-lock v2 和对 yarn.lock 的支持
新的 package-lock 格式将释放出进行确定性可重复构建的能力,并包含了 npm 完全构建包树所需的一切。现在,CLI 还可以将 yarn.lock 用作软件包元数据和解决方案指南的来源。
npm 7.0.0 中的重大更改
除了这 3 个主要的新功能外,在这个版本中还有一些突破性的变化,开发者应该知道,因为我们都知道,为了提高整体的开发者体验,一些突破性的变化是必要的。
自动安装同级依赖关系的能力有可能破坏某些工作流程。
npm 现在使用了 package.export 字段,使得不再 require() npm 的内部模块。
npx 已经完全重写了,现在可以使用 npm exec 命令了
npm audit 的输出在人可读和 --json 输出方式上都有所改变。它不再使用表格来显示漏洞,vuln count 也不再是将树上的每一个节点相乘。
现在默认情况下,npm ls 仅显示顶级软件包
V8
取消对 node12 以下版本的支持
yarn 拉取依赖冲突