承接上一節(jié)的內(nèi)容构拳,本節(jié)來繼續(xù)學(xué)習(xí)前端工程化中依賴關(guān)系相關(guān)的內(nèi)容。在此之前侨颈,先回顧一下「項(xiàng)目組織」主題的知識點(diǎn):
說到項(xiàng)目中的依賴關(guān)系署穗,我們往往會想到使用 yarn/npm 解決依賴問題。依賴關(guān)系大體上可以分為:
- 嵌套依賴
- 扁平依賴
項(xiàng)目中趁舀,我們引用了三個包:PackageA赖捌、PackageB、PackageC矮烹, 它們都依賴了 PackageD 的不同版本越庇。那么在安裝時,如果 PackageA奉狈、PackageB卤唉、PackageC 在各自的 node_modules 目錄中分別含有 PackageD,那么我們將其理解為嵌套依賴:
PackageA
node_modules/PackageD@v1.1
PackageB
node_modules/PackageD@v1.2
PackageC
node_modules/PackageD@v1.3
如果在安裝時仁期,先安裝了 PackageA桑驱,那么 PackageA 依賴的 PackageD 版本成為主版本竭恬,它和 PackageA、PackageB熬的、PackageC 一起平級出現(xiàn)痊硕,我們認(rèn)為這是扁平依賴。此時 PackageB押框、PackageC 各自的 node_modules 目錄中也含有各自的 PackageD 版本:
PackageA
PackageD@v1.1
PackageB
node_modules/PackageD@v1.2
PackageC
node_modules/PackageD@v1.3
npm 在安裝依賴包時岔绸,會將依賴包下載到當(dāng)前的 node_modules 目錄中。對于嵌套依賴和扁平依賴的話題强戴,npm 給出了不同的處理方案:npm3 以下版本在依賴安裝時亭螟,非常直接挡鞍,它會按照包依賴的樹形結(jié)構(gòu)下載到本地 node_modules 目錄中骑歹,也就是說,每個包都會將該包的依賴放到當(dāng)前包所在的 node_modules 目錄中墨微。
這么做的原因可以理解:它考慮到了包依賴的版本錯綜復(fù)雜的問題道媚,同一個包因?yàn)楸灰蕾嚨年P(guān)系原因會出現(xiàn)多個版本,保證樹形結(jié)構(gòu)的安裝能夠簡化和統(tǒng)一對于包的安裝和刪除行為翘县。這樣能夠簡單地解決多版本兼容問題最域,可是也帶來了較大的冗余。
npm3 則采用了扁平結(jié)構(gòu)锈麸,但是更加智能镀脂。在安裝時,按照 package.json 里聲明的順序依次安裝包忘伞,遇到新的包就把它放在第一級 node_modules 目錄薄翅。后面再進(jìn)行安裝時,如果遇到一級 node_modules 目錄已經(jīng)存在的包氓奈,那么會先判斷包版本翘魄,如果版本一樣則跳過安裝,否則會按照 npm2 的方式安裝在樹形目錄結(jié)構(gòu)下舀奶。
npm3 這種安裝方式只能夠部分解決問題暑竟,比如:項(xiàng)目里依賴模塊 PackageA、PackageB育勺、PackageC但荤、PackageD, 其中 PackageC、PackageB 依賴模塊 PackageD v2.0涧至,A 依賴模塊 PackageD v1.0腹躁。那么可能在安裝時,先安裝了 PackageD v1.0化借,然后分別在 PackageC潜慎、PackageB 樹形結(jié)構(gòu)內(nèi)部分別安裝 PackageD v2.0。這也是一定程度的冗余。為了解決這個問題铐炫,因此也就有了 npm dedupe 命令垒手。
npm 和 yarn 的內(nèi)容足以單獨(dú)開講,我們這里不再展開倒信。
另外科贬,為了保證同一個項(xiàng)目中不同團(tuán)隊(duì)成員安裝的版本依賴相同,我們往往使用 package-lock.json 或 yarn-lock.json 這類文件通過 git 上傳以共享鳖悠。在安裝依賴時榜掌,依賴版本將會鎖定。
這些內(nèi)容與開發(fā)息息相關(guān)乘综,但是往往被開發(fā)者所忽視憎账。依賴問題說小很小,說復(fù)雜卻也很復(fù)雜卡辰,我們再來看一個循環(huán)依賴的問題胞皱。
復(fù)雜依賴關(guān)系分析和處理
前端項(xiàng)目,安裝依賴非常簡單:
npm install / yarn add
安裝一時爽九妈,而帶來的依賴關(guān)系慢慢地會讓人頭大反砌。依賴關(guān)系的復(fù)雜性帶來的主要副作用有就是循環(huán)依賴。
這里我們來重點(diǎn)說一下萌朱。簡單來說宴树,循環(huán)依賴就是模塊 A 和模塊 B 相互引用,在不同的模塊化規(guī)范下晶疼,對于循環(huán)依賴的處理不盡相同酒贬。
Node.js 中,我們制造一個簡單的循環(huán)引用場景冒晰。
模塊 A:
exports.loaded = false
const b = require('./b')
module.exports = {
bWasLoaded: b.loaded,
loaded: true
}
模塊 B:
exports.loaded = false
const a = require('./a')
module.exports = {
aWasLoaded: a.loaded,
loaded: true
}
在 index.js 中調(diào)用:
const a = require('./a');
const b = require('./b')
console.log(a)
console.log(b)
這種情況下揪罕,并未出現(xiàn)死循環(huán)崩潰的現(xiàn)象宪彩,而是輸出:
{ bWasLoaded: true, loaded: true }
{ aWasLoaded: false, loaded: true }
因是模塊加載過程的緩存機(jī)制:Node.js 對模塊加載進(jìn)行了緩存少辣。按照執(zhí)行順序厂抽,第一次加載 a 時,走到 const b = require('./b')蒋情,這樣直接進(jìn)入模塊 B 當(dāng)中埠况,此時模塊 B 中 const a = require('./a'),模塊 A 已經(jīng)被緩存棵癣,因此模塊 B 返回的結(jié)果為:
{
aWasLoaded: false,
loaded: true
}
模塊 B 加載完成辕翰,回到模塊 A 中繼續(xù)執(zhí)行,模塊 A 返回的結(jié)果為:
{
aWasLoaded: true,
loaded: true
}
據(jù)此分析狈谊,我們不難理解最終的打印結(jié)果喜命。也可以總結(jié)為:
Node.js沟沙,或者 CommonJS 規(guī)范,得益于其緩存機(jī)制壁榕,在遇見循環(huán)引用時矛紫,程序并不會崩潰。但這樣的機(jī)制牌里,仍然會有問題:它只會輸出已執(zhí)行部分颊咬,對于未執(zhí)行部分,export 內(nèi)容為 undefined牡辽。
ES 模塊化與 CommonJS 規(guī)范不同喳篇,ES 模塊不存在緩存機(jī)制,而是動態(tài)引用依賴的模塊态辛。
《Exploring ES6》 一文中的示例很好地闡明了這樣的行為:
//------ a.js ------
import {bar} from 'b'; // (i)
export function foo() {
bar(); // (ii)
}
//------ b.js ------
import {foo} from 'a'; // (iii)
export function bar() {
if (Math.random()) {
foo(); // (iv)
}
}
這樣的代碼麸澜,如果在 commonJS 規(guī)范中:
//------ a.js ------
var b = require('b');
function foo() {
b.bar();
}
exports.foo = foo;
//------ b.js ------
var a = require('a');
function bar() {
if (Math.random()) {
a.foo();
}
}
exports.bar = bar;
如果模塊 a.js 先被執(zhí)行,a.js 依賴 b.js因妙,在 b.js 中痰憎,因?yàn)?a.js 此刻還并沒有暴漏出任何內(nèi)容,因此如果在 b.js 中攀涵,對于頂層 a.foo() 的調(diào)用,會得到報(bào)錯洽沟。但是如果 a.js 模塊執(zhí)行完畢后以故,再調(diào)用 b.bar(),b.bar() 當(dāng)中的 a.foo() 可以正常運(yùn)行裆操。
但是這樣的方式的局限性:
如果 a.js 采用 module.exports = function () { ··· } 的方式怒详,那么 b.js 當(dāng)中的 a 變量在賦值之后不會二次更新。
ESM 不會存在這樣的局限性踪区。ESM 加載的變量昆烁,都是動態(tài)引用其所在的模塊。只要引用是存在的缎岗,代碼就能執(zhí)行静尼。回到:
//------ a.js ------
import {bar} from 'b'; // (i)
export function foo() {
bar(); // (ii)
}
//------ b.js ------
import {foo} from 'a'; // (iii)
export function bar() {
if (Math.random()) {
foo(); // (iv)
}
}
代碼传泊,第 ii 行和第 iv 行鼠渺,bar 和 foo 都指向原始模塊數(shù)據(jù)的引用。ESM 的設(shè)計(jì)目的之一就是支持循環(huán)引用眷细。
ES 的設(shè)計(jì)思想是:盡量靜態(tài)化拦盹,這樣在編譯時就能確定模塊之間的依賴關(guān)系。這也是 import 命令一定要出現(xiàn)在模塊開頭部分的原因溪椎。在模塊中普舆,import 實(shí)際上不會直接執(zhí)行模塊恬口,而是只生成一個引用。在模塊內(nèi)真正引用依賴邏輯時沼侣,再到模塊里取值楷兽。這樣的設(shè)計(jì)非常有利于 tree shaking 技術(shù)的實(shí)現(xiàn),我們在《深入淺出模塊化相關(guān)話題(含 tree shaking)》課程中繼續(xù)展開华临。
在工程實(shí)踐中芯杀,循環(huán)引用的出現(xiàn)往往是由設(shè)計(jì)不合理造成的。如果使用 webpack 進(jìn)行項(xiàng)目構(gòu)建雅潭,可以使用 webpack 插件 circular-dependency-plugin 來幫助檢測項(xiàng)目中存在的所有循環(huán)依賴揭厚。循環(huán)依賴這個問題說大不大,說小不小扶供,我們應(yīng)該盡可能在設(shè)計(jì)源頭規(guī)避筛圆。
另外復(fù)雜的依賴關(guān)系還會帶來以下等問題:
- 依賴版本不一致
- 依賴丟失
對此,需要開發(fā)者根據(jù)真實(shí)情況進(jìn)行處理椿浓,同時太援,合理使用 npm/yarn 工具,也能起到非常關(guān)鍵的作用扳碍。
筆者團(tuán)隊(duì)中通過:
"scripts": {
// ...
"analyzeDeps": "scripts analyzeDeps",
"graph": "scripts graph",
// ...
}
即
yarn run analyzeDeps
來對依賴進(jìn)行分析提岔。具體流程是 analyzeDeps 腳本會對依賴版本沖突和依賴丟失的情況進(jìn)行處理,這個過程依賴 missingDepsAnalyze 和 versionConflictsAnalyze 兩個任務(wù):
其中 missingDepsAnalyze 依賴 depcheck笋敞,depcheck 可以找出哪些依賴是沒有用到的碱蒙,或者對比 package.json 聲明中缺少的依賴項(xiàng)。
同時 missingDepsAnalyze 會讀取 lerna.json 配置夯巷,獲得項(xiàng)目中所有 package赛惩,接著對所有 package 中的 package.json 進(jìn)行遍歷,檢查是否存在相關(guān)依賴趁餐,如果不存在則自動執(zhí)行 yarn add XXXX 進(jìn)行安裝喷兼。
versionConflictsAnalyze 任務(wù)類似,只不過在獲得每個 package 的 package.json 中定義的依賴之后后雷,檢查同一個依賴是否有重復(fù)聲明且存在版本不一致的情況季惯。對于版本沖突,采用交互式命令行喷面,讓開發(fā)者選擇正確的版本星瘾。
相關(guān)代碼并不難實(shí)現(xiàn),感興趣的讀者可以在評論區(qū)交流或者向我提問惧辈,出于隱私原因琳状,這里不再貼出。
使用 yarn workspace 管理依賴關(guān)系
monorepo 項(xiàng)目中依賴管理問題值得重視『谐荩現(xiàn)在我們來看一下非常流行的 yarn workspace 如何處理這種問題念逞。
workspace 的定位為:
It allows you to setup multiple packages in such a way that you only need to run yarn install once to install all of them in a single pass.
翻譯過來困食,workspace 能幫助你更好地管理有多個子 package 的 monorepo。開發(fā)者既可以在每個子 package 下使用獨(dú)立的 package.json 管理依賴翎承,又可以享受一條 yarn 命令安裝或者升級所有依賴的便利硕盹。
引入 workspace 之后,在根目錄執(zhí)行:
yarn install / yarn updrade XX
所有的依賴都會被安裝或者更新叨咖。
當(dāng)然瘩例,如果只想更新某一個包內(nèi)的版本,可以通過以下代碼完成:
yarn workspace upgrade XX
在使用 yarn 的項(xiàng)目中甸各,如果想使用 yarn workspace垛贤,我們不需要安裝其他的包,只要簡單更改 package.json 便可以工作:
// package.json
{
"private": true,
"workspaces": ["workspace-1", "workspace-2"]
}
需要注意的是趣倾,如果需要啟用 workspace聘惦,那么這里的 private 字段必須設(shè)置成 true。 同時 workspaces 這個字段值對應(yīng)一個數(shù)組儒恋,數(shù)組每一項(xiàng)是個字符串善绎,表示一個 workspace(可以理解為一個 repo)。
接著诫尽,我們可以在 workspace-1 和 workspace-2 項(xiàng)目中分別添加 package.json 內(nèi)容:
{
"name": "workspace-1",
"version": "1.0.0",
"dependencies": {
"react": "16.2.3"
}
}
以及:
{
"name": "workspace-2",
"version": "1.0.0",
"dependencies": {
"react": "16.2.3",
"workspace-1": "1.0.0"
}
}
執(zhí)行 yarn install 之后禀酱,發(fā)現(xiàn)項(xiàng)目根目錄下的 node_modules 內(nèi)已經(jīng)包含所有聲明的依賴,且各個子 package 的 node_modules 里面不會重復(fù)存在依賴箱锐,只會有針對根目錄下 node_modules 中的 React 引用比勉。
我們發(fā)現(xiàn),yarn workspace 跟 Lerna 有很多共同之處驹止,解決的問題也部分重疊。下面我們對比一下** workspace **和 Lerna观蜗。
- yarn workspace 寄存于 yarn臊恋,不需要開發(fā)者額外安裝工具,同時它的使用也非常簡單墓捻,只需要在 package.json 中進(jìn)行相關(guān)的配置抖仅,不像 Learn 那樣提供了大量 API
- yarn workspace 只能在根目錄中引入,不需要在各個子項(xiàng)目中引入
事實(shí)上砖第,Lerna 可以與 workspace 共存撤卢,搭配使用能夠發(fā)揮更大作用。在我們團(tuán)隊(duì)中:Lerna 負(fù)責(zé)版本管理與發(fā)布梧兼,依靠其強(qiáng)大的 API 和設(shè)置放吩,做到靈活細(xì)致;workspace 負(fù)責(zé)依賴管理羽杰,整個流程非常清晰渡紫。
在 Lerna 中使用 workspace到推,首先需要修改 lerna.json 中的設(shè)置:
{
...
"npmClient": "yarn",
"useWorkspaces": true,
...
}
然后將根目錄下的 package.json 中的 workspaces 字段設(shè)置為 Lerna 標(biāo)準(zhǔn) packages 目錄:
{
...
"private": true,
"workspaces": [
"packages/*"
],
...
}
注意:如果我們開啟了 workspace 功能,lerna.json 中的 packages 值便不再生效惕澎。原因是 Lerna 會將 package.json 中 workspaces 中所設(shè)置的 workspaces 數(shù)組作為 lerna packages 的路徑莉测,也就是各個子 repo 的路徑。換句話說唧喉,Lerna 會優(yōu)先使用 package.json 中的 workspaces 字段捣卤,在不存在該字段的情況下,再使用 lerna.json 中的 packages 字段八孝。如果未開啟 workspace 功能董朝,lerna.json 配置為:
{
"npmClient": "yarn",
"useWorkspaces": false,
"packages": [
"packages/11/*",
"packages/12/*"
]
}
根目錄下的 package.json 配置為:
{
"private": true,
"workspaces": [
"packages/21/*",
"packages/22/*",
],
...
}
那么這就意味著使用 yarn 管理的是 package.json 中 workspaces 所對應(yīng)的項(xiàng)目路徑下的依賴:packages/21/* 以及 packages/22/。而 Leran 管理的是 lerna.json 中 packages 所對應(yīng)的 packages/11/ 以及 packages/12/* 的項(xiàng)目唆阿。
總結(jié)
本節(jié)主要拋出了大型前端項(xiàng)目的組織選型問題益涧,著重分析了 monorepo 方案,內(nèi)容注重實(shí)戰(zhàn)驯鳖。對于大型代碼庫的組織闲询,本節(jié)梳理出一條完善的工作流程。找到適合自己團(tuán)隊(duì)的風(fēng)格浅辙,是一名合格的開發(fā)者所需要具備的技能扭弧。
但是關(guān)于 npm 和 yarn 以及所牽扯出的依賴問題、monorepo 設(shè)計(jì)問題仍然將是挑戰(zhàn)记舆,其中的話題仍然值得深挖和系統(tǒng)展開鸽捻。具體工程化項(xiàng)目的代碼組織選型和設(shè)計(jì),開發(fā)者一定要通過動手來理解泽腮。在此學(xué)習(xí)過程中御蒲,有任何疑問和想法,都?xì)g迎與我交流诊赊,也希望能有更多機(jī)會和大家交流厚满。