Ryan 对于 node.js 的十大遗憾之一就是支持了 node_modules,node_modules 的设计虽然能满足大部分的场景,但是其仍然存在着种种缺陷,尤其在前端工程化领域,造成了不少的问题,本文总结下其存在的一些问题,和可能的改进方式。
综合:module 不一定是 package,package 不一定是 module。
现在项目里有两个依赖 A 和 C,A 和 C 分别依赖 B 的不同版本,如何处理
这里存在两个问题1. 首先是 B 本身支持多版本共存,只要 B 本身没有副作用,这是很自然的,但是对于很多库如 core-js 会污染全局环境,本身就不支持多版本共存,因此我们需要尽早的进行报错提示(conflict 的 warning 和运行时的 conflict 的 check)。 2. 如果 B 本身支持多版本共存,那么需要保证 A 正确的加载到 B v1.0 和 C 正确的加载到 B v2.0。
我们重点考虑第二个问题。
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 的解决方式,也带来了非常多的问题
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 版本,符合预期
这时我们发现了即解决了 depdency hell 也避免了 npm2 的 nest 模式导致的重复依赖问题。
但是问题并没有结束,如果此时引入的 D 依赖的是 B v2.0,而引入的 E 依赖的是 B v1.0,我们发现无论是把 B v2.0 还是 B v1.0 放在 top level,都会导致另一个版本任何会存在重复的问题,如这里的 B 的 v2.0 的重复问题
你也许会说版本重复不就是浪费一点空间吗,而且这种只有出现版本冲突的时候才会碰到,似乎问题不大,事实的确如此,然而某些情况下这仍然会造成问题
虽然各个 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版本,更容易触发问题。
我们发现 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 的模拟,哪有没有更好的模拟方式能够避免上述问题呢。
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 被破坏。
那么在现实世界该如何处理这种问题,你肯定不希望自己的代码在本地是正常运行的,但是当你上线的时候就挂了吧。 在你的测试完成和业务上线前的 gap 期间,如果你的某个依赖不遵循 semver,产生了 breaking change ,那么你可能得半夜上线查 bug 了。我们发现问题的根源在于如何保证测试时候的代码和上线的代码是完全一致的。
一个很自然的想法就是,我直接把我的第三方依赖版本都写死不就行了
然而问题并没这么简单,虽然你锁定了 webpack 的版本,但是 webpack 的依赖却没法锁定,如果 webpack 的某个依赖法生产不遵循 semver 的 breaking change,我们的应用还是会受到影响,除非我们保证所有的第三方以及第三方的依赖都是写死版本,这即意味着整个社区放弃 semver,这显然是不可能的。
一个更加靠谱的写法是将项目里的依赖和第三方的依赖同时锁定,yarn 的 lock 和 npm 的 lock都支持该功能,一个常见的 lock 文件如下 如我们的项目安装了express 的依赖
其 lock 文件如下
我们发现 express 的所有依赖及其依赖的依赖的版本在 lock 文件里都锁定了,这样另一个用户或者环境,能够凭借 lock 文件复现 node_modules 里各个库的版本。 然而还是有一些场景 lock 无法覆盖,当我们第一次安装创建项目时或者第一次安装某个依赖的时候,此时即使第三方库里含有 lock 文件,但是 npm install|(yarn install) 并不会去读取第三方依赖的 lock,这导致第一次创建项目的时候,用户还是会可能触发 bug。这在全局安装 cli 的场景下非常常见,经常会碰到上一次安装全局 cli 的时候正常,但是重新安装这个版本的 cli 却挂了,这很有可能是该 cli 的版本的某个上游依赖发生了 breaking change,因为不存在全局环境的 lock,因此目前没有较好的解决方式。
如果你某天安装了一个新的 webpack-cli,却发现这个 webpack-cli 并不能正常工作,经过一番定位发现,是该 cli 的一个上游依赖 portfinder 的最近一个版本有 bug,但是该 cli 的作者在休假,没办法及时修复这个cli,但项目赶着上线该怎么处理?yarn 提供了一个叫做https://classic.yarnpkg.com/en/docs/selective-version-resolutions/ 的机制,使得你可以忽略 dependency 的限制,强行将 portfinder 锁定为某个没有 bug 的版本,以解燃眉之急
npm 本身没有提供 resolution 机制,但是可以通过 npm-froce-resolution
这个库实现类似机制
前面提到 npm 和 yarn 在 install 的时候并不会读取第三方库里的 lock 文件,那么我们编写库的时候还有必要提供 lock 文件吗。
不知道大家有没有过这种经验,某天发现了某个第三方库存在某个 bug,摩拳擦掌的将该库下载下来,准备修复下发个 mr,一顿npm install && npm build
操作猛如虎,然后就见到了一堆莫名其妙的编译错误,这些错误很可能是编译工具的某个上游依赖的 breaking change 所致,经过一番 google + stackoverflow 仍然没有修复,这时候就基本上断了提 mr 的冲动,如果库的开发者将当前的编译环境的 lock 提交上来,则很大程度上可以避免该问题。
determinism 指的是在给定 package.json 和 lock 文件下,每次重新 install 都会得到同样的 node_modules 的拓扑结构。 事实上 yarn 仅保证了同一版本的确定性而无法保证不同版本的确定性,npm 则保证了不同版本的确定性。 版本确定性 !== 拓扑确定性 我们之前说到 yarn.lock 保证了所有第三方库和其依赖的版本号是锁定的,虽然保证了版本,但是实际上 yarn.lock里并没有包含任何的 node_modules 拓扑信息
如上面的例子,该 lock 文件只保证了 has-flag 的版本和 suppors-colors 的版本,却没有保证 has-flag 是出现在 top level 还是出现在 supports-color 里,如下两种拓扑结构都是合理的 第一种
第二种
与之相比 npm 的 lock 信息则包含了拓扑结构信息
上述结构表明 has-flag 和 supports-color 处于同一层级
而如上的 lock 文件我们可以看出,define-property 和 is-accessor-descritpor 等依赖是放在 base 里的 node_modules 的
大部分场景下锁定版本号 + depdency 的拓扑结构一致基本上已经没啥问题了,即使 node_modules 的拓扑结构不一致,也不会产生问题,然而在某些场景下仍然会有问题。 如下的代码实际上是对 ndoe_modules 的拓扑结构有强假定,一旦 @types 的位置出现问题就可能存在问题。
这也要求我们读取第三方的依赖的时候不要使用任何的相对路径,而是应该通过 require.resolve 来读取模块的路径,然后再基于此路径去进行查找。 相对于哪个目录 相对路径的另一个问题,就是意义不明 以 babel 为例,当我们用 babel 去编译代码的时候,一般涉及到三个目录
问题来了,这里的@babel/preset-env
位置是相对于谁呢,这完全取决于 babel/core 里的内部实现。
如果说第三方库里存在的依赖问题一定程度上还比较可控,那么当我们进入 monorepo 领域,问题就会被加倍放大。当我们用一个仓库管理多个 package 的时候,有两个比较严重的问题
无论是 lerna 还是 yarn 工作机制核心都是
这种方式尽管解决了依赖重复和 link hell 两个核心问题,却引入了其他问题
实际上 html-webpack-plugin 运行时会依赖 webpack
在 hoist 前,react-scripts 会调用 html-webpack-plugin ,继而调用 webpack,根据 node 的 resolve 算法,会优先使用最近的 node_modules 里的 webpack 版本即这里的 webpack@2 但在 hoist 后,按照邻近原则则会使用 root-level 的 webpack 版本即 webpack@1 这样就会造成运行时错误。 而对于 yarn 和 npm,其优先会使用 hoist,只有当本地版本和 root 的冲突的时候,才不进行 hoist 的操作(甚至你没办法判定当存在多个版本的时候哪个版本会被 hoist 到 root level)。 这个问题并不局限于 webpack,eslint、jest、babel 等只要涉及到 core 及其插件的都会受此影响。 因此 react 官方为了解决这个问题,特地搞了个 preflight 检查(https://github.com/facebook/create-react-app/pull/3771),用于检查当前用户的 react-scripts 的 node_modules 及其祖先的 node_modules 里 babel 和 webpack 版本的一致性,一旦检测出版本不一致则给出 warning 直接退出
由于 hoist 本身的一些缺陷,这也是导致 React 废弃了 monorepo 支持的一大原因,该mr合并后被 revert。yarn 还有一种更为激进的模式,即 --flat 模式,该模式下 node_modules 里的各个 package 只允许才一个一个版本的存在,当出现版本冲突的时候,你需要自己选择指定一个版本(即通过指定在 resolution 里,强控版本),这在大型项目中显然行不通,因为第三方库里存在大量的版本冲突问题(仅 webpack 内就存在 160+ 个版本冲突),这样说明了 doopelganges 的严重性,强制指明所有版本不能解决问题。
在不考虑循环依赖的情况下,我们实际的 depdency graph 实际上某种有向无环图( DAG ),但是 npm 和 yarn 通过文件目录和 node resolve 算法模拟的实际上是有向无环图的一个超集(多出了很多错误祖先节点和兄弟节点之间的链接),这导致了很多的问题,所以我们需要个更加接近 DAG 的模拟。pnpm 正是采取了这种解决方式,通过更加精确的模拟 DAG 来解决 yarn 和 npm 代理的问题。
相比于 yarn 尽可能的将 package 放到 root level,pnpm 则是只将显式写明的 dependency 的依赖写入 root-level 的 node_modules,这避免了业务里错误的引入隐式依赖的问题,即解决了 phantom dependency 以如下例子为例
但在我们的代码里却可以使用 debug 模块,因为这是 express 引入的模块,虽然我们自己没有显式的引入
// src/index.js
const debug = require('debug')
如果有一天 express 决定将 debug 模块换成了better-debug
模块,那么我们的代码就会挂掉。
npm 的结构
pnpm 的结构
我们发现在顶层 node_modules 只有 express 模块,没有 debug 模块,因此我们无法在业务代码里错误的引入 debug,同时每一个第三方库里都有自己的 node_modules 目录,每一个 node_modules 目录都包含了自己的 depdency 的软链,这样保证在 express 里可以正确的加载debug版本。
pnpm在解决 phantom depdency 问题的同时,在此基础上也解决了 doopelganger 问题。 考虑如下代码
// package.json
{
"dependencies": {
"debug": "3",
"express": "4.0.0",
"koa": "^2.11.0"
}
}
使用 pnpm 安装相关依赖后,我们发现项目中存在 debug 的两个版本
dependencies:
debug 3.1.0
express 4.0.0
├── debug 0.8.1
├─┬ send 0.2.0
│ └── debug 1.0.5
└─┬ serve-static 1.0.1
└─┬ send 0.1.4
└── debug 1.0.5
koa 2.11.0
└── debug 3.1.0%
查看 node_modules 里的版本,我们发现区别于 yarn,pnpm 是将不同版本放在同一层级里通过软链选择加载版本,而 yarn 则是放在不同层级,依赖递归查找算法来选择版本
我们发现 pnpm 的 node_modules 里包含了三个版本,并且不同的模块分别连接到了三个版本
这样即使出现版本冲突,只需要将各个模块进行链接即可,并不需要每个模块再进行重复安装模块。 我们可以发现 pnpm 避免直接依赖 node_modules 的递归查找依赖的性质,而是直接通过软链解决了 phantom dependency 和 doppelgangers 问题。因为彻底的避免了包的重复问题,其节省了大量的空间和加快了安装速度 以一个 monorepo 项目为例
对比一下 pnpm: node_modules大小 359M,安装耗时 20s yarn: node_modules大小 1.2G,安装耗时 173s 差别非常显著
pnpm 不仅仅能保证一个项目里的所有 package 的每个版本是唯一的,甚至能保证你使得你不同的项目之间也可以公用唯一的版本(只需要公用 store 即可),这样可以极大的节省了磁盘空间。核心就在于 pnpm 不再依赖于 node 的递归向上查找 node_modules 的算法,因为该算法强依赖于 node_modules 的物理拓扑结构,这也是导致不同项目的项目难以复用 node_modules 的根源。(还有一种干法,就是使用代码的地方写死依赖的版本号,这是 deno 的干法)
实际上除了 node 的 npm,很少有其他的语言是需要每个项目都维护一个 node_modules 这种依赖(听说过其他语言有 node_modules hell 的问题吗),其他语言也很少有这种递归查找依赖的做法,所以其他语言很多都采用了全局store的管理系统。我们可以看一下 rust 是如何进行包管理的。 新建一个 rust 项目很简单,只需要运行
$ rust new hello-cargo // 创建项目,包含可执行的binary
$ rust new hello-lib --lib // 创建lib,
其生成目录结构如下
.
├── Cargo.toml
└── src
└── main.rs
其中的 Cargo.toml 和 package.json 的功能几乎一致(相比 json,tom 支持注释),包括如下一些信息
// src/main.rs
fn main() {
println!("Hello, world!");
}
其中的 dependencies 用于存放第三方依赖 cargo 的 src/main.rs 为项目的主入口,类似 index.js
// src/main.rs
fn main() {
println!("Hello, world!");
}
cargo 内置了 rust 的编译功能(相比于 js 生态里丰富的工具,cargo 内置 rustc 编译的好处是很明显,所有的第三方库只需要提供源码即可,cargo 自己完成第一方依赖的递归编译操作)
$ cargo build // 编译生成binary文件
$ cargo run // 执行binary文件
我们尝试添加一个第三方依赖看看,与 npm 类似,cargo 的 dependencies 也支持 git 协议和 file 协议。
[dependencies]
time = "0.1.12"
rand = { git = "https://github.com/rust-lang-nursery/rand.git" } // 支持git协议
执行 build 安装依赖,此时发现多了个 Cargo.lock,其类似于 yarn.lock 文件,里面包含了第三方库的及其依赖的确定性版本
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
[[package]]
name = "hello_cargo"
version = "0.1.0"
dependencies = [
"time",
]
[[package]]
name = "libc"
version = "0.2.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "99e85c08494b21a9054e7fe1374a732aeadaff3980b6990b94bfd3a70f690005"
[[package]]
name = "time"
version = "0.1.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca8a50ef2360fbd1eeb0ecd46795a87a19024eb4b53c5dc916ca1fd95fe62438"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "winapi"
version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8093091eeb260906a183e6ae1abdba2ef5ef2257a21801128899c3fc699229c6"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
此时的目录结构如下
.
├── Cargo.lock
├── Cargo.toml
├── src
└── target // 编译产物
我们发现项目里并没有类似 node_modules 存放项目所有依赖的东西。
那么他的依赖都存放到哪里了?
cargo home
cargo 将所有的第三方依赖的代码,都存放在了称为 cargo home的目录里,默认为~/.cargo
,其包含三个主要目录
/bin // 存放executable的bin文件
/git // 存放从git协议拉取的第三方库代码
/registry // 存放从registry拉取的第三方库代码
cargo本身也提供了对 monorepo的支持,和 yarn 类似,cargo 也是通过 workspace 的概念来支持 monorepo
// Cargo.tom
[workspace]
members = [
"adder",
"hardfist-add-one"
]
其几乎等价于下面的 yarn.lock
// package.json
{
"workspaces":["adder","hardfist-add-one"]
}
我们看下 workspace 的目录结构
和 yarn 类似,其公用一个 Cargo.lock 文件 我们可以通过本地 file 将各个库进行链接 假设 monorepo 里 adder 依赖 hardfist-add-one
// adder/Cargo.toml
[package]
name = "hardfist-adder"
version = "0.1.0"
authors = ["hardfist <1562502418@qq.com>"]
edition = "2018"
description = "test publish"
license = "MIT"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
hardfist-add-one = { path = "../hardfist-add-one", version="0.1.0" }
rand="0.7"
我们可以将 hardfist-add-one 通过 path 协议指向本地。 当我们需要将 adder 进行发布的时候,cargo 是不允许发布只包含 path 路径的 dependency 的,因此我们需要同时给 hard-fist-one 指明 version 用于发布。
虽然在同一个 workspace 里,如果我们的 hardfist-add-one 依赖了 rand,同时 hardfist-adder 依赖了hardfist-add-one,如果 hardfist-adder 本身没有将rand声明为其依赖,cargo 则进行报错处理
每次执行编译的时候,cargo 都会自动的递归编译其所有依赖,并不需要额外工具支持。 因为大部分的 cargo 应用都是打包成一个 binary,所以也不存在下面所述的 node 里的 monorepo 里的 vendor 的问题 cargo 同时也支持 offline 模式,支持离线安装依赖
monorepo 还存在的一个较大的问题就是如何分别部署每个 package,这在 serveless 场景下问题更为突出,因为一般 serveless 的环境对于用户的资源大小都有较大的限制。 当我们使用 monorepo 管理应用时,部署存在两个问题
针对这个问题,基本上有两种解决方式
这两种方案遇到的最大问题就是隐式依赖 bundle 对于前端应用 bundle 习以为常,但是对于服务端应用 bundle 却并不常见,实际上很多的服务端语言都是采用 bundle 的方案,如 deno、rust、go 等,上线的都是一个 bundle 文件,这个 bundle 文件可能是binary也可能是其他格式。 实际上 node 生态里即使服务端也有一些比较成熟 bundle 方案,如 github.com/zeit/ncc, 其会智能的处理将 server 端的代码 bundle 成一个 js 文件,更有甚者可以将 runtime 连同业务代码打包为一个 binary 文件,如 github.com/zeit/pkg 的方案。 服务端 bundle 存在最大的问题就是文件读写和动态导入,因为编译功能无法在编译时获取需要读写|导入文件的的信息,因此很难适用于一些约定大于配置的框架(如 egg 和 gulu),但如果是 express 和 koa 这种需要显示的写明依赖的框架,是没有问题的。
Copyright© 2013-2020
All Rights Reserved 京ICP备2023019179号-8