01 讲我们讲了 npm 的技巧和原理,但其实在前端工程化这个主题上除了 npm,还有不可忽视的 Yarn。

Yarn 是一个由 Facebook、Google、Exponent 和 Tilde 构建的新的 JavaScript 包管理器。它的出现是为了解决历史上 npm 的某些不足(比如 npm 对于依赖的完整性和一致性保障,以及 npm 安装速度过慢的问题等),虽然 npm 目前经过版本迭代汲取了 Yarn 一些优势特点(比如一致性安装校验算法等),但我们依然有必要关注 Yarn 的思想和理念。

Yarn 和 npm 的关系,有点像当年的 Io.js 和 Node.js,殊途同归,都是为了进一步解放和优化生产力。这里需要说明的是,不管是哪种工具,你应该做的就是全面了解其思想,优劣胸中有数,这样才能驾驭它,为自己的项目架构服务。

当 npm 还处在 v3 时期时,一个叫作 Yarn 的包管理方案横空出世。2016 年,npm 还没有 package-lock.json 文件,安装速度很慢,稳定性也较差,而 Yarn 的理念很好地解决了以下问题。

确定性:通过 yarn.lock 等机制,保证了确定性。即不管安装顺序如何,相同的依赖关系在任何机器和环境下,都可以以相同的方式被安装。(在 npm v5 之前,没有 package-lock.json 机制,只有默认并不会使用的npm-shrinkwrap.json。)

采用模块扁平安装模式:将依赖包的不同版本,按照一定策略,归结为单个版本,以避免创建多个副本造成冗余(npm 目前也有相同的优化)。

网络性能更好:Yarn 采用了请求排队的理念,类似并发连接池,能够更好地利用网络资源;同时引入了更好的安装失败时的重试机制。

采用缓存机制,实现了离线模式(npm 目前也有类似实现)。

我们先来看看 yarn.lock 结构:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
"@babel/cli@^7.1.6", "@babel/cli@^7.5.5":
  version "7.8.4"
  resolved "http://npm.in.zhihu.com/@babel%2fcli/-/cli-7.8.4.tgz#505fb053721a98777b2b175323ea4f090b7d3c1c"
  integrity sha1-UF+wU3IamHd7KxdTI+pPCQt9PBw=
  dependencies:
    commander "^4.0.1"
    convert-source-map "^1.1.0"
    fs-readdir-recursive "^1.1.0"
    glob "^7.0.0"
    lodash "^4.17.13"
    make-dir "^2.1.0"
    slash "^2.0.0"
    source-map "^0.5.0"
  optionalDependencies:
    chokidar "^2.1.8"

该结构整体和 package-lock.json 结构类似,只不过 yarn.lock 并没有使用 JSON 格式,而是采用了一种自定义的标记格式,新的格式仍然保持了较高的可读性。

相比 npm,Yarn 另外一个显著区别是 yarn.lock 中子依赖的版本号不是固定版本。这就说明单独一个 yarn.lock 确定不了 node_modules 目录结构,还需要和 package.json 文件进行配合。

其实,不管是 npm 还是 Yarn,说到底它们都是一个包管理工具,在项目中如果想进行 npm/Yarn 切换,并不是一件麻烦的事情。甚至还有一个专门的 synp 工具,它可以将 yarn.lock 转换为 package-lock.json,反之亦然。

关于 Yarn 缓存,我们可以通过这个命令查看缓存目录,并通过目录查看缓存内容:

1
yarn cache dir

值得一提的是,Yarn 默认使用 prefer-online 模式,即优先使用网络数据。如果网络数据请求失败,再去请求缓存数据。

最后,我们来看一看一些区别于 npm,Yarn 所独有的命令:

1
2
3
4
5
yarn import
yarn licenses
yarn pack
yarn why
yarn autoclean

npm 独有的命令是: npm rebuild 。

现在,你已经对 Yarn 有了一个初步了解,接下来我们来分析一下 Yarn 的安装机制和思想。

Yarn 安装机制和背后思想

