理解前端依赖

现代的前端开发, 每个前端工程师都需要自己的项目添加第三方依赖, 也会自己开发的第三方库进行对外发布, 而承担这些功能的我们叫它包管理器, 世面上常见的包管理器有bower npm yarn, 这三个里npm是使用最为广泛的.

因为前端依赖是允许再同一个项目中可能在不同层级的依赖上使用同一个第三方依赖的多个版本, 所以前端的依赖管理逻辑往往让人理不清. 这本质的原因在于当允许依赖树中出现多个相同的依赖, 就会出现’环’这种东西, 所以实际上依赖应该被解析成graph 而不是 tree, 但是在文件系统确只能表达tree, 因此在不同的npm版本, 对于依赖的解析也有不同的方式, 我们下面提到的均是以v6版本为基础的.

npm是如何解析项目依赖的

在前端项目里, 基本上都能看到一个名叫package.json的文件, 里面有一部分重要的字段, 比如dependencies devDependencies 等等,都是是用来记录项目依赖的, 相关字段的区别无非是设置依赖的安装条件而已, 具体的可以查看官方文档, 这里不再赘述. 项目里的所有依赖都是有版本标记的, 他们都使用同一种版本命名法Semantic, 这可以让npm很好的对相同第三方库的版本进行区分和排序, 这也是能够实现一个依赖树中出现多个相同第三方库的基础.

我们知道, 前端项目不仅仅有package.json这个文件, 还有一个目录叫做node_modules. ‘package.json’是用来描述我们的项目依赖, 而node_modules则是用来保存对应的第三方依赖包的, 那么npm是如何通过package.json来构建node_modules文件夹的呢?

我还是直接上一个例子吧!

下面是一个项目mage的一个依赖图:

1
2
3
4
mage
+-- sugar-design@1.0.0 // 直接依赖
+-- react@16.2.0
+-- classname@5.0.0

首先, npm会尝试吧所有的依赖扁平化的保存到最顶级的node_modules, 也就是生成下面这样的文件结构:

1
2
3
4
5
mage
+-- node_modules
+-- sugar-design@1.0.0
+-- react@16.2.0
+-- classname@5.0.0

当我们给mage添加一个新的依赖react@16.13.1时, mage的依赖图会变成这样:

1
2
3
4
5
mage
+-- react@16.13.1
+-- sugar-design@1.0.0
+-- react@16.2.0
+-- classname@5.0.0

那么接下来node_modules的文件结构会变成怎样呢?

  1. 根据项目的顶级依赖, 顶级node_modules安装react@16.13.1
  2. 由于顶级node_modules目录已经安装了react@16.13.1, 而sugar-design依赖的react版本是16.2.0, 所以只能安装在它内部的node_modules

所以文件结构会是这样:

1
2
3
4
5
6
7
mage
+-- node_modules
+-- react@16.13.1
+-- sugar-design@1.0.0
| +-- node_modules
| +-- react@16.2.0
+-- classname@5.0.0

ok, 我们继续, 现在我们又为mage增加一个新的依赖editor@1.0.0, 它依赖于react@16.2.0className@5.0.0

对应的依赖图:

1
2
3
4
5
6
7
8
mage
+-- react@16.13.1
+-- sugar-design@1.0.0
| +-- react@16.2.0
| +-- classname@5.0.0
+-- editor@1.0.0
+-- react@16.2.0
| +-- classname@5.0.0

接下来npm再安装依赖的时候会这样做:

  • 在顶级node_modules上安装editor@1.0.0
  • 由于它依赖的react版本和顶级node_modulesreact版本不匹配, 所以在它的内部node_modules里安装react@16.2.0
  • 由于它依赖的classname版本和顶级node_modulesclassname版本匹配本, 所以不需要再单独安装

文件结构会是这样:

1
2
3
4
5
6
7
8
9
10
mage
+-- node_modules
+-- react@16.13.1
+-- sugar-design@1.0.0
| +-- node_modules
| +-- react@16.2.0
+-- classname@5.0.0
+-- editor@1.0.0
+-- node_modules
+-- react@16.2.0

我们看到, 在安装依赖时, 都是优先安装在顶级node_modules上, 如果有已经用相同的第三方库已经安装, 则在子级node_modules上安装, 如果还有, 则以此类推

最后让我们把sugar-design的版本进行一次升级, 新的版本也同时升级了它的依赖, 把react升级到了16.13.1 classname 升级到了 6.0.0, 对应的依赖图是:

1
2
3
4
5
6
7
8
mage
+-- react@16.13.1
+-- sugar-design@2.0.0
| +-- react@16.13.1
| +-- classname@6.0.0
+-- editor@1.0.0
+-- react@16.2.0
| +-- classname@5.0.0

接下来会发生:

  • 由于祖先有相同版本的react, 所以移除了sugar-design内部的react
  • 由于安装顺序原因sugar-designclassname@6.0.0会安装在node_modules根目录上, 所以editor在内部node_modules安装classname@5.0.0

最后的文件结构会是这样:

1
2
3
4
5
6
7
8
9
mage
+-- node_modules
+-- react@16.13.1
+-- sugar-design@2.0.0
+-- classname@6.0.0
+-- editor@1.0.0
+-- node_modules
+-- react@16.2.0
+-- classname@5.0.0

最后的最后, 我们来测试一下变换声明顺序:

我们把sugar-designeditor的声明顺序对调一下:

1
2
3
4
5
6
7
8
mage
+-- react@16.13.1
+-- editor@1.0.0 // 对调了
| +-- react@16.2.0
| +-- classname@5.0.0
+-- sugar-design@2.0.0 // 对调了
+-- react@16.13.1
+-- classname@6.0.0

