聊一聊 NPM 依賴管理的復(fù)雜性
這是一個很少被提及的話題 —— 「依賴管理」(Dependencies Management) 。
在開源文化盛行的現(xiàn)代,多數(shù)時候我們都不必從零開始搭建一套軟件系統(tǒng),轉(zhuǎn)而可以借助諸多開放的代碼片段及其他資源更快速高效開發(fā)軟件應(yīng)用,這算的上軟件工程發(fā)展史上一次巨大革命,因為它能大幅提升軟件工業(yè)的生產(chǎn)效率,我們不必再從底層開始編寫所有代碼,大部分問題與常見的編程模式都能在社區(qū)找到相應(yīng)的解決方案,且這些被反復(fù)消費錘煉的軟件包通常有更高的穩(wěn)定性與性能,你需要做的只是花一些時間了解學(xué)習(xí)這些開源資源,并在項目使用它們,“ 「依賴」 ”它們即可,這已經(jīng)是一種被不斷實踐,不斷被驗證為行之有效的開發(fā)模式。
但現(xiàn)實比預(yù)想的復(fù)雜許多,如果你開發(fā)的只是規(guī)模較小,或生命周期非常短的項目時,依賴的狀態(tài)并不會造成多大問題,你只要確保當(dāng)下執(zhí)行 ok,功能符合預(yù)期即可。但,任何軟件項目一旦疊加“「規(guī)模」”與“「時間」”兩個變量之后,依賴網(wǎng)絡(luò)就很容易變得復(fù)雜混亂,如果不及時施加恰當(dāng)?shù)墓芾硎侄蝿t遲早會引發(fā)諸多晦澀難懂的穩(wěn)定性、性能、安全等諸多方面的問題。因此,我們需要學(xué)習(xí)了解“依賴管理”的基本含義與潛在風(fēng)險,需要掌握一些軟件工程管理方面的方式方法,確保依賴網(wǎng)絡(luò)及其隨時間發(fā)生的變化都在可控范圍內(nèi),讓依賴網(wǎng)絡(luò)盡可能保持一個清晰有條理,且具備一定程度的健壯性。
在展開具體內(nèi)容之前,我們先明確一下“依賴”這個概念,嚴格來說,你的代碼所需要消費的任何直接與間接資源都歸屬于“依賴”范疇,往小了說,包括系統(tǒng)時間、語言特性(如事件循環(huán)、閉包)、執(zhí)行環(huán)境(如瀏覽器接口、node 接口)等;往大了說還包括:操作系統(tǒng)、網(wǎng)絡(luò),甚至硬件設(shè)備(如 GPU),但這些并不在本文討論范圍內(nèi),誠然這些要素也可能帶來性能、安全性各方面的影響,但多數(shù)屬于基礎(chǔ)設(shè)施,穩(wěn)定性還是比較有保障的。
本文希望更聚焦討論 Node 場景下的依賴 —— 或者更直觀的說是 NPM Package 結(jié)構(gòu)的不穩(wěn)定性所帶來的被嚴重低估的質(zhì)量風(fēng)險,以及相應(yīng)的應(yīng)對策略。
第三方依賴帶來的問題
09 年 NodeJS + NPM 的出現(xiàn),不僅讓 JavaScript 擁有了脫離瀏覽器環(huán)境執(zhí)行的能力,也帶來一套相對體系化的依賴管理方案,在此之前的依賴管理多數(shù)由“人”手工完成,需要用到什么就手動 copy 代碼進倉庫,或者 copy cdn 鏈接到 HTML 頁面。
而 NPM(Node Package Manager) 讓這件事情 「盡可能」 做到了自動化,我們只需要執(zhí)行 npm install 命令即可自動完成下述工作:
- 解析依賴樹:根據(jù)項目 package.json 文件中的依賴項列表,遞歸檢查每個依賴項及子依賴項的名稱和版本要求,構(gòu)建出依賴樹并計算每一個依賴需要安裝的確切版本(這個并不容易做到,參考:Version SAT);
- 參考:https://research.swtch.com/version-sat
- 下載依賴項:構(gòu)建出完整的依賴樹后,npm 會根據(jù)依賴項的名稱和版本,下載相應(yīng)的依賴包,下載過程還會對依賴包做一系列安全檢查,防止被篡改;
- 安裝依賴項:當(dāng)依賴項下載完成后,npm 將它們安裝到項目的
node_modules
目錄中。它會在該目錄下創(chuàng)建一個與依賴項名稱相對應(yīng)的文件夾,并將軟件包的文件和目錄解壓復(fù)制到相應(yīng)的位置(不同包管理器最終產(chǎn)出的包結(jié)構(gòu)不同); - 解決依賴沖突:在安裝依賴項的過程中,可能會出現(xiàn)依賴沖突,即不同依賴項對同一軟件包的版本有不同的要求。npm 會嘗試解決這些沖突,通常采用版本回退或更新來滿足所有依賴項的要求;
- 更新 package-lock.json:在安裝完成后,npm 會更新項目目錄下的
package-lock.json
文件。該文件記錄了實際安裝的軟件包和版本信息,以及確切的依賴關(guān)系樹,可用于確保在后續(xù)安裝過程中保持一致的依賴項狀態(tài)(npm ci
);
PS: 本文僅以 NPM 舉例,yarn、pnpm 的執(zhí)行算法雖差異較大,但整體遵循上述過程,因此不再贅述。
相比于過往人工管理的各種低效且容易出錯的騷操作,NPM 這類包管理器能以極低的成本,更規(guī)范化、自動化完成依賴包的檢索、安裝、更新等管理動作,更容易搭建出一個相對穩(wěn)定且安全可靠的工程環(huán)境,也更容易復(fù)用外部那些經(jīng)過良好封裝、充分測試的代碼片段。
But,伴隨著工程能力的提升,依賴之間的復(fù)雜度也在急劇增長,當(dāng)前我們正面臨著更多依賴管理相關(guān)的工程問題,例如:幽靈依賴、版本沖突、依賴地獄等等,這些問題很少被討論卻時時刻刻影響著工程項目的穩(wěn)定性、開發(fā)效率、性能等要素,接下來我會盡可能完整討論依賴管理的方方面面,幫助大家更深入了解這些潛藏在日常開發(fā)之下很少被察覺的各類問題,并討論相關(guān)的應(yīng)對方案。
依賴管理潛在的問題
1. semver 并不穩(wěn)定
先從依賴管理中最淺顯直觀的視角講起,當(dāng)我們決定使用某一個 NPM 包時,需要做的第一件事就是在項目 package.json 文件中定義 dependencies ,類似于:
{
"name": "foo",
"dependencies": {
"lodash": "^1.0.0"
}
}
這似乎已經(jīng)是一種簡單而自然,不需要過多討論的常識,but,我們應(yīng)該依賴于 Package 的那些版本呢?
答案取決于具體的功能需求、穩(wěn)定性、性能等諸多因素,但一個大致通用的實踐是:「盡可能使用最新版本的范圍版本」,例如假定 React 最新版本為 18.2.0,在項目中可以聲明依賴為 "react": "^18.2.0",這種方法一方面能夠應(yīng)用最新版本 —— 這可能意味著更多的功能,以及更好的性能等;另一方面,借助 ^ 聲明該依賴接受 >= 18.2.0 < 19 的版本范圍,在 React 下次發(fā)布 18.2.1 或更大版本時都能自動匹配應(yīng)用,以此獲得一定范圍內(nèi)動態(tài)更新依賴的能力。
PS:補充一個知識點,當(dāng)前多數(shù)框架都遵循 semver 版本號(https://semver.org/)規(guī)則,即包含 Major.Minor.Patch 三段版本號,Major 代表較大范圍的功能迭代,通常意味著破壞性更新;Minor 代表小版本迭代,可能帶來若干新接口但“承諾”向后兼容;Patch 代表補丁版本,通常意味著沒有明顯的接口變化。
這看似很完美,但實踐卻漏洞百出。首先,部分 NPM 包作者并沒有嚴格遵守 semver 定義的規(guī)則迭代版本號,特別是許多公司內(nèi)部依賴的版本管理更是混亂不堪,Patch 可能破壞原本的接口定義(多一個參數(shù)少一個參數(shù)),Minor 可能導(dǎo)致向后不兼容,等等,致使舊代碼無法正常執(zhí)行。
其次,即使完全按照 semver 語義嚴格管理版本號,誰又能保證每次版本迭代都能完美符合用戶預(yù)期呢?例如按照 semver 語義,Patch 只用作 bug 修復(fù),但難保會在一些邊界情況發(fā)生變化,比如:「日志」,講道理用戶不應(yīng)該直接依賴代碼包的日志輸出,但可能有一些輸入輸出沒有覆蓋用戶需求,或者用戶沒有了解到正確的使用方式,致使消費者傾向于直接從運行日志解讀信息(只要用戶體量足夠大,總會出現(xiàn)一些意料之外的使用方法),若此時 Patch 版本更改了日志內(nèi)容 —— 這看似很合理,卻可能導(dǎo)致日志解析失敗。從這個示例來說,日志算不算補丁更新呢?這種情況下,Patch 還是安全的嗎?
那么,能不能放棄范圍版本,寫死版本號呢?例如上例中只要把依賴關(guān)系寫死成 "react": "18.2.0" 似乎就能規(guī)避版本變化帶來的不確定性?某種程度上確實如此,但這又會帶來新的風(fēng)險:版本累積可能帶來更大的破壞性更新!我們必須承認一個事實:無論你有多強的惰性,「軟件項目只要存活的時間足夠長,就總會有一天需要升級依賴」,升級的主因可能是:安全、合規(guī)、性能、架構(gòu)調(diào)整等等,如果你從一開始就在使用某個固定版本,直到不得不更新的時刻到來時,新版本的使用方案、功能表現(xiàn)等可能都已經(jīng)發(fā)生了劇變(例如,從 React 17 => 18),很可能會導(dǎo)致你原本運行良好的程序漏洞百出,質(zhì)量風(fēng)險、回歸成本都很高。
因此,「良好的依賴管理策略應(yīng)該在保證穩(wěn)定的前提下,定期跟進依賴包的更新」,小步快進將升級風(fēng)險分攤到每一次小版本迭代中,為達成這一效果,一個比較 「常見」 的實踐是在開發(fā)環(huán)境中使用適當(dāng)?shù)姆秶姹?,在測試 & 生產(chǎn)環(huán)境使用固定版本,以 NPM 為例,可以繼續(xù)沿用 "react": "^18.2.0",在開發(fā)態(tài)中使用 npm install 安裝依賴,在測試 & 生產(chǎn)環(huán)境則使用 npm ci 命令,兩者區(qū)別在于 npm install 會嘗試更新依賴,觸發(fā)依賴結(jié)構(gòu)樹變化并記錄到 package-lock.json 文件;而 npm ci 則嚴格按照 package-lock.json 內(nèi)容準確安裝各個依賴版本,在 CI/CD 環(huán)境中能獲得更強的穩(wěn)定性,確保代碼行為與開發(fā)環(huán)境盡可能一致。
2. 依賴類型
在確定依賴版本之后,接下來需要決定將依賴注冊到那個 dependencies 節(jié)點,按 package.json 規(guī)則,可選類型有:
- dependencies:生產(chǎn)依賴,指在軟件包執(zhí)行時必需的依賴項。這些依賴項是你的應(yīng)用程序或模塊的核心組成部分,當(dāng)你部署到生產(chǎn)或測試環(huán)境時,這些依賴項都需要被安裝消費;
- devDependencies:開發(fā)依賴,僅在開發(fā)過程中需要使用的依賴項,通常包括測試框架、構(gòu)建工具、代碼檢查器、TS 類型庫等。開發(fā)依賴項不需要在生產(chǎn)環(huán)境安裝;
- peerDependencies:對等依賴,用于指定當(dāng)前 package 希望宿主環(huán)境提供的依賴,這解釋有點繞,下面我們會展開解釋;
- optionalDependencies:可選依賴,當(dāng)滿足特定條件時可以選擇性安裝的依賴,且即使安裝失敗,安裝命令也不會中斷??蛇x依賴項通常用于提供額外的功能或優(yōu)化,并不是必需的;
- bundledDependencies:捆綁依賴,用于指定需要一同打包發(fā)布的依賴項,用的比較少。
根據(jù)我們正在開發(fā)的軟件包的用途及對依賴的使用方式,這里會有不同的決策邏輯。
假設(shè)正在開發(fā)的是“頂層”應(yīng)用(Web APP、Service、CLI 等),那么多數(shù)依賴都可以注冊到 devDependencies 或 dependencies 節(jié)點,這也是我們?nèi)粘?yīng)用比較多的依賴類型。兩者主要差異在于:dependencies 是生產(chǎn)環(huán)境依賴,是確保軟件包正常運行的必要依賴項;而 devDependencies 則是僅在開發(fā)階段需要使用的依賴項。
舉個例子,假設(shè)你應(yīng)用邏輯中直接使用了 lodash 的方法,那么 lodash 必然是 dependencies;但假設(shè)你只是在一些構(gòu)建腳本之類的非應(yīng)用邏輯中使用了 lodash ,那么應(yīng)該將其注冊到 devDependencies 中。
PS:對于需要將代碼和依賴全部打包在一起的應(yīng)用 —— 例如常見的基于 Webpack 的 web 應(yīng)用,從功效上 dependencies 與 devDependencies 并無差別,但建議還是根據(jù)語義對依賴做好分類管理。
換個視角,假設(shè)正在編寫的代碼最終會被發(fā)布成 NPM Package 供其他方消費,那么我們必須慎重許多,因為你的決策會深刻影響消費者的使用體驗。首先,你必須非常謹慎地使用 dependencies,因為 NPM 在安裝你這個 Package 會順帶將你的 package.json 中的 dependencies 也都安裝一遍,錯誤的依賴分類可能會帶來一些影響開發(fā)體驗的 Bad Case:
- 需要占用更多的安裝依賴的時間;
- 依賴結(jié)構(gòu)更復(fù)雜,容易導(dǎo)致“菱形依賴”(后面會會展開解釋)問題;
舉個例子,@vue/cli 的 package.json 部分內(nèi)容如下:
{
"name": "@vue/cli",
"version": "5.0.8",
...
"dependencies": {
...
"vue": "^2.6.14",
...
},
...
}
那么使用者安裝 @vue/cli 之后,還會強制安裝 vue@^2.6.14 版本 —— 即使用戶消費的可能是其他 Vue 版本,這種行為無疑都會給用戶增加不必要的負擔(dān),因此,在開發(fā) Package 時,除非有非常明確且強烈的訴求,否則都應(yīng)該優(yōu)先使用 devDependencies!
那么,假設(shè)你的 Package 確實存在一些必要,但又不適合注冊到 dependencies 的依賴,該怎么辦呢?這種 Case 也非常常見,例如 Webpack 插件通常對 Webpack 存在強依賴,但并不適合直接使用 dependencies,否則可能導(dǎo)致用戶安裝多份 Webpack 副本。針對這種情況 NPM 提供了另外一種依賴類型:peerDependencies,語義上可以理解為:Package 希望宿主環(huán)境提供的“對等”依賴,NPM 對這種類型的處理邏輯稍微有點復(fù)雜:
若宿主提供了對等依賴聲明(無論是 dependencies 還是 devDependencies),則優(yōu)先使用宿主版本,若版本沖突則報出警告:
若宿主未提供對等依賴,則嘗試自動安裝對應(yīng)依賴版本(NPM 7.0 之后支持)。
PS:正是因為 peerDependencies 的復(fù)雜性,不同包管理器,甚至同一包管理器的不同版本對其處理邏輯都有所不同,例如 NPM 在 3.0 之前支持自動安裝 peerDependencies,但這一特性帶來的問題比較多,3.0 之后取消了自動下載,交由消費者自行維護,一直到 7.0 版本設(shè)計了一種更高效的依賴推算算法之后,才又重新引入這一特性。
peerDependencies 能幫助我們實現(xiàn):“「即要」”確保 Package 能正常運行,“「又要」”避免給用戶帶來額外的依賴結(jié)構(gòu)復(fù)雜性,在開發(fā) NPM Package,特別是一些“框架”插件、組件時可以多加使用,實踐中通常還會:
- 使用 peerDependencies 聲明 Wepack 為對等依賴,要求宿主環(huán)境安裝對應(yīng)依賴副本。
- 同時使用 devDependencies 聲明 Wepack 為開發(fā)依賴,確保開發(fā)過程中能正確安裝必要依賴項。
接下來聊一個相對冷門的類型:optionalDependencies,也就是“可選”依賴,雖然多數(shù)時候我們對 Package 的依賴應(yīng)該是比較明確的:要么有要么沒有,但某些特定場景下也可能是“可以有也可以沒有”。
舉個例子,fsevents 是一個針對 「Mac OSX」 系統(tǒng)的文件系統(tǒng)事件監(jiān)控庫 —— 注意啊,它只適用于 「Mac OSX」 系統(tǒng),因此在其他操作系統(tǒng)上都不能使用 —— 自自然然的也不需要安裝這個 Package,因此可以是一個“可選”依賴,實際上在知名構(gòu)建工具 rollup 中就是以 optionalDependencies 方式引入 fsevents 的:
{
"name": "rollup",
"version": "4.1.4",
// ...
"optionalDependencies": {
"fsevents": "~2.3.2"
},
// ...
}
需要注意,optionalDependencies 意味著“可能有也可能沒有”,因此消費方式上也需要加以區(qū)分,例如 rollup 是這么導(dǎo)入 fsevents 的:
import type FsEvents from 'fsevents';
export async function loadFsEvents(): Promise<void> {
try {
// 使用 `import` 函數(shù)異步導(dǎo)入,并做好異常判斷
({ default: fsEvents } = await import('fsevents'));
} catch (error: any) {
fsEventsImportError = error;
}
}
// ...
代碼位置:rollup/src/watch/fsevents-importer.ts
optionalDependencies 非常適合用作處理“平臺”強相關(guān)的依賴,除此之外還可用于性能兜底、交互功能兜底等場景,這里就不一一贅述了。
簡單總結(jié)下,package.json 提供了若干影響安裝行為的依賴類型屬性,以應(yīng)對不同場景的管理需求,開發(fā)者需要基于性能、可用性、穩(wěn)定性等角度考慮謹慎判斷依賴類型。當(dāng)然,也有一些基本規(guī)則能幫助我們快速識別依賴類型,包括:
- 常見的各類工程化工具,如 eslint、vitest、vite、jest、webpack 等等都適合放在 devDependencies。
- 各類 TS 類型包,例如 @types/react、@types/react-dom 一般也可以放在 devDependencies 中。
- 開發(fā)框架插件時,盡可能將框架聲明為 peerDependencies,例如 webpack 與 cache-loader。
- 平臺強相關(guān)的依賴,可以考慮使用 optionalDependencies,之后配合 postinstall 鉤子執(zhí)行平臺相關(guān)的依賴安裝 or 編譯動作。
- 等等。
3. 失控的依賴結(jié)構(gòu)
思考一下:「安裝某個依賴時,需要附帶安裝多少子孫依賴」?很多同學(xué)此前可能沒關(guān)注過這一塊,這個問題并沒有具體的通用答案,取決于你實際安裝的包,但這個數(shù)量通常都不會很小。舉個例子,知名的 React 組件庫 antd 的依賴結(jié)構(gòu)是這樣的:
這張圖肉眼可見的復(fù)雜。。。一旦我們決定使用 antd 則必須引入這一坨復(fù)雜的依賴結(jié)構(gòu),而這并不是孤例,不少知名框架都有類似問題,包括 jest、webpack、http-parser 等等,當(dāng)我們依賴這些 Package 時,依賴結(jié)構(gòu)最終會合并成一張龐大、復(fù)雜,且沖突不斷的網(wǎng)絡(luò)。
造成這一現(xiàn)象的原因其實不難理解,在當(dāng)下開源文化環(huán)境下,跨組織的代碼共享變得如此簡單平常,即使是非常小的代碼片段都可以以極低的成本貢獻到社區(qū)供人使用。舉個例子,在 NPM 上有一個這么一個 Package:escape-string-regexp,它的核心代碼算上注釋才不到十行,但周下載量達到驚人的一億次:
export default function escapeStringRegexp(string) {
if (typeof string !== 'string') {
throw new TypeError('Expected a string');
}
// Escape characters with special meaning either inside or outside character sets.// Use a simple backslash escape when it’s always valid, and a `\xnn` escape when the simpler form would be disallowed by Unicode patterns’ stricter grammar.return string
.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&')
.replace(/-/g, '\\x2d');
}
在此背景下,多數(shù)時候 —— 包括開發(fā) Package 時,多數(shù)開發(fā)者在解決特定需求時自然都會傾向于使用開源代碼片段,這可以幫助作者跳過代碼「設(shè)計、開發(fā)、測試、調(diào)試、維護」等步驟,進而提升開發(fā)效率。
這本是一種良好實踐,但當(dāng)它被廣泛采用時,不可避免的會帶來一個副作用:依賴粒度變得非常細小,依賴網(wǎng)絡(luò)結(jié)構(gòu)變得無比復(fù)雜龐大,而這又容易(或者說必然)觸發(fā)更多負面效應(yīng),包括:
- 需要計算依賴包之間的關(guān)系并下載大量依賴包,CPU 與 IO 占用都非常高,導(dǎo)致項目初始化與更新性能都比較差,我就曾經(jīng)歷過初始 yarn install 需要跑兩個小時,加一個依賴需要跑半個小時的巨石項目。。。開發(fā)體驗一言難盡;
- 多個 Package 的依賴網(wǎng)絡(luò)可能存在版本沖突,輕則導(dǎo)致重復(fù)安裝,或重復(fù)打包,嚴重時可能導(dǎo)致 Package 執(zhí)行邏輯與預(yù)期不符,引入一些非常難以定位的 bug,這個問題比較隱晦卻重要,后面我們還會展開細講;
- 由于可能存在大量沖突,項目的依賴網(wǎng)絡(luò)可能變得非常脆弱,某些邊緣節(jié)點的微小變化可能觸發(fā)依賴鏈條上層大量 Package 的版本發(fā)生變化,引起雪崩效應(yīng),進而影響軟件最終執(zhí)行效果,這同樣可能引入一些隱晦的 bug;
- 等等吧。
那么,如何應(yīng)對這些問題呢?先說結(jié)論:沒有一勞永逸完美方案,只能盡力降低問題出現(xiàn)的范圍和影響。首先我們不應(yīng)該因噎廢食,即使存在上述問題,依賴外置更有助于提升模塊之間的低耦合高內(nèi)聚,保持更佳的可維護性,在此基礎(chǔ)上,可以適當(dāng)引入一些管理措施緩解癥狀,包括:
- 設(shè)定更嚴格的開源包審核規(guī)則:除了周下載量、Star 數(shù)這些指標外,可以適當(dāng)打開倉庫看看代碼結(jié)構(gòu)是否合理,是否有單測,單測覆蓋率多少,是否能通過單測,Issue 持續(xù)時間,二級依賴網(wǎng)絡(luò)結(jié)構(gòu)是否合理等等,確保依賴的質(zhì)量是穩(wěn)定可信賴的;
- 盡可能減少不必要的依賴:在引入第三方庫時,仔細審查其功能,看看是否真的需要使用整個庫,或者我們僅需要其中的部分功能,有時我們可能自己實現(xiàn)(甚至 Copy)這些功能更為快速、更為簡單,同時也減少了對第三方庫的依賴;
- 分層依賴:如果項目較大(monorepo?),可以將項目分層,每一層只能依賴相同層級或更基礎(chǔ)的層級的庫,這樣可以降低各層之間的相互依賴,也有助于分層級管理依賴結(jié)構(gòu),減小變動對上游的影響;
- 避免循環(huán)依賴:循環(huán)依賴絕對是一種可怕的災(zāi)難!它不僅會急劇提升依賴網(wǎng)絡(luò)的結(jié)構(gòu)復(fù)雜度,還很可能導(dǎo)致一些難以預(yù)料的問題,因此在做依賴結(jié)構(gòu)審計時務(wù)必盡可能規(guī)避這類情況。
4. 幽靈依賴
“幽靈依賴”是指我們明明沒有在 package.json 中注冊聲明某個依賴包,卻能在代碼中引用消費該 Package,之所以出現(xiàn)這個問題,歸根到底主要是兩個因素引起的:
- NodeJS 的模塊尋址邏輯;
- 包管理器執(zhí)行 install 命令后,安裝下來的 node_modules 文件目錄結(jié)構(gòu)。
眾所周知(吧?),在 NodeJS 以及 Webpack、Babel 等常見工程化工具中,當(dāng)我們使用 require/import 導(dǎo)入外部依賴包時,NodeJS 會首先嘗試在當(dāng)前目錄下的 node_modules 尋找同名模塊,若未找到則沿著目錄結(jié)構(gòu)逐級向上遞歸查找 node_modules 直至系統(tǒng)根目錄,例如在 /home/user/project/foo.js 文件中查找模塊時,可能會在如下目錄嘗試尋找 Package:
/home/user/project/node_module
/home/user/node_module
/home/node_module
/node_module
若此時某些 Package 被安裝在項目 project 路徑的上層,則必然會被尋址邏輯命中,導(dǎo)致代碼中能夠“錯誤”引用到這個包。其次,即使不考慮這個目錄遞歸尋址邏輯,NPM 與 Yarn 的扁平化 node_modules 結(jié)構(gòu)也非常容易引起幽靈依賴問題。這里補充點歷史知識,在 NPM@3 之前,每個模塊的依賴項都會被放置在自己專屬的 node_modules 文件夾內(nèi),即所謂的"「嵌套依賴」",例如:
- 依賴結(jié)構(gòu):
- A
- B
- C
- D
- C
- node_module 結(jié)構(gòu):
- node_modules
- A
- node_modules
- B
- node_modules
- C
- D
- node_modules
- C
這種方案非常容易導(dǎo)致依賴結(jié)構(gòu)深度過大,最終可能導(dǎo)致文件路徑超過了一些系統(tǒng)的最大文件路徑長度限制(主要是Windows 系統(tǒng)),導(dǎo)致奔潰。這就引入 NPM@3 的優(yōu)化策略:扁平化依賴結(jié)構(gòu),也就是將所有的模塊 —— 無論是頂層依賴還是子依賴,都會直接寫入到在項目頂層的 node_modules 目錄中,例如:
- 依賴結(jié)構(gòu):
- A
- B
- C
- D
- C
- node_module 結(jié)構(gòu):
- node_modules
- A
- B
- C
- D
這種目錄結(jié)構(gòu)看起來更簡潔清晰,也確實解決了目錄過深的問題。「但是」,根據(jù) NodeJS 的尋址邏輯,這也就意味著我們可以引用到任意子孫依賴!這種不明確的依賴關(guān)系是非常不穩(wěn)定的,可能觸發(fā)很多問題:
- 不一致性:幽靈依賴可能導(dǎo)致應(yīng)用程序的行為在不同的環(huán)境中表現(xiàn)不一致,因為不同環(huán)境中可能缺少或包含不同版本的幽靈依賴;
- 不可預(yù)測性:本質(zhì)上,幽靈依賴的是頂層依賴的依賴網(wǎng)絡(luò)的一部分,你很難精細控制這些子孫依賴的版本,完全隨緣;
- 難以維護:若你的代碼中存在幽靈依賴,在依賴庫升級或遷移時,幽靈依賴可能導(dǎo)致意外的兼容性問題或升級困難。
那么如何解決幽靈依賴問題呢?其實也比較簡單,核心準則:請務(wù)必確保依賴關(guān)系是清晰明確的,一旦消費則必須在項目工程內(nèi)注冊依賴!有許多工具能幫我們達成這一點:
- 使用 pnpm:與 yarn、npm 不同,pnpm 不是簡單的扁平化結(jié)構(gòu),而是使用符號鏈接將物理存儲的依賴鏈接到項目的 node_modules 目錄,確保每個項目只能訪問在其 package.json 中明確聲明的依賴;
- 使用 ESLint:ESLint 提供了不少規(guī)則用于檢測幽靈依賴,例如 import/no-extraneous-dependencies,只需要在項目中啟用即可;
- 使用 depcheck:這是一個用于檢測未使用的或缺失的 npm 包依賴,可以協(xié)助發(fā)現(xiàn)現(xiàn)存代碼可能存在的幽靈依賴,類似的還有:npm-check 等。
5. 依賴沖突
依賴沖突通常發(fā)生在兩個或多個包依賴不同版本的同一庫時。設(shè)想這樣一個場景:包 app 依賴了 lib-a、lib-b,而 lib-a、lib-b 又依賴了 lib-d,此時這幾個實體之間之間形成了一種菱形依賴關(guān)系:
圖解:菱形依賴
ok,菱形依賴本身是一種非常常見且合理的依賴結(jié)構(gòu),這不是問題,真正的問題出現(xiàn)在若此時 lib-a/lib-b 所依賴的 lib-d 版本不一致時,就會產(chǎn)生依賴沖突現(xiàn)象:
圖解:依賴沖突
而這輕則導(dǎo)致 lib-d 被重復(fù)安裝;嚴重時可能導(dǎo)致如構(gòu)建失敗、應(yīng)用運行錯誤(例如 bundle 中同時存在兩個 react 實例)等問題。其次,更大的隱患在于,依賴沖突會使得依賴網(wǎng)絡(luò)的復(fù)雜度進一步提升惡化,降低項目的可維護性和擴展性,長期難以維護。
圖解:進一步劣化的結(jié)構(gòu)
比較難受的是,依賴沖突問題多數(shù)時候出現(xiàn)在次級依賴中,我們通常無法細粒度地管控好這些底層依賴,悲觀地說,我們還無法從根本上解決這些問題,只能采取一些手段盡可能緩解:
- 打包構(gòu)建時,可以借助 webpack alias 之類的手段,強制指定版本包位置。
- 可以借助 package.json 的 resolution 字段強制綁定版本號。
- 必要時,借助 patch-package 或 pnpm patch 對依賴包做微調(diào)。
6. 循環(huán)依賴
循環(huán)依賴是指兩個或多個 Package 之間相互依賴,形成鏈式閉環(huán)的情況。這種循環(huán)結(jié)構(gòu)可能很明顯也可能很隱蔽,但總之在依賴鏈條上形成了一個環(huán)狀的結(jié)構(gòu)關(guān)系。
循環(huán)依賴的問題在于,它會使得依賴關(guān)系變得非常復(fù)雜 —— 從有向無環(huán)到更復(fù)雜的有向有環(huán)圖,這會增加依賴網(wǎng)絡(luò)解析成本,包管理器通常需要為此編寫復(fù)雜的循環(huán)依賴安裝算法;也會增加“開發(fā)者”的理解成本 —— 而這必然也會進一步降低項目的可維護性。
其次,循環(huán)依賴的更新邏輯也會變得特別啰嗦,假設(shè)存在 A=>B=>C=>A 這樣的循環(huán)依賴鏈條,那么 B 的更新可能會導(dǎo)致 C/A 需要同步更新,整體結(jié)構(gòu)的穩(wěn)定性變得非常脆弱。
7. 依賴更新鏈路長
設(shè)想一個場景,存在依賴鏈條:A => B => C => D
,若底層 D 包發(fā)布了一個新版本(比如修復(fù)了一個重要的安全問題),那么有時候可能需要鏈條上的 B 與 C 包都隨之更新版本之后,A 才能得到相應(yīng)更新。關(guān)鍵問題在于,中間節(jié)點越多,完成更新所需要的時間往往越長,如果中間某些節(jié)點的更新活躍度并不高的時候,延遲問題必然會更嚴重,這些風(fēng)險點最終都會嫁接到頂層 A 包身上。
當(dāng)然,當(dāng)下的開源依賴包也并沒有如上述設(shè)想的那般脆弱,質(zhì)量“良好”的開源 Package 往往有較強的容錯性,對底層的依賴往往也會優(yōu)先遵循 semver 的范圍版本規(guī)則。但“閉源”軟件包通常就沒這么高的質(zhì)量要求了,可能會設(shè)置一些拙劣的兼容策略,甚至為了避免向前向后兼容的麻煩,直接“鎖死”核心依賴版本,導(dǎo)致底層包出現(xiàn)問題時,頂層依賴可能難以得到更新。
8. 大型應(yīng)用中的依賴更新
設(shè)想我們正在維護代碼總量超過 10w 行且持續(xù)迭代的一個大型應(yīng)用,若此時需要對某些基礎(chǔ)依賴做比較大的版本升級,那么你所面臨工作量與復(fù)雜度都會非常高。
首先,你需要細致地梳理出新舊版本之間的接口、行為差異,這一步需要做許多調(diào)研工作,甚至可能需要仔細比對兩個版本源碼之間的區(qū)別;其次,按照這些差異點對 10w 行代碼都做一次更新適配,以使得代碼在新版本中能夠正常運行,某些命中 Breaking change 的地方可能還需要重新設(shè)計實現(xiàn)方案。
這個過程隱含著非常大的開發(fā)與測試的工作量,通常需要持續(xù)投入一段時間做開發(fā),但問題是業(yè)務(wù)本身還在持續(xù)迭代,不可能把所有事情停下來等著你慢慢把版本升上去;也通常,這件事情很難僅僅通過“增加人力”就能提高執(zhí)行效率,因為改造過程隨時可能出現(xiàn)一些始料未及的新問題,需要有足夠技術(shù)功力的人才能高效做出新的判斷與決策。
應(yīng)對這些問題,一個 「理所當(dāng)然」 的解決方案是 Case by case 地設(shè)計一些技術(shù)方案來實現(xiàn)漸進式代碼升級,例如在微前端場景中可以通過子應(yīng)用方式,將頁面與模塊逐個遷移到新的依賴版本,直至整體升級完畢;此外,也可以適當(dāng)設(shè)計一些接口適配器,盡可能減少直接改動頂層代碼。
其次,對于一些工程能力比較強的團隊,推薦引入一些 E2E 技術(shù)(彩蛋:為什么這里不是 UT?)并持續(xù)維護一套至少覆蓋核心鏈路的測試用例,發(fā)生變更時由自動化測試技術(shù)確保應(yīng)用狀態(tài)符合功能預(yù)期。這是一種一本萬利的技術(shù)投入,同樣適用于驗證日常業(yè)務(wù)迭代中的代碼變動。
一些最佳實踐
綜上,依賴管理是一個復(fù)雜問題,天然存在著許多復(fù)雜性與不可控因素,并且當(dāng)下并沒有任何解決方案能普適地解決所有問題。不過,也有一些值得在日常工作中遵循的最佳實踐,能夠一定程度上緩解各種問題的影響面。
1. 嚴格審查
在引入新的三方依賴時,不要輕易做決定!雖然 NPM 已經(jīng)注冊了數(shù)不勝數(shù)的各種類型的依賴,足以覆蓋我們?nèi)粘S龅降亩鄶?shù)開發(fā)場景,并且使用成本都非常低,但這并不意味著我們可以未經(jīng)思考通通采用!請記住,在軟件工程中,治理問題的成本與復(fù)雜度多數(shù)時候比開發(fā)一個新功能特性要高出許多,一個錯誤的決策在未來可能需要花十倍力氣解決問題(總是要還的)。
因此,在使用某個 Package 之前,我們至少應(yīng)該對它做一些基礎(chǔ)的調(diào)研,雖然很難完全準確評估一個 Package 的好壞,但某些關(guān)鍵特性還是有助于側(cè)面了解它的質(zhì)量,例如:
- 是否有完備詳盡的 Readme:這體現(xiàn)了作者的用心程度與專業(yè)度,也同時決定了我們使用這個包的成本。理想的 Readme 應(yīng)該至少包含這個包的使用方法與基本原理,內(nèi)容越詳細越好;
- 更新頻率:更新頻率越高通常證明作者或者社區(qū)的活躍度越高,也通常意味著出現(xiàn) Issue 時解決速度越快,你也不想在遇到問題時沒有被及時解決吧?
- 單測:作為開源框架,穩(wěn)定性是一個非常重要的指標,而單測又是一種能夠確保穩(wěn)定性的重要工具,因此可以在做決策時建議看看框架源碼本身的單測覆蓋率,以及單測斷言的使用情況;反之,如果連單測都沒做好,建議慎重!
- Benchmark:與單測類似,若源碼中包含一定比例的 Benchmark,則意味著作者對作品的性能有一定要求,那么自然地質(zhì)量相對更值得信任一些;
- 下載或 Star 量:這兩個指標通常意味著這個開源作品被使用的頻率,頻率越高通常意味著被越多人消費、驗證過,也就越能證明這個框架不會存在一些基本的質(zhì)量問題 —— 至少能跑的通嘛;不過請注意,不要迷信這兩個指標,有許多場外因素(例如發(fā)布時間、作者影響力等)都會影響這些數(shù)量的變化,數(shù)量大不足以證明質(zhì)量高;
- 代碼結(jié)構(gòu):如果時間允許,非常建議審查開源框架的代碼結(jié)構(gòu),如果發(fā)現(xiàn)明顯的 Bad Smell,例如圈復(fù)雜度明顯很高,或者有許多重復(fù)代碼,則建議慎重采用;
2. 定期清理無用依賴
隨項目迭代,依賴列表通常會逐漸增加,但很少被及時清理,導(dǎo)致無用依賴逐漸增多,甚至可能引發(fā)上述諸多依賴問題,因此建議有一套機制,定期掃描 & 刪除項目中的無用依賴。社區(qū)已經(jīng)提供了不少依賴掃描工具,例如 depcheck,借助這些工具我們能快速找出無用依賴。
3. 定期 review 依賴結(jié)構(gòu)圖
同樣,隨項目迭代,依賴結(jié)構(gòu)圖持續(xù)發(fā)生變化,且通常會越來越復(fù)雜,可能多數(shù)開發(fā)者體感上覺得依賴安裝的時間越來越長,但沒有深究或觀察過依賴結(jié)構(gòu)正在出現(xiàn)一些不合理的劣化,可能那天想起來要優(yōu)化的時候,問題已經(jīng)變得非常復(fù)雜,難以糾偏。
因此,建議在日常工作中關(guān)注依賴結(jié)構(gòu)的變化情況,是否出現(xiàn)上述異常,例如:重復(fù)依賴、依賴沖突等。一個比較簡單的方式,是觀察 pnpm-lock.yaml、yarn.lock 等文件的內(nèi)容,可以考慮借助 CI,寫腳本,在合碼之前對比 Merge Request 前后的結(jié)構(gòu)圖,檢查是否出現(xiàn)一些 bad case。
4. 使用 Pnpm
在 JS 社區(qū),目前比較主流的包管理器有:NPM、Yarn、Pnpm 三種,從底層實現(xiàn)邏輯來說,更推薦使用 Pnpm (Performance NPM),它安裝下來的依賴結(jié)構(gòu)更合理,能避開大多數(shù)幽靈依賴問題,更重要的,它的緩存結(jié)構(gòu)更合理,也因此有更好的安裝、更新性能。
結(jié)語
綜上,社區(qū)開源能切實提升整個軟件工業(yè)的發(fā)展速度,極大降低開發(fā)成本,但不可忽視的也帶來了一些新的復(fù)雜性 —— 依賴管理,這其中隱含著許多很少被關(guān)注的隱患,多數(shù)時候這并不會直接造成問題,但疊加時間與規(guī)模兩個因素后,通常會慢慢會演變的越來越復(fù)雜,積重難返!所以,一方面日常需要警惕依賴結(jié)構(gòu)的劣化,一方面真遇到問題時,可以參照上面梳理的各種 case,分析具體問題,予以解決。