上一讲我们已经介绍过了 npm 安装机制,这里我们再来看一下 Yarn 的安装理念。简单来说,Yarn 的安装过程主要有以下 5 大步骤:

检测(checking)→ 解析包(Resolving Packages) → 获取包(Fetching Packages)→ 链接包(Linking Packages)→ 构建包(Building Packages)

Yarn 安装流程图

检测包(checking)

这一步主要是检测项目中是否存在一些 npm 相关文件,比如 package-lock.json 等。如果有,会提示用户注意:这些文件的存在可能会导致冲突。在这一步骤中,也会检查系统 OS、CPU 等信息。

解析包(Resolving Packages)

这一步会解析依赖树中每一个包的版本信息。

首先获取当前项目中 package.json 定义的 dependencies、devDependencies、optionalDependencies 的内容,这属于首层依赖。

接着采用遍历首层依赖的方式获取依赖包的版本信息,以及递归查找每个依赖下嵌套依赖的版本信息,并将解析过和正在解析的包用一个 Set 数据结构来存储,这样就能保证同一个版本范围内的包不会被重复解析。

对于没有解析过的包 A,首次尝试从 yarn.lock 中获取到版本信息,并标记为已解析;

如果在 yarn.lock 中没有找到包 A,则向 Registry 发起请求获取满足版本范围的已知最高版本的包信息,获取后将当前包标记为已解析。

总之,在经过解析包这一步之后,我们就确定了所有依赖的具体版本信息以及下载地址。

解析包获取流程图

获取包(Fetching Packages)

这一步我们首先需要检查缓存中是否存在当前的依赖包,同时将缓存中不存在的依赖包下载到缓存目录。说起来简单,但是还是有些问题值得思考。

比如:如何判断缓存中是否存在当前的依赖包?其实 Yarn 会根据 cacheFolder+slug+node_modules+pkg.name 生成一个 path,判断系统中是否存在该 path,如果存在证明已经有缓存,不用重新下载。这个 path 也就是依赖包缓存的具体路径。

对于没有命中缓存的包,Yarn 会维护一个 fetch 队列,按照规则进行网络请求。如果下载包地址是一个 file 协议,或者是相对路径,就说明其指向一个本地目录,此时调用 Fetch From Local 从离线缓存中获取包;否则调用 Fetch From External 获取包。最终获取结果使用 fs.createWriteStream 写入到缓存目录下。

获取包流程图

链接包(Linking Packages)

上一步是将依赖下载到缓存目录,这一步是将项目中的依赖复制到项目 node_modules 下,同时遵循扁平化原则。在复制依赖前,Yarn 会先解析 peerDependencies,如果找不到符合 peerDependencies 的包,则进行 warning 提示,并最终拷贝依赖到项目中。

这里提到的扁平化原则是核心原则,我也会在后面内容进行详细的讲解。

链接包解析流程图

构建包(Building Packages)

如果依赖包中存在二进制包需要进行编译,会在这一步进行。

了解了 npm 和 Yarn 的安装原理还不是“终点”,因为一个应用项目的依赖错综复杂。接下来我将从“依赖地狱”说起,帮助你加深对依赖机制相关内容的理解,以便在开发生产中灵活运用。

破解依赖管理困境

早期 npm(npm v2)的设计非常简单,在安装依赖时将依赖放到项目的 node_modules 文件中;同时如果某个直接依赖 A 还依赖其他模块 B,作为间接依赖,模块 B 将会被下载到 A 的 node_modules 文件夹中,依此递归执行,最终形成了一颗巨大的依赖模块树。

这样的 node_modules 结构,的确简单明了、符合预期,但对大型项目在某些方面却不友好,比如可能有很多重复的依赖包,而且会形成“嵌套地狱”。

那么如何理解“嵌套地狱”呢?

项目依赖树的层级非常深,不利于调试和排查问题;

依赖树的不同分支里,可能存在同样版本的相同依赖。比如直接依赖 A 和 B,但 A 和 B 都依赖相同版本的模块 C,那么 C 会重复出现在 A 和 B 依赖的 node_modules 中。

