带你摸清前端依赖管理的历史进程,深入npm、yarn、pnpm背后的故事

1,255 阅读9分钟

前言

依赖管理作为前端开发不可缺少的部分,在我们的日常工作中非常重要。在快速迭代中,我们几乎不可能在不依赖开源项目的情况下独立为项目设计自有的组件库/请求库,因为这会大大影响主要功能的开发进度,然而我们的前端项目到底是怎么做到在不“混乱”的情况下的使用几百个依赖库的呢?

Take your JavaScript development up a notch

——www.npmjs.com

🐟 刀耕火种时代

<script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>

<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.6/umd/popper.min.js" integrity="sha384-wHAiFfRlMFy6i5SRaxvfOCifBUQy1xHdJ/yoi7FRNXMRBu5WHdZYu1hA6ZOblgut" crossorigin="anonymous"></script>

<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.2.1/js/bootstrap.min.js" integrity="sha384-B0UglyR+jN6CkvvICOB2joaf5I4l3gm9GU6Hc1og6Ls7i6U/mkkaduKaBhlAXv9k" crossorigin="anonymous"></script>

相信接触过jquery的同学们一定听说过bootstrap这个组件库

在最早的不基于任何前端现代库(React/Vue等)的原生页面中,如果我们想快捷的搭建一套b端页面,bootstrap是一个很好的选择,你只需要在script标签中引入对应库的cdn链接即可,然而由于bootstrap依赖于jquery,我们必须把引入jquery的标签写在bootstrap的前面,不然就会导致报错。

这样简单粗暴的依赖管理方法显然跟不上时代的变化,几个库也许可以分清前后依赖关系,那么如果依赖库数量增多,这个依赖关系将会变得更加复杂

🐠 npm:白银时代

亮点

npm是Node.js官方提供的,只要你安装了node,你就可以使用npm的强大能力,它的出现同时也制定了一些包管理规范:

  • 所有的第三方依赖包都放在node_modules这个文件目录下,我们再增加,删除,升级依赖也只是更新这个文件下的相关依赖包。
  • package.json文件中存放本项目及项目的依赖和版本信息,这样我们就可以知道本项目用到什么,都是什么版本。

至此,我们多一个可以帮我们管理依赖库的助手,我们再也不用关心我们使用的代码库是否依赖于其他库,npm替我们托管一切

里程碑

npm 2 递归结构

├── node_modules

│   ├── A@1.0.0

│   │   └── node_modules

│   │   │   └── D@1.0.0

│   ├── B@1.0.0

│   │   └── node_modules

│   │        └── D@1.0.0

在安装依赖包时,采用简单的递归安装方法。执行 npm install 后,npm 2 依次递归安装 A 和 B 两个包到 node_modules 中。执行完毕后,我们会看到 ./node_modules 这层目录只含有这两个子目录。

进入更深一层 A 或 B 目录,将看到这两个包各自的 node_modules 中,已经由 npm 递归地安装好自身的依赖包。包括 ./node_modules/A/node_modules/D , ./node_modules/B/node_modules/D 等等。而每一个包都有自己的依赖包,每个包自己的依赖都安装在了自己的 node_modules 中。依赖关系层层递进,构成了一整个依赖树,这个依赖树与文件系统中的文件结构树刚好层层对应

对复杂的工程, node_modules 内目录结构可能会太深。导致深层的文件路径过长而触发 windows 文件系统中,文件路径不能超过 260 个字符长的错误

部分被多个包所依赖的包,很可能在应用 node_modules 目录中的很多地方被重复安装。随着工程规模越来越大,依赖树越来越复杂,这样的包情况会越来越多,造成大量的冗余

如上图,两个一级依赖都同时依赖于一个二级依赖,这个库被下载了两遍

npm 3 扁平结构

├── node_modules

│   ├── A@1.0.0

│   ├── B@1.0.0

│   ├── D@1.0.0

