Ryan 对于 node.js 的十大遗憾之一就是支持了 node_modules,node_modules 的设计虽然能满足大部分的场景,但是其仍然存在着种种缺陷,尤其在前端工程化领域,造成了不少的问题,本文总结下其存在的一些问题,和可能的改进方式
术语
package:包含了 package.json, 使用 package.json 定义的一个 package,通常是对应一个 module,也可以不包含 module,比如 bin 里指明一个 shell 脚本, 甚至是任意文件(将 registry 当做 http 服务器使用,或者利用 unpkg 当做 cdn 使用), 一个 package 可以是一个 tar 包,也可以是本地 file 协议,甚至 git 仓库地址
module:能被 require 加载的就叫一个 module,如下都是 module,只有当 module 含有 package.json 的时候才能叫做 package,
一个包含 package.json 且含有 main 字段的文件夹
一个含有 index.js 的文件夹
任意的 js 文件
综合: module 不一定是 package,package 不一定是 module
Dependency Hell
现在项目里有两个依赖 A 和 C,A 和 C 分别依赖 B 的不同版本,如何处理
这里存在两个问题
首先是 B 本身支持多版本共存,只要 B 本身没有副作用,这是很自然的,但是对于很多库如 core-js 会污染全局环境,本身就不支持多版本共存,因此我们需要尽早的进行报错提示(conflict 的 warning 和运行时的 conflict 的 check)
如果 B 本身支持多版本共存,那么需要保证 A 正确的加载到 B v1.0 和 C 正确的加载到 B v2.0
我们重点考虑第二个问题
npm 解决方式
node 的解决方式是依赖的 node 加载模块的路径查找算法和 node_modules 的目录结构来配合解决的
如何从 node_modules 加载 package
核心是递归向上查找 node_modules 里的 package,如果在'/home/ry/projects/foo.js'文件里调用了require('bar.js'),则 Node.js 会按以下顺序查找:
/home/ry/projects/node_modules/bar.js
/home/ry/node_modules/bar.js
/home/node_modules/bar.js
/node_modules/bar.js
该算法有两个核心
优先读取最近的node_modules的依赖
递归向上查找node_modules依赖
该算法即简化了 Dependency hell 的解决方式,也带来了非常多的问题。
node_modules 的目录结构
nest mode
利用 require 先在最近的 node_module 里查找依赖的特性,我们能想到一个很简单的方式,直接在 node_module 维护原模块的拓扑图即可
这样根据 mod-a 就近的使用 mod-b 的 1.0 版本,而 mod-c 就近的使用了 mod-b 的 2.0 版本
但是这样带来了另一个问题,如果我们此时再依赖一个 mod-d, 该 mod-d 也同时依赖的 mod-b 的 2.0 版本,这时候 node_modules 就变成下面这样
我们发现这里存在个问题,虽然 mod-a 和 mod-d 依赖了同一个 mod-b 的版本,但是 mod-b 却安装了两遍,如果你的应用了很多的第三方库,同时第三方库共同依赖了一些很基础的第三方库如 lodash,你会发现你的 node_modules 里充满了各种重复版本的 lodash,造成了极大的空间浪费,也导致 npm install 很慢,这既是臭名昭著的 node_modules hell
flat mode
我们还可以利用向上递归查找依赖的特性,将一些公共依赖放在公共的 node_module 里
根据 require 的查找算法
A 和 D 首先会去自己的 node_module 里去查找 B,发现不存在 B,然后递归的向上查找,此时查找到了 B 的 v1.0 版本,符合预期
C 会先查找到自己的 node_module 里查找到了 B v2.0, 符合预期
这时我们发现了即解决了 depdency hell 也避免了 npm2 的 nest 模式导致的重复依赖问题
doppelgangers
但是问题并没有结束,如果此时引入的 D 依赖的是 B v2.0 而引入的 E 依赖的是 B v1.0, 我们发现无论是把 Bv2.0 还是 Bv1.0 放在 top level, 都会导致另一个版本任何会存在重复的问题, 如这里的 B 的 v2.0 的重复问题
版本重复会有问题吗?
你也许会说版本重复不就是浪费一点空间吗,而且这种只有出现版本冲突的时候才会碰到,似乎问题不大,事实的确如此, 然而某些情况下这仍然会造成问题
全局 types 冲突
虽然各个 package 之前的代码不会相互污染,但是他们的 types 仍然可以相互影响,很多的第三方库会修改全局的类型定义,典型的就是 @types/react, 如下是一个常见的错误
其错误原因就在于全局的 types 形成了命名冲突,因此假如版本重复可能会导致全局的类型错误。
一般的解决方式就是自己控制包含哪些加载的 @types/xxx。
破坏单例模式
require 的缓存机制
node 会对加载的模块进行缓存,第一次加载某个模块后会将结果缓存下来,后续的 require 调用都返回同一结果,然而 node 的 require 的缓存并非是基于 module 名,而是基于 resolve 的文件路径的,且是大小写敏感的,这意味着即使你代码里看起来加载的是同一模块的同一版本,如果解析出来的路径名不一致,那么会被视为不同的 module,如果同时对该 module 同时进行副作用操作,就会产生问题。
以 react-loadable 为例,其同时在 browser 和 node 层使用
browser 里使用
node 层使用
然后将 browser 进行打包编译为 bundle.js,并在 node 层加载编译好的代码 bundle.js
虽然 node 层和 browser 访问的都是'react-loadable',如果 webpack 编译的时候涉及到路径改写,虽然 react-loadable 的版本一致,那么会导致 node 和 browser 加载的不是一份 react-loadble 的导出对象,不幸的是 react-loadable 强依赖 node 和 browser 导出的是同一个对象。因为 node 层会读取 browser 设置的 READY_INITIALIZERS, 如果 node 和 browser 导出的不是同一个对象,则导致读取失败
另一个容易出问题的地方就是使用 git submodule,git submodule 很容易造成一个环境里多版本共存,比如同时存在多个 react 版本,更容易触发问题。
Phantom dependency
我们发现 flat mode 相比 nest mode 节省了很多的空间,然而也带来了一个问题即 phantom depdency,考察下如下的项目
我们编写如下代码
这里的 glob 和 brace-expansion 都不在我们的 depdencies 里,但是我们开发和运行时都可以正常工作(因为这个是 rimraf 的依赖),一旦将该库发布,因为用户安装我们的库的时候并不会安装库的 devDepdency,这导致在用户的地方会运行报错。
我们把一个库使用了不属于其 depdencies 里的 package 称之为 phantom depdencies,phantom depdencies 不仅会存在库里,当我们使用 monorepo 管理项目的情况下,问题更加严重,一个 package 不但可能引入 DevDependency 引入的 phantom 依赖,更很有可能引入其他 package 的依赖,当我们部署项目或者运行项目的时候就可能出问题。
在基于 yarn 或者 npm 的 node_modules 的结构下,doppelganger 和 phantom dependency 似乎并没有太好的解决方式。其本质是因为 npm 和 yarn 通过 node resolve 算法 配合 node_modules 的树形结构对原本 depdency graph 的模拟,哪有没有更好的模拟方式能够避免上述问题呢。
Semver 当理想遇到现实
npm 对 package 版本号采用语义化版本,Semver 本身也是为了解决 Depdency Hell 而引入的解决方案,如果你的项目引入的第三方依赖越来越多,你将会面临一个困境
如果你为你的每一个版本都写死依赖,那么如果某个底层的依赖需要修复或者升级,你难以评估这个升级会修复的影响范围,这可能导致级联反应,与其协作的任何包都可能会挂掉,导致整个系统都需要全量的测试回归,最后的结果很大可能是整个应用彻底锁死版本,再也不敢做任何升级改动
因此 semver 的提出主要是用于控制每个 package 的影响范围,能够实现系统的平滑升级和过渡,npm 每次安装都会按照 semver 的限制,安装最新的符合约束的依赖。
这样每次 npm install 都会安装符合 "^4.0.0" 约束的最新依赖,可能是 4.42.0 的版本。
如果所有的库都能完美的遵守语义化版本,那么世界和平,然而现实是很多库因为种种原因并未遵守 semver,原因包括
不可预知的 bug,本来以为某个版本只是 bugfix,发布了 patch 版本,但是该 patch 却引入了未预料的 breaking change 导致 semver 被破坏
semver 的设计过于理想,实际上即使是最小的 bugfix,如果业务方无意中依赖了这个 bug,仍然会导致 breaking change,bug 和 breaking change 的界限是模糊的