这种重复问题使得安装结果浪费了较大的空间资源,也使得安装过程过慢,甚至会因为目录层级太深导致文件路径太长,最终在 Windows 系统下删除 node_modules 文件夹出现失败情况。

因此 npm v3 之后,node_modules 的结构改成了扁平结构,按照上面的例子(项目直接依赖模块 A,A 还依赖其他模块 B),我们得到下面的图示:

npm 不同版本的安装结构图 ①

当项目新添加了 C 依赖,而它依赖另一个版本的 B v2.0。这时候版本要求不一致导致冲突,B v2.0 没办法放在项目平铺目录下的 node_moduls 文件当中,npm v3 会把 C 依赖的 B v2.0 安装在 C 的 node_modules 下:

npm 不同版本的安装结构图 ②

接下来,在 npm v3 中,假如我们的 App 现在还需要依赖一个 D,而 D 也依赖 B v2.0 ,我们会得到如下结构:

npm 安装结构图 ①

这里我想请你思考一个问题:为什么 B v1.0 出现在项目顶层 node_modules,而不是 B v2.0 出现在 node_modules 顶层呢?

其实这取决于模块 A 和 C 的安装顺序。因为 A 先安装,所以 A 的依赖 B v1.0 率先被安装在顶层 node_modules 中,接着 C 和 D 依次被安装,C 和 D 的依赖 B v2.0 就不得不安装在 C 和 D 的 node_modules 当中了。因此,模块的安装顺序可能影响 node_modules 内的文件结构。

我们继续依赖工程化之旅。假设这时候项目又添加了一个依赖 E ,E 依赖了 B v1.0 ,安装 E 之后,我们会得到这样一个结构:

npm 安装结构图 ②

此时对应的 package.json 中,依赖包的顺序如下:

1
2
3
4
5
6
{
    A: "1.0",
    C: "1.0",
    D: "1.0",
    E: "1.0"
}

如果我们想更新模块 A 为 v2.0,而模块 A v2.0 依赖了 B v2.0,npm v3 会怎么处理呢?

整个过程是这样的:

删除 A v1.0;

安装 A v2.0;

留下 B v1.0 ,因为 E v1.0 还在依赖;

把 B v2.0 安装在 A v2.0 下,因为顶层已经有了一个 B v1.0。

它的结构如下:

npm 安装结构图 ③

这时模块 B v2.0 分别出现在了 A、C、D 模块下——重复存在了。

通过这一系列操作我们可以看到:npm 包的安装顺序对于依赖树的影响很大。模块安装顺序可能影响 node_modules 内的文件数量。

这里一个更理想的依赖结构理应是:

npm 安装结构图 ④

过了一段时间,模块 E v2.0 发布了,并且 E v2.0 也依赖了模块 B v2.0 ,npm v3 更新 E 时会怎么做呢?

删除 E v1.0;

安装 E v2.0;

删除 B v1.0;

安装 B v2.0 在顶层 node_modules 中,因为现在顶层没有任何版本的 B 了。

此时得到图:

npm 安装结构图 ⑤

这时候,你可以明显看到出现了较多重复的依赖模块 B v2.0。我们可以删除 node_modules,重新安装,利用 npm 的依赖分析能力,得到一个更清爽的结构。

实际上,更优雅的方式是使用 npm dedupe 命令,得到:

npm 安装结构图 ⑥

实际上,Yarn 在安装依赖时会自动执行 dedupe 命令。整个优化的安装过程,就是上一讲提到的扁平化安装模式,也是需要你掌握的关键内容。

结语

这一讲我们解析了 Yarn 安装原理。

通过本讲内容,你可以发现包安装并不只是从远程下载文件那么简单,这其中涉及缓存、系统文件路径,更重要的是还涉及了安装依赖树的解析、安装结构算法等。

最后,给大家布置一个思考题,npm v7 在 2020 年 10 月刚刚发布,请你总结一下它的新特性,并思考一下为什么要引入这些新的特性?这些新特性背后是如何实现的?欢迎在留言区分享你的观点。

对标阿里P7技术需求 + 每月大厂内推,6 个月助你斩获名企高薪 Offer。点此链接,快来领取!