扁平结构相当于把整个node_modules给“拍平”了,npm它会遍历所有依赖树节点,逐个将模块放在一级node_modules中,当发现有重复模块时,则将其丢弃

在这种结构中,我们可以省略冗余的依赖下载,但是遇到同名依赖的不同版本,它似乎变得有些“不懂变通”,下面两张图只取决于node_modules 中依赖的书写顺序,case2和case1生成了完全不同的安装树(npm后续版本会把最新版本的库安装在一级目录,如果其他库依赖较低版本才会安装在自身的node_modules中)

case1:

├── node_modules

│   ├── A@1.0.0

│   ├── B@1.0.0

│   ├── D@1.0.0

│   ├── C@1.0.0

│   │   └── node_modules

│   │   │   └── D@2.0.0

case2:

├── node_modules

│   ├── C@1.0.0

│   ├── D@2.0.0

│   ├── A@1.0.0

│   │   └── node_modules

│   │   │   └── D@1.0.0

│   ├── B@1.0.0

│   │   └── node_modules

│   │   │   └── D@1.0.0

对于 npm 来说同名但不同版本的包是两个独立的包,而同层不能有两个同名子目录,所以:

  • 在一级node_moudles中已经存在依赖包的情况下,新安装的依赖包如果存在版本冲突,则仍会安装到新依赖包的node_modules中。
  • 在一级node_moudles中已经存在依赖包的情况下,新安装的依赖包如果不存在版本冲突,则会忽略安装。

npm 5 辅助锁定

npm 5.x开始,执行npm install时会自动生成一个package-lock.json 文件。

ps:在2016年yarn推出yarn.lock后npm在2017年才推出了package-lock.json