对调后,虽然最后加载的版本没错, 但是生成的文件结构变了:

1
2
3
4
5
6
7
8
9
10
mage
+-- node_modules
+-- react@16.13.1
+-- sugar-design@2.0.0
+-- node_modules
+-- classname@6.0.0 // sugar-design 的依赖到了自己的内部
+-- classname@5.0.0 // editor 的依赖classname到了顶级
+-- editor@1.0.0
+-- node_modules
+-- react@16.2.0

ok, 例子结束, 我们整理一下上面的例子, 得到一下的结论:

  • npm安装依赖时, 会尝试吧所有的依赖扁平化的安装在node_modules根目录
  • 如果祖先对应的依赖版本不匹配的话, 将在自己的内部node_modules内进行安装, 以此类推
  • 安装顺序和依赖的声明顺序有关, 所以声明顺序变了, 最后生成的`node_modules’结构可能会不同

经过上面的饿总结, 相信大家对npm的依赖安装逻辑已经有足够的了解了.

那么我们想一想, 依赖加载时, 查找的逻辑是什么呢? 其实从上面的依赖安装逻辑已经可以看出来了, 实际上就是’最近原则’, 也就是说先查找自己的node_modules, 没有就依次往上查找上级node_modules, 直到找到为止. 值得注意的是, 在真正调用依赖的时候, 系统并不会去验证版本号, 只要找到就进行加载, 版本号的验证都在安装阶段进行校验的

虽然了解了安装依赖的逻辑, 但是这对我们日常工作能有什么帮助呢? 毕竟日常当中, 我们往往是引入第三方库, 而不是发布第三方库. 如果我们是一个第三方库的作者, 我们需要在这方面注意什么呢?

第三方库开发误区

现在的前端工程, 离不开打包工具, 不管是webpack 还是rollup, 他们的核心理念都是吧多个模块封装到一个模块里.所以在我们开发的时候往往会对package.json內声明的依赖不太重视, 以为只要代码打包, 所有的依赖都已经打入构建好的源码中了, package.json里的东西只会在第三方库内部开发时才会用到, 不会对引用第三方库的人产生什么影响. 实际上这完全是错误的. 因为只要你的第三方库中的package.json声明了dependencies, 那么引入这个第三方的项目就都需要安装它, 虽然项目里确实不会真的引用到这些依赖.

上面这种情况, 虽然会安装多余的依赖, 但是并不会对项目本身产生多少负面作用.但接下来一个情况, 可能会对项目有重大印象, 并且在做bug排查时, 也可能会遇到很大的困难, 比如(Invalid Hook Call Warning)[https://reactjs.org/warnings/invalid-hook-call-warning.html].

首先我们应该理解一个概念, 在开发第三方库时, 应该吧运行时的依赖写在dependencies里.

如果我们开发的第三方库并不希望再运行时使用自己的依赖, 而是使用宿主的依赖, 比如react classname react-dom, 通常我们会对打包工具的external字段进行配置, 这样打包工具就不会把我们的对应依赖打入构建后的代码里. 这时构建好的第三方库是没对应的依赖的. 但是在其他项目引入我们写第三方库时, 项目打包工具在编译到我们的第三方库时, 由于我们的第三方库没有打包对应的依赖, 所以会去尝试加载我们第三方库的依赖,这时, 打包工具会去查找我们自己写第三方库下面的node_modules是否这些依赖, 如果第三方库里的依赖是使用dependencies来声明的话, 很有可能因为声明的版本和宿主环境的不同导致在内部又多安装了一份依赖, 构建工具最终会把我们自己写的第三方库的依赖也打入最后的项目构建代码中, 这样就会导致, 打入了多个不同版本的依赖, 就像下图

1
2
3
4
5
6
mage
+-- node_modules
+-- react@16.13.1 // 计划使用宿主的react
+-- sugar-design@2.0.0 // <- 我们自己的第三方库
+-- node_modules
+-- react@16.2.0 // 实际加载的是自己的依赖react

所以最好的方法是使用peerDependencies, 为什么不是dependencies呢? 因为peerDependencies可以保证你的第三方库不会额外引入一个react react-dom. 而像react react-dom这样的库, 因为设计原因, 同时引入多个可能会造成未知的错误, 而在排查这类bug的时候确往往是很困难的.

如果真的发生了上面说的问题, 应该怎么办呢?

如何找到多次引入的第三方包呢?

如果真的想确定是否有多个第三方库被引入呢? 以rect为例, 我这几个一个办法:

第一个
你可以使用npm命令, 查看当前的dependency graph:

1
npm ls react

上面的命令可以查看, 到底项目里有多少个第三方库依赖react, 之后在逐一进行排查.

第二个
首先在开发环境运行项目, 打开浏览器控制台: 按住 command + p, 并在出现的输入框中输入react.dev

如果出现多个结果, 但是来源却不一致, 就像上图一样, 那么恭喜你, 你已经找到了!

结语

所以, 请大家在写第三方库package.json的依赖部分时, 保持谨慎, 防止以外发生, 最好现在就去检查一下你写得第三方库是不是就有这类问题. 我相信将来npm也会为我们作更多的工作, 减少这些维护的成本.
因为npm一直再持续的迭代版本, 目前v7也在紧张的开发中, 这次带来了功能更强大的依赖树管理工具arborist, 感兴趣的同学可以进行深入了解.

推荐阅读

Understanding npm dependency resolution

Understanding the npm dependency model

npm install guideline