-– ### 精选评论 ##### *强: > 了解了 npm 的安装依赖的原理,还有 npm dedupe 命令,学到了!😎 ######     编辑回复: >     加油~ ##### **的小叶酱: > 找了一下官方的文档,好像没有内部原理相关的内容。想请教一下大佬的学习方法🙋 ######     讲师回复: >     看源码其实是个很好的方式,没有文档,总有源码呀 ##### **欣: > `其实 Yarn 会根据 cacheFolder+slug+node_modules+pkg.name 生成一个 path,判断系统中是否存在该 path,如果存在证明已经有缓存,不用重新下载` 和 `Yarn 默认使用 prefer-online 模式,即优先使用网络数据。如果网络数据请求失败,再去请求缓存数据。` 这2段话是不是冲突了啊 ######     讲师回复: >     第一段表述其实有个前提: prefer-online 如果关闭了,那么在走缓存时,「`其实 Yarn 会根据 cacheFolder+slug+node_modules+pkg.name 生成一个 path,判断系统中是否存在该 path,如果存在证明已经有缓存,不用重新下载」 ##### *舒: > 这一讲下来,我的感触是 是不是yarn是当时历史的产物,现在 npm 的功能已经比较完善了,不太需要 yarn 了 ######     讲师回复: >     目前来看,两者并驾齐驱,取决于团队技术选型 ##### **彪: > “相比 npm,Yarn 另外一个显著区别是 yarn.lock 中子依赖的版本号不是固定版本。这就说明单独一个 yarn.lock 确定不了 node_modules 目录结构,还需要和 package.json 文件进行配合。”npm的node_modules目录结构不也是通过lock和package.json一起决定的吗?希望予以解答,谢谢~ ######     讲师回复: >     这里的差别在于 yarn.lock 和 package-lock ##### **航: > 为什么npm不在安装或者更新依赖时自动执行npm dedupe ######     讲师回复: >     npm dedupe 还是要留给开发者应用,毕竟它并不完全可靠,按你说的,npm 就很难有一致性可靠性了 ##### **彪: > npm v7更新:1. Workspaces: npm CLI的一组功能,可支持从单个顶级软件包中管理多个软件包2.">自动安装peerDependencies3. package-lock v2和对yarn.lock的支持 ##### **宇: > 为什么b1.0和b2.0不能设计成平级,根据不同版本号区分 ######     讲师回复: >     历史的坑,最开始方案就不支持,如果只区分 major 那么 patch 和 fix 版本号还平级吗?平级的话,size 太大了,不平级,那区别也不多 ##### *腾: > 想问一下侯大有调试yarn源码的技巧吗 ######     讲师回复: >     和调试其他源码没什么特别的地方,唯一需要注意的是,可以结合实践分析 ##### 无: > 请教侯大阅读 yarn 源码的技巧,是生读源码还是配合调试工具进行的呢?研究 yarn install 部分的源码时,发觉基于 gulp 打包至 lib 的流程似乎难以实现源码逐行调试,有些疑惑🤔 ######     讲师回复: >     这个看自己喜好吧。我是遇见了问题,去翻源码。不会直接看源码 ##### **鑫: > 这个示例项目同时依赖了两个不同版本的依赖 B v1.0 和 C 依赖的 B v2.0, 那么项目打包的时候是两个版本的 B 都被打包进项目了吗? ######     讲师回复: >     是否都被打包,取决于业务上是否都有直接或间接应用 ##### **飞: > 为啥不讲 npm v6 而是讲 v3 ######     讲师回复: >     v3 开始加入的某些新特性,v6 一直在沿用,对比新旧特性,就提到了 v3 这个本质不冲突啊,注意:v3 开始加入的某些新特性,v6 一直在沿用,沿用到现在,都一起讲了啊。那我为啥不讲新出的 v7…v7 也有些新特性,留在了思考题阶段 ##### *帅: > 请问如果一个项目里,混用npm和yarn会产生什么影响呢 ######     讲师回复: >     锁版本机制不一样 ##### **4299: > 受益匪浅,感谢老师,讲的太好了,深知自己的不足。