Ryan 對于 node.js 的十大遺憾之一就是支持了 node_modules,node_modules 的設(shè)計(jì)雖然能滿足大部分的場景钾菊,但是其仍然存在著種種缺陷辑畦,尤其在前端工程化領(lǐng)域,造成了不少的問題布朦,本文總結(jié)下其存在的一些問題囤萤,和可能的改進(jìn)方式
術(shù)語
package:包含了 package.json, 使用 package.json 定義的一個(gè) package,通常是對應(yīng)一個(gè) module是趴,也可以不包含 module涛舍,比如 bin 里指明一個(gè) shell 腳本, 甚至是任意文件(將 registry 當(dāng)做 http 服務(wù)器使用,或者利用 unpkg 當(dāng)做 cdn 使用), 一個(gè) package 可以是一個(gè) tar 包唆途,也可以是本地 file 協(xié)議富雅,甚至 git 倉庫地址
module:能被 require 加載的就叫一個(gè) module,如下都是 module肛搬,只有當(dāng) module 含有 package.json 的時(shí)候才能叫做 package,
一個(gè)包含 package.json 且含有 main 字段的文件夾
一個(gè)含有 index.js 的文件夾
任意的 js 文件
綜合: module 不一定是 package没佑,package 不一定是 module
Dependency Hell
現(xiàn)在項(xiàng)目里有兩個(gè)依賴 A 和 C,A 和 C 分別依賴 B 的不同版本温赔,如何處理
這里存在兩個(gè)問題
首先是 B 本身支持多版本共存蛤奢,只要 B 本身沒有副作用,這是很自然的陶贼,但是對于很多庫如 core-js 會(huì)污染全局環(huán)境啤贩,本身就不支持多版本共存,因此我們需要盡早的進(jìn)行報(bào)錯(cuò)提示(conflict 的 warning 和運(yùn)行時(shí)的 conflict 的 check)
如果 B 本身支持多版本共存拜秧,那么需要保證 A 正確的加載到 B v1.0 和 C 正確的加載到 B v2.0
我們重點(diǎn)考慮第二個(gè)問題
npm 解決方式
node 的解決方式是依賴的 node 加載模塊的路徑查找算法和 node_modules 的目錄結(jié)構(gòu)來配合解決的
如何從 node_modules 加載 package
核心是遞歸向上查找 node_modules 里的 package痹屹,如果在'/home/ry/projects/foo.js'文件里調(diào)用了require('bar.js'),則 Node.js 會(huì)按以下順序查找:
/home/ry/projects/node_modules/bar.js
/home/ry/node_modules/bar.js
/home/node_modules/bar.js
/node_modules/bar.js
該算法有兩個(gè)核心
優(yōu)先讀取最近的node_modules的依賴
遞歸向上查找node_modules依賴
該算法即簡化了 Dependency hell 的解決方式腹纳,也帶來了非常多的問題痢掠。
node_modules 的目錄結(jié)構(gòu)
nest mode
利用 require 先在最近的 node_module 里查找依賴的特性,我們能想到一個(gè)很簡單的方式嘲恍,直接在 node_module 維護(hù)原模塊的拓?fù)鋱D即可
這樣根據(jù) mod-a 就近的使用 mod-b 的 1.0 版本,而 mod-c 就近的使用了 mod-b 的 2.0 版本
但是這樣帶來了另一個(gè)問題雄驹,如果我們此時(shí)再依賴一個(gè) mod-d, 該 mod-d 也同時(shí)依賴的 mod-b 的 2.0 版本佃牛,這時(shí)候 node_modules 就變成下面這樣
我們發(fā)現(xiàn)這里存在個(gè)問題,雖然 mod-a 和 mod-d 依賴了同一個(gè) mod-b 的版本医舆,但是 mod-b 卻安裝了兩遍俘侠,如果你的應(yīng)用了很多的第三方庫,同時(shí)第三方庫共同依賴了一些很基礎(chǔ)的第三方庫如 lodash蔬将,你會(huì)發(fā)現(xiàn)你的 node_modules 里充滿了各種重復(fù)版本的 lodash爷速,造成了極大的空間浪費(fèi),也導(dǎo)致 npm install 很慢霞怀,這既是臭名昭著的 node_modules hell
flat mode
我們還可以利用向上遞歸查找依賴的特性惫东,將一些公共依賴放在公共的 node_module 里
根據(jù) require 的查找算法
A 和 D 首先會(huì)去自己的 node_module 里去查找 B,發(fā)現(xiàn)不存在 B,然后遞歸的向上查找廉沮,此時(shí)查找到了 B 的 v1.0 版本颓遏,符合預(yù)期
C 會(huì)先查找到自己的 node_module 里查找到了 B v2.0, 符合預(yù)期
這時(shí)我們發(fā)現(xiàn)了即解決了 depdency hell 也避免了 npm2 的 nest 模式導(dǎo)致的重復(fù)依賴問題
doppelgangers
但是問題并沒有結(jié)束,如果此時(shí)引入的 D 依賴的是 B v2.0 而引入的 E 依賴的是 B v1.0, 我們發(fā)現(xiàn)無論是把 Bv2.0 還是 Bv1.0 放在 top level, 都會(huì)導(dǎo)致另一個(gè)版本任何會(huì)存在重復(fù)的問題, 如這里的 B 的 v2.0 的重復(fù)問題
版本重復(fù)會(huì)有問題嗎滞时?
你也許會(huì)說版本重復(fù)不就是浪費(fèi)一點(diǎn)空間嗎叁幢,而且這種只有出現(xiàn)版本沖突的時(shí)候才會(huì)碰到,似乎問題不大坪稽,事實(shí)的確如此, 然而某些情況下這仍然會(huì)造成問題
全局 types 沖突
雖然各個(gè) package 之前的代碼不會(huì)相互污染曼玩,但是他們的 types 仍然可以相互影響,很多的第三方庫會(huì)修改全局的類型定義窒百,典型的就是 @types/react, 如下是一個(gè)常見的錯(cuò)誤
其錯(cuò)誤原因就在于全局的 types 形成了命名沖突黍判,因此假如版本重復(fù)可能會(huì)導(dǎo)致全局的類型錯(cuò)誤。
一般的解決方式就是自己控制包含哪些加載的 @types/xxx贝咙。
破壞單例模式
require 的緩存機(jī)制
node 會(huì)對加載的模塊進(jìn)行緩存样悟,第一次加載某個(gè)模塊后會(huì)將結(jié)果緩存下來,后續(xù)的 require 調(diào)用都返回同一結(jié)果庭猩,然而 node 的 require 的緩存并非是基于 module 名窟她,而是基于 resolve 的文件路徑的,且是大小寫敏感的蔼水,這意味著即使你代碼里看起來加載的是同一模塊的同一版本震糖,如果解析出來的路徑名不一致,那么會(huì)被視為不同的 module趴腋,如果同時(shí)對該 module 同時(shí)進(jìn)行副作用操作吊说,就會(huì)產(chǎn)生問題。
以 react-loadable 為例优炬,其同時(shí)在 browser 和 node 層使用
browser 里使用
node 層使用
然后將 browser 進(jìn)行打包編譯為 bundle.js颁井,并在 node 層加載編譯好的代碼 bundle.js
雖然 node 層和 browser 訪問的都是'react-loadable',如果 webpack 編譯的時(shí)候涉及到路徑改寫蠢护,雖然 react-loadable 的版本一致雅宾,那么會(huì)導(dǎo)致 node 和 browser 加載的不是一份 react-loadble 的導(dǎo)出對象,不幸的是 react-loadable 強(qiáng)依賴 node 和 browser 導(dǎo)出的是同一個(gè)對象葵硕。因?yàn)?node 層會(huì)讀取 browser 設(shè)置的 READY_INITIALIZERS, 如果 node 和 browser 導(dǎo)出的不是同一個(gè)對象眉抬,則導(dǎo)致讀取失敗
另一個(gè)容易出問題的地方就是使用 git submodule,git submodule 很容易造成一個(gè)環(huán)境里多版本共存懈凹,比如同時(shí)存在多個(gè) react 版本蜀变,更容易觸發(fā)問題。
Phantom dependency
我們發(fā)現(xiàn) flat mode 相比 nest mode 節(jié)省了很多的空間介评,然而也帶來了一個(gè)問題即 phantom depdency库北,考察下如下的項(xiàng)目
我們編寫如下代碼
這里的 glob 和 brace-expansion 都不在我們的 depdencies 里,但是我們開發(fā)和運(yùn)行時(shí)都可以正常工作(因?yàn)檫@個(gè)是 rimraf 的依賴),一旦將該庫發(fā)布贤惯,因?yàn)橛脩舭惭b我們的庫的時(shí)候并不會(huì)安裝庫的 devDepdency洼专,這導(dǎo)致在用戶的地方會(huì)運(yùn)行報(bào)錯(cuò)。
我們把一個(gè)庫使用了不屬于其 depdencies 里的 package 稱之為 phantom depdencies孵构,phantom depdencies 不僅會(huì)存在庫里屁商,當(dāng)我們使用 monorepo 管理項(xiàng)目的情況下,問題更加嚴(yán)重颈墅,一個(gè) package 不但可能引入 DevDependency 引入的 phantom 依賴蜡镶,更很有可能引入其他 package 的依賴,當(dāng)我們部署項(xiàng)目或者運(yùn)行項(xiàng)目的時(shí)候就可能出問題恤筛。
在基于 yarn 或者 npm 的 node_modules 的結(jié)構(gòu)下官还,doppelganger 和 phantom dependency 似乎并沒有太好的解決方式。其本質(zhì)是因?yàn)?npm 和 yarn 通過 node resolve 算法 配合 node_modules 的樹形結(jié)構(gòu)對原本 depdency graph 的模擬毒坛,哪有沒有更好的模擬方式能夠避免上述問題呢望伦。
Semver 當(dāng)理想遇到現(xiàn)實(shí)
npm 對 package 版本號(hào)采用語義化版本,Semver 本身也是為了解決 Depdency Hell 而引入的解決方案煎殷,如果你的項(xiàng)目引入的第三方依賴越來越多屯伞,你將會(huì)面臨一個(gè)困境
如果你為你的每一個(gè)版本都寫死依賴,那么如果某個(gè)底層的依賴需要修復(fù)或者升級(jí)豪直,你難以評(píng)估這個(gè)升級(jí)會(huì)修復(fù)的影響范圍劣摇,這可能導(dǎo)致級(jí)聯(lián)反應(yīng),與其協(xié)作的任何包都可能會(huì)掛掉弓乙,導(dǎo)致整個(gè)系統(tǒng)都需要全量的測試回歸末融,最后的結(jié)果很大可能是整個(gè)應(yīng)用徹底鎖死版本,再也不敢做任何升級(jí)改動(dòng)
因此 semver 的提出主要是用于控制每個(gè) package 的影響范圍暇韧,能夠?qū)崿F(xiàn)系統(tǒng)的平滑升級(jí)和過渡勾习,npm 每次安裝都會(huì)按照 semver 的限制,安裝最新的符合約束的依賴懈玻。
這樣每次 npm install 都會(huì)安裝符合 "^4.0.0" 約束的最新依賴语卤,可能是 4.42.0 的版本。
如果所有的庫都能完美的遵守語義化版本酪刀,那么世界和平,然而現(xiàn)實(shí)是很多庫因?yàn)榉N種原因并未遵守 semver钮孵,原因包括
不可預(yù)知的 bug骂倘,本來以為某個(gè)版本只是 bugfix,發(fā)布了 patch 版本巴席,但是該 patch 卻引入了未預(yù)料的 breaking change 導(dǎo)致 semver 被破壞
semver 的設(shè)計(jì)過于理想历涝,實(shí)際上即使是最小的 bugfix,如果業(yè)務(wù)方無意中依賴了這個(gè) bug,仍然會(huì)導(dǎo)致 breaking change荧库,bug 和 breaking change 的界限是模糊的