{

    "name": "web_offline_widget",

    "version": "1.0.0",

    "lockfileVersion": 1,

    "requires": true,

    "dependencies": {

        "accepts": {

            "version": "1.3.7",

            "resolved": "http://bnpm.byted.org/accepts/-/accepts-1.3.7.tgz",

            "integrity": "sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==",

            "requires": {

                "mime-types": "~2.1.24",

                "negotiator": "0.6.2"

            }

        },

       //以下省略

npm为了让开发者在安全的前提下使用最新的依赖包,在package.json中通常做了锁定大版本的操作,这样在每次npm install的时候都会拉取依赖包大版本下的最新的版本。这种机制最大的一个缺点就是当有依赖包有小版本更新时,可能会出现协同开发者的依赖包不一致的问题

package-lock.json文件精确描述了node_modules 目录下所有的包的树状依赖结构,每个包的版本号都是完全精确的

因为这个文件记录了 node_modules 里所有包的结构、层级和版本号甚至安装源,它也就事实上提供了 “保存” node_modules 状态的能力。只要有这样一个 lock 文件,不管在哪一台机器上执行 npm install 都会得到完全相同的 node_modules 结果。

Q:为什么我们的package-lock.json文件总是在npm install的时候就自己更新了?它到底锁了什么?

A:

  • 不一样版本的 npm 的安装算法不一样。
  • 某些依赖项自上次安装以来,可能已发布了新版本,所以将根据 package.json 中的 semver-range version 更新依赖。
  • 某个依赖项的依赖项可能已发布新版本,即便你使用了固定依赖项说明符(1.2.3 而不是 ^1.2.3),它也会更新。

🐬 yarn:黄金时代

亮点

yarn是Facebook首创,后由独立组织维护的依赖管理工具

说到yarn就不得不提到它的高速,速度快的理由主要来自以下两个方面:

  • 并行安装:无论 npm 还是 Yarn 在执行包的安装时,都会执行一系列任务。npm 是按照队列执行每个 package,也就是说必须要等到当前 package 安装完成之后,才能继续后面的安装。而 Yarn 是同步执行所有任务,提高了性能
  • 离线模式:如果之前已经安装过一个软件包,用Yarn再次安装时之间从缓存中获取,就不用像npm那样再从网络下载了

里程碑

yarn2 无node_modules模式

迁移官方文档: yarnpkg.com/getting-sta…

npm install -g yarn@berry

在使用yarn 2.x安装以后,node_modules不会再出现,代替它的是.yarn目录,里面有cache和unplugged两个目录,以及外面一个.pnp.js

  • .yarn/cache里面放所有需要的依赖的压缩包,zip格式
  • .yarn/unplugged是你需要手动去修改的依赖,使用yarn unplugin lodash可以把lodash解压到这个目录下,之后想修改什么的随意
  • .pnp.js是PNP功能的核心,所有的依赖定位都需要通过它来

无node_modules模式可以加快项目安装速度,同时大大缩减删除一整个项目的速度,node_modules为项目带来了非常多的节点文件,仅依赖于React的项目就有将近2w+个节点,删除操作就变得困难起来

ps.新项目可以尝鲜去试试yarn2/yarn3,老项目改造异常困难,会报各种各样的错误,改造收益不大,目前中文互联网有几乎没有成熟的兼容迁移文档,直接干掉node_modules对于一个较为复杂的项目来说步子还是迈的太大了

🐳 pnpm:未来时代

亮点

pnpm 相比较于 yarn/npm 这两个常用的包管理工具在性能上也有了极大的提升

如果我们使用yarn/npm安装express,我们的node_modules目录会变成:

而如果使用pnpm,会变成这样:

node_modules 中只有一个叫 .pnpm 的文件夹以及一个叫做 express 的软链。 不错,pnpm只安装了 express,所以它是唯一一个你的应用拥有访问权限的包。

express 只是一个软链。 当 Node.js 解析依赖的时候,它使用这些依赖的真实位置,所以它不保留软链。 express 的真实位置在node_modules/.pnpm/express@4.17.1/node_modules/express里,我们的.pnpm/ 以真正平铺的形式储存着所有的包,所以每个包(包括这个包的所有依赖包)都可以在这种命名模式的文件夹中被找到:.pnpm/<name>@<version>/node_modules/<name>

这个平铺的结构避免了 npm v2 创建的嵌套 node_modules 引起的长路径问题,但与 npm v3,4,5,6 或 yarn v1 创建的扁平的 node_modules 不同的是,它保留了包之间的相互隔离,并且真正解决了重复包的问题。

解决Phantom dependencies

Phantom dependencies 被称之为幽灵依赖,解释起来很简单,即某个包没有被安装(package.json中并没有,但是用户却能够引用到这个包)

这个现象的出现原理很好理解,在npm推出v3以后,一个库只要被其他库依赖,哪怕没有显式声明在package.json中,也可以会被安装在node_modules的一级目录里,我们可以“自由”的在项目中使用这些幽灵依赖

试想这种case:

package.json -> a(ba 依赖)



node_modules

  /a

  /b

那么这里这个 b 就成了一个幽灵依赖,如果某天某个版本的 a 依赖不再依赖 b 或者 b 的版本发生了变化,那么 require b 的模块部分就会抛错

得益于pnpm的目录格式,它天生解决了这个幽灵依赖问题,如果不显式声明,开发者不可能拥有 b 的使用权限

提出的规范需要时间践行

pnpm这么好,我们可以现在就使用它吗?答案是 可以 也 不可以

如果是一个从零开始的新项目,那大可放心的用上

但如果是一个有几年历史包袱的老项目,如果这个项目的部分依赖库写的不够规范,库自己就使用了幽灵依赖,那么将导致在pnpm install的时候报错

虽然我们实在不行还可以设置shamefully-hoist:ture来豁免幽灵依赖:

但是pnpm的设计初衷还是为了杜绝幽灵依赖的,如果未来工具库的设计者都遵循标准设计出合理使用依赖的库,那么pnpm将会发挥更大的作用。

参考

www.javashuo.com/article/p-t…

juejin.cn/post/684490…

zhuanlan.zhihu.com/p/107343333

pnpm.io/zh/blog/202…