多應(yīng)用項(xiàng)目開(kāi)發(fā)架構(gòu)和多進(jìn)程間構(gòu)建優(yōu)化分析
(給前端大學(xué)加星標(biāo),提升前端技能.)
作者:Lucas HC
https://zhuanlan.zhihu.com/p/15941922
隨著業(yè)務(wù)復(fù)雜度的上升,前端項(xiàng)目不管是從代碼量上,還是從依賴(lài)關(guān)系上都會(huì)爆炸式增長(zhǎng)。對(duì)于單頁(yè)面應(yīng)用或者多應(yīng)用項(xiàng)目來(lái)說(shuō),各個(gè)應(yīng)用之間的關(guān)系也會(huì)更加復(fù)雜,多個(gè)應(yīng)用之間如何配合,如何維護(hù)相互關(guān)系?公共庫(kù)版本如何管理?如何兼顧開(kāi)發(fā)體驗(yàn)和上線構(gòu)建效率?這些話(huà)題隨著前端業(yè)務(wù)的發(fā)展,逐漸浮出水面。
這篇文章我就以一個(gè)成熟的大型項(xiàng)目為例,從其中一個(gè)優(yōu)化點(diǎn)延伸,談一談前端現(xiàn)代化開(kāi)發(fā)和架構(gòu)設(shè)計(jì)方式的思考和經(jīng)驗(yàn)。當(dāng)然,每一種風(fēng)格的項(xiàng)目組織方式都各有特點(diǎn),如何在這些不同架構(gòu)下,打造順暢的開(kāi)發(fā)構(gòu)建流程,持續(xù)優(yōu)化提效是一個(gè)非常值得深入的話(huà)題。
多項(xiàng)目應(yīng)用開(kāi)發(fā)架構(gòu)設(shè)計(jì)
對(duì)于一個(gè)大型復(fù)雜的業(yè)務(wù),如果我們將所有業(yè)務(wù)邏輯開(kāi)發(fā)放在同一個(gè) Git 倉(cāng)庫(kù)下,那么長(zhǎng)久以往會(huì)導(dǎo)致項(xiàng)目臃腫不堪,難以維護(hù)。針對(duì)于此,歷史上我們習(xí)慣將這個(gè)“超級(jí) Git 倉(cāng)庫(kù)”,分散成多個(gè)小的 Git 倉(cāng)庫(kù),一個(gè)項(xiàng)目拆分成多個(gè)應(yīng)用。但是這樣的做法并沒(méi)有解決多個(gè)應(yīng)用之間的耦合和公共邏輯的重復(fù)。甚至極端的場(chǎng)景下,如果應(yīng)用之間具有強(qiáng)關(guān)聯(lián),這樣的多倉(cāng)庫(kù)設(shè)計(jì)勢(shì)必造成開(kāi)發(fā)調(diào)試和上線的痛苦。
針對(duì)上述背景,目前更加現(xiàn)代化的前端管理風(fēng)格和架構(gòu)設(shè)計(jì)主要有兩種(基于 Git submodule 能力的方案不再本文考慮范圍之內(nèi)):
基于 Webpack 多入口的項(xiàng)目拆分和多應(yīng)用打包構(gòu)建
基于 Monorepo 風(fēng)格的項(xiàng)目設(shè)計(jì)
這兩種解決方案都保留了這個(gè)唯一的“超級(jí) Git 倉(cāng)庫(kù)”,但是它們的設(shè)計(jì)思想?yún)s有不同。我們可以簡(jiǎn)單理解為:
「基于 Webpack 多入口的項(xiàng)目拆分和多應(yīng)用打包構(gòu)建」更加適合于業(yè)務(wù)應(yīng)用項(xiàng)目,在多項(xiàng)目?jī)?nèi)聚的前提下,保證了開(kāi)發(fā)和調(diào)試的便利性,同時(shí)可以拆分構(gòu)建和打包流程,顯著提高效率
「基于 Monorepo 風(fēng)格的項(xiàng)目組織」似乎更加適合庫(kù)的編寫(xiě),使用 Lerna 這種工具的 Monorepo 方案帶有鮮明的發(fā)版能力和強(qiáng)烈的工程庫(kù)風(fēng)格。這種模式下,依然具有上述提到的開(kāi)發(fā)和調(diào)試便利性,構(gòu)建和打包的原子性,可謂「你喜歡的樣子我都有」
這兩種解決方案的原理和實(shí)現(xiàn)這里我不再贅述,感興趣的讀者可以訂閱頻道,后續(xù)我會(huì)詳細(xì)解析,而本篇將會(huì)繼續(xù)從另一種角度來(lái)持續(xù)深入。
Monorepo VS Multirepo
由于下文涉及到的場(chǎng)景采用了 Monorepo 風(fēng)格的管理方式,且這是我個(gè)人非常推崇的方案,因此這里我稍微介紹一下相關(guān)概念。對(duì)于相關(guān)概念已經(jīng)有過(guò)了解的讀者,可以直接跳過(guò)這部分,進(jìn)行下部分的閱讀。
現(xiàn)代管理和組織代碼的方式主要分為兩種:
Multirepo
Monorepo
顧名思義,Multirepo 就是將應(yīng)用按照模塊分別管理在不同的倉(cāng)庫(kù)中;而 Monorepo 就是將應(yīng)用中所有的模塊全部一股腦放在同一個(gè)項(xiàng)目中,不再需要在單獨(dú)發(fā)包、測(cè)試,且所有代碼都在一個(gè)項(xiàng)目中管理,在開(kāi)發(fā)階段能夠更早地復(fù)現(xiàn) bugs,暴露問(wèn)題,更方便進(jìn)行調(diào)試。
這是項(xiàng)目代碼在組織上的不同哲學(xué):一種倡導(dǎo)分而治之,一種倡導(dǎo)集中管理。究竟是把雞蛋放在同一個(gè)籃子里,還是倡導(dǎo)多元化,這就要根據(jù)團(tuán)隊(duì)的風(fēng)格以及面臨的實(shí)際場(chǎng)景進(jìn)行選型。
我試圖從 Multirepo 和 Monorepo 兩種處理方式的各自弊端說(shuō)起,希望給讀者更多的參考和建議。
對(duì)于 Multirepo,存在以下問(wèn)題:
開(kāi)發(fā)調(diào)試以及版本更新效率低下
團(tuán)隊(duì)技術(shù)選型分散,有可能不同的庫(kù)實(shí)現(xiàn)風(fēng)格存在較大差異
Changelog 梳理困難,issue 管理混亂(對(duì)于開(kāi)源庫(kù)來(lái)說(shuō))
而 Monorepo 缺點(diǎn)也非常明顯:
庫(kù)體積超大,目錄結(jié)構(gòu)復(fù)雜度上升
需要使用維護(hù) Monorepo 的工具,這就意味著學(xué)習(xí)成本
社區(qū)上的經(jīng)典選型案例:
Babel 和 React 都是典型的 Monorepo
他們的 issue 和 pull request 都集中到唯一的項(xiàng)目中,changelog 可以簡(jiǎn)單地從一份 commit 列表梳理出來(lái)。我們參看 React 項(xiàng)目倉(cāng)庫(kù),從其目錄結(jié)構(gòu)即可看出其強(qiáng)烈的 Monorepo 風(fēng)格:
react-16.2.0/
packages/
react/
react-art/
react-.../
因此,react 和 react-dom 在 npm 上是兩個(gè)不同的庫(kù),他們只不過(guò)在 react 項(xiàng)目中通過(guò) Monorepo 的方式進(jìn)行管理。
而著名的 rollup 目前是 Multirepo 組織。
對(duì)于 Monorepo 和 Multirepo,選擇了 Monorepo 的 babel 貢獻(xiàn)了文章:Why is Babel a Monorepo? 該文章思想,前文已經(jīng)有所指出,這里不再展開(kāi)。
Monorepo 風(fēng)格在開(kāi)發(fā)構(gòu)建中的挑戰(zhàn)
上述對(duì)于 Monorepo 的優(yōu)缺點(diǎn)分析主要是針對(duì)于其管理風(fēng)格本身來(lái)說(shuō)的。作為工程師,我們還是要在實(shí)踐中總結(jié)和發(fā)現(xiàn)問(wèn)題,比如我還要補(bǔ)充 Monorepo 實(shí)際落地之后,會(huì)面臨的兩個(gè)挑戰(zhàn):
Monorepo 項(xiàng)目過(guò)大,導(dǎo)致每次上線構(gòu)建流程過(guò)長(zhǎng),存在不必要的時(shí)間成本消耗
Monorepo 項(xiàng)目子應(yīng)用和依賴(lài)在開(kāi)發(fā)階段存在互相「干擾」的損耗
先說(shuō)第一個(gè)挑戰(zhàn)點(diǎn),對(duì)于一個(gè) Monorepo 項(xiàng)目來(lái)說(shuō),雖然可以在開(kāi)發(fā)階段單獨(dú)構(gòu)建打包,但是在整個(gè)項(xiàng)目上線時(shí),卻需要全量構(gòu)建。舉例來(lái)說(shuō),一個(gè) Monorepo 項(xiàng)目包含應(yīng)用:App1,App2,App3,Dependecies。當(dāng)我們對(duì) App1 進(jìn)行改動(dòng)時(shí),因?yàn)樗袘?yīng)用都在同一個(gè) Git 倉(cāng)庫(kù)中,導(dǎo)致上線時(shí) App2 和 App3 仍然需要重新構(gòu)建,這種構(gòu)建顯然是不必要的(App1,App2,App3 不同應(yīng)用應(yīng)該互相獨(dú)立),這和 Multirepo 相比,這無(wú)疑增加了上線構(gòu)建成本。這里需要注意的是:如果 Dependecies 改動(dòng),那么所有依賴(lài) Dependecies 的項(xiàng)目比如 App1,App2,App3 的重新構(gòu)建是必要且必須的。
這種“缺陷”我們往往使用「增量構(gòu)建」的方案來(lái)優(yōu)化。這個(gè)話(huà)題很有意思,比如涉及到「如何能找出每次提交的改動(dòng)點(diǎn)所對(duì)應(yīng)的原子構(gòu)建任務(wù)」,我們這里暫不展開(kāi),依然回到本文的主題上。
再說(shuō)第二個(gè)挑戰(zhàn)點(diǎn),「Monorepo 項(xiàng)目子應(yīng)用和依賴(lài)在開(kāi)發(fā)階段存在互相干擾的損耗」并不好理解,但正是本篇文章一個(gè)非常核心的輸出之一。接下來(lái),我們通過(guò)下一部分,從一個(gè)案例來(lái)說(shuō)起,幫助大家體會(huì),并一起找到優(yōu)化方案。
多進(jìn)程間構(gòu)建流程優(yōu)化
前端構(gòu)建流程的本質(zhì)其實(shí)是一個(gè)個(gè) NodeJS 任務(wù),也因此是逃離不了進(jìn)程或者線程的概念。Webpack,Babel,NPM Script 這些我們耳熟能詳?shù)墓ぞ吆湍_本都是一個(gè)獨(dú)立或相互關(guān)聯(lián)的進(jìn)程任務(wù)。這里需要大家明白一個(gè)「不間斷進(jìn)程」概念,我使用 continuous processes 來(lái)表達(dá)。其實(shí)很簡(jiǎn)單,比如 @babel/cli 提供了 watch mode 選項(xiàng):
npx babel script.js --watch --out-file script-compiled.js
這個(gè) watch 選項(xiàng)可以監(jiān)聽(tīng)文件(夾)的實(shí)時(shí)變動(dòng),并在有變動(dòng)時(shí)重新對(duì)目標(biāo)文件(夾)進(jìn)行編譯。因此這個(gè)編譯進(jìn)程是掛起的,持續(xù)的,更多內(nèi)容可以看筆者之前的文章 從構(gòu)建進(jìn)程間緩存設(shè)計(jì) 談 Webpack5 優(yōu)化和工作原理 。類(lèi)似的場(chǎng)景在 Webpack 當(dāng)中也非常常見(jiàn)。
對(duì)于復(fù)雜的前端構(gòu)建過(guò)程,當(dāng)這些任務(wù)進(jìn)程交織在一起,產(chǎn)生流水關(guān)系時(shí),就會(huì)變非常有趣,請(qǐng)繼續(xù)閱讀。
一個(gè)多項(xiàng)目應(yīng)用開(kāi)發(fā)架構(gòu)下的瑕疵
我們的中后臺(tái)項(xiàng)目「Monstro」采用了經(jīng)典的 Monorepo 結(jié)構(gòu),項(xiàng)目組織如下:

其中,package.json 中字段,
"workspaces": [
"packages/*",
"apps/*"
],
也暗示了項(xiàng)目中:apps 目錄內(nèi)是 Monorepo 下每一個(gè)單獨(dú)的子應(yīng)用,這些應(yīng)用可以單獨(dú)發(fā)版,單獨(dú)構(gòu)建,子應(yīng)用之間相對(duì)獨(dú)立;packages 目錄內(nèi)是公共依賴(lài),被 apps 目錄內(nèi)所有子應(yīng)用引用。
簡(jiǎn)要說(shuō)明一下這種組織架構(gòu)的優(yōu)勢(shì):
不同子應(yīng)用之間構(gòu)建環(huán)節(jié)獨(dú)立,每個(gè)子應(yīng)用存在自己的 package.json 文件,可以在項(xiàng)目根目錄下通過(guò)?
NPMScript:yarn start ${appName}或yarn build ${appName}?結(jié)合 --scope 選項(xiàng),進(jìn)行獨(dú)立開(kāi)發(fā)調(diào)試和構(gòu)建不同子應(yīng)用之間可以共同依賴(lài) packages 內(nèi)的公共依賴(lài)、公共組件、公共腳本
我們從開(kāi)發(fā)流程來(lái)說(shuō)起:當(dāng)在根目錄下進(jìn)行 yarn start app1 時(shí),會(huì)啟動(dòng) appName 為 app1 的項(xiàng)目,瀏覽器代開(kāi) locahost:3000 端口進(jìn)行開(kāi)發(fā)調(diào)試。這一系列過(guò)程是如何串聯(lián)起來(lái)的呢?yarn start app1 對(duì)應(yīng)的腳本定義于 packages/script/* 目錄當(dāng)中,其內(nèi)容簡(jiǎn)要為:
process.env.NODE_ENV = 'development'
const [app] = process.argv.slice(2)
const config = {
stdio: 'inherit',
env: {
...process.env
}
}
spawn.sync('monstro-scripts', ['clean'], config)
spawn('monstro-scripts', ['prebuild', '--watch'], config)
spawn(
'npx',
['lerna', 'exec', 'npm', 'run', 'start', '--scope', `@monstro/app-${app}`],
config
)
代碼很好理解,其實(shí)在 start 腳本中我們做了三件事情:
串行執(zhí)行 clean 腳本,進(jìn)行一次新的構(gòu)建前處理
clean 執(zhí)行完后,并行執(zhí)行 prebuild 腳本,對(duì) pacakges 目錄中各個(gè)依賴(lài)項(xiàng)進(jìn)行 babel 編譯,并傳遞 watch 參數(shù)
clean 執(zhí)行完后,并行執(zhí)行 app1 scope 內(nèi)的 npm run start,注意這個(gè) npm run start 對(duì)應(yīng)的 NPM Script 定義在 apps/app1/packages.json 中
其中代碼中 monstro-scripts 命令行預(yù)先定義在 packages/scripts 的 package.json 文件中:
"bin": {
"monstro-scripts": "bin/monstro-scripts.js"
},
保證形如 spawn.sync('monstro-scripts', ['命令名稱(chēng)'], 參數(shù)) 的腳本能夠正常執(zhí)行。
讓我們來(lái)逐一分析:
開(kāi)發(fā)者敲入 yarn start app1 后,先執(zhí)行 clean 腳本,clean 腳本執(zhí)行構(gòu)建結(jié)果清理工作:
import rimraf from 'rimraf'
rimraf.sync('node_modules/.cache')
rimraf.sync('packages/*/lib')
rimraf.sync('apps/*/build')
rimraf.sync('apps/*/node_modules/.cache')
同時(shí)執(zhí)行 spawn('monstro-scripts', ['prebuild', '--watch'], config) 腳本,prebuild 過(guò)程實(shí)際上是使用 @babel/cli 對(duì)依賴(lài)目錄 packages 內(nèi) src 目錄內(nèi)容進(jìn)行編譯,原地輸出到 lib 目錄中:
const args = process.argv.slice(2)
const packages = glob
.sync(path.resolve(process.cwd(), 'packages/*'))
.filter(name => readdirSync(name).includes('src'))
for (const pkg of packages) {
spawn(
'npx',
[
'babel',
path.resolve(`${pkg}`, 'src'),
'--out-dir',
path.resolve(`${pkg}`, 'lib'),
'--copy-files',
'--config-file',
path.resolve(__dirname, '../configs/babel.config.js'),
'-x',
['.es6', '.js', '.es', '.jsx', '.mjs', '.ts', '.tsx'].join(','),
...args
],
{
stderr: 'inherit',
env: {
...process.env,
NODE_ENV: process.env.NODE_ENV || 'production'
}
}
)
}
其中關(guān)于 Babel 的配置我們采用了 react-app 這個(gè)預(yù)設(shè):
presets: [['react-app', { flow: false, typescript: true }]]
依然是同時(shí)執(zhí)行:
spawn(
'npx',
['lerna', 'exec', 'npm', 'run', 'start', '--scope', `@monstro/app-${app}`],
config
)
這一步使用了 lerna exec 命令,該命令可以在每個(gè)包目錄下(apps/*)執(zhí)行任意命令,我們到 apps/app1(@monstro/app-${app}) 下執(zhí)行了 npm run start,對(duì)應(yīng) app1 的 start 腳本定義在 apps/app1/packages.json 中:
"scripts": {
"build": "react-scripts build",
"start": "react-scripts start"
},
由此可知,我們最終是使用了 create-react-app 提供的 react-scripts 腳本完成了項(xiàng)目的開(kāi)發(fā)構(gòu)建,create-react-app 提供的 react-scripts 最終會(huì)打開(kāi)瀏覽器,呈現(xiàn)應(yīng)用內(nèi)容,啟動(dòng)持續(xù)化進(jìn)程,監(jiān)聽(tīng)?wèi)?yīng)用依賴(lài)樹(shù)上的任何變動(dòng),隨時(shí)進(jìn)行重新構(gòu)建。
總結(jié)一下,一個(gè) start 腳本構(gòu)建流程如圖:

整體來(lái)看,這套流程架構(gòu)兼顧了各子應(yīng)用的獨(dú)立性,也充分尊重了子應(yīng)用之間和依賴(lài)的關(guān)聯(lián)性,從而達(dá)到了較高的開(kāi)發(fā)調(diào)試效率。在較長(zhǎng)一段時(shí)間內(nèi),穩(wěn)定為中后臺(tái)系統(tǒng)賦能,支持一體化的開(kāi)發(fā)、編譯、上線流程。
直到有一天收到開(kāi)發(fā)者 A 同學(xué)的反饋:有時(shí)候短時(shí)間內(nèi)連續(xù)開(kāi)啟多個(gè)應(yīng)用,會(huì)造成較高的內(nèi)存占用,電腦持續(xù)發(fā)熱并伴隨有較大風(fēng)扇噪音。
這雖然是偶發(fā)的狀況,但是仍然得到了我們的重視。還原場(chǎng)景如:我先開(kāi)發(fā)第一個(gè)應(yīng)用 app1:yarn start app1,接著開(kāi)發(fā)第二個(gè)應(yīng)用:yarn start app2,再開(kāi)發(fā)第二個(gè)應(yīng)用:yarn start app3...
分析問(wèn)題本質(zhì) 找到優(yōu)化方案
多應(yīng)用同時(shí)開(kāi)發(fā)時(shí)的內(nèi)存成本持續(xù)上升的原因是什么呢?
我們將上述過(guò)程通過(guò)兩個(gè)應(yīng)用的啟動(dòng)來(lái)進(jìn)行演示:

關(guān)鍵點(diǎn)在于 prebuild 這一步?;仡櫼幌?start 腳本中對(duì)于 prebuild 任務(wù)的啟動(dòng):
spawn('monstro-scripts', ['prebuild', '--watch'], config)
這里使用 Babel 編譯 packages 下內(nèi)容時(shí),我們使用了 @babel/cli 的 --watch 這一參數(shù)。用前文說(shuō)法, watch 模式的開(kāi)啟將會(huì)創(chuàng)建一個(gè)可持續(xù)進(jìn)程,監(jiān)聽(tīng) packages 下文件內(nèi)容的變動(dòng),并即時(shí)將編譯結(jié)果輸出到原地 lib 目錄中。
我們知道,對(duì)于每一個(gè)應(yīng)用,我們使用了 react-script 構(gòu)建開(kāi)發(fā)應(yīng)用,create-react-app 中 react-script 會(huì)內(nèi)置 Webpack 配置,參看其源碼,可以找到內(nèi)置 Webpack 配置的部分內(nèi)容,配置有 webpack-dev-server 來(lái)幫助開(kāi)發(fā)者啟動(dòng)本地服務(wù)用于開(kāi)發(fā):
watchOptions: {
ignored: ignoredFiles(paths.appSrc),
},
源碼:webpackDevServer.config.js
簡(jiǎn)要對(duì)源碼進(jìn)行說(shuō)明:ignoredFiles(paths.appSrc) 是一個(gè)匹配項(xiàng)目 nodemodules 的正則表達(dá)式,意味著 create-react-app 在持續(xù)性進(jìn)程重新構(gòu)建中會(huì)顯式地忽略 nodemodules 目錄的變動(dòng)。
module.exports = function ignoredFiles(appSrc) {
return new RegExp(
`^(?!${escape(
path.normalize(appSrc + '/').replace(/[\\]+/g, '/')
)}).+/node_modules/`,
'g'
);
};
這么做的原因主要是考慮到監(jiān)聽(tīng) nodemodules 全量?jī)?nèi)容時(shí)的性能損耗的性?xún)r(jià)比。畢竟在 create-react-app 早期在全量監(jiān)聽(tīng) nodemodules 時(shí),某些系統(tǒng)(OS X)上會(huì)偶現(xiàn) CPU 使用率過(guò)高的問(wèn)題。具體 issues:
High CPU usage while running on OS X
Document src/node_modules as official solution for absolute imports
目前 create-react-app 對(duì)于監(jiān)聽(tīng) nodemodules 這件事情所采用的策略非常聰(雞)明(賊):如果 nodemodules 加入一個(gè)新的依賴(lài)包,仍然會(huì)被監(jiān)聽(tīng)到,從而觸發(fā) create-react-app 重新構(gòu)建,這個(gè)是依賴(lài) WatchMissingNodeModulesPlugin 插件實(shí)現(xiàn)的,在 create-react-app 源碼文件 webpack.config.js 中:
isEnvDevelopment &&
new WatchMissingNodeModulesPlugin(paths.appNodeModules),
總之,create-react-app 中 react-script 腳本使用了 webpack-dev-server,這樣也同樣開(kāi)啟了一個(gè)可持續(xù)進(jìn)程,監(jiān)聽(tīng)當(dāng)前應(yīng)用上依賴(lài)樹(shù)關(guān)系的任何變動(dòng),以便隨時(shí)重新進(jìn)行構(gòu)建。
距離“破案”越來(lái)越近了。我們想,當(dāng)我們?cè)谝呀?jīng)啟動(dòng) app1 并觸發(fā) webpack-dev-server watch 監(jiān)聽(tīng)后,再次啟動(dòng) app2,app2 的 start 流程不可避免地進(jìn)行 prebuild 腳本,使得 packages 目錄下產(chǎn)生了變動(dòng),這個(gè)變動(dòng)反過(guò)來(lái)會(huì)影響 app1,被 app1 所對(duì)應(yīng)的 webpack-dev-server 進(jìn)程捕獲到變動(dòng),進(jìn)而重新構(gòu)建 app1(我們這里默認(rèn)所有的業(yè)務(wù)項(xiàng)目都依賴(lài)了 packages 目錄內(nèi)容,實(shí)際上這也是 99% 的場(chǎng)景)。這樣循環(huán)下去,如果我們同時(shí)開(kāi)啟 K 個(gè)應(yīng)用,當(dāng)再次開(kāi)啟 K + 1 個(gè)應(yīng)用時(shí)(yarn start ${appK+1}),因?yàn)椴豢杀苊獾赜|發(fā)了 packages 目錄變動(dòng),前面 K 個(gè)應(yīng)用都將會(huì)同時(shí)重新構(gòu)建。這就意味著更大的內(nèi)存消耗。
如下圖,我們以開(kāi)啟第四個(gè)應(yīng)用項(xiàng)目為例:

我把這個(gè)問(wèn)題稱(chēng)之為——「多應(yīng)用多持續(xù)性進(jìn)程間的構(gòu)建消耗」問(wèn)題。
互斥鎖和鎖競(jìng)爭(zhēng)的解決之道
如何解決這個(gè)「多應(yīng)用多持續(xù)性進(jìn)程間的構(gòu)建消耗」問(wèn)題呢?首先,create-react-app 中 react-script 腳本的 webpack-dev-server 的 watch 配置一定是我們預(yù)期當(dāng)中的:因?yàn)槲覀兿M趹?yīng)用項(xiàng)目中,有相關(guān)文件改動(dòng),即重新構(gòu)建。其次 react-script 腳本由 create-react-app 封裝,且不暴露配置 webpack-dev-server 的能力,同時(shí) eject create-react-app 是我們永遠(yuǎn)不想做的, 因此改動(dòng) react-script 腳本的思路不可行。
關(guān)鍵當(dāng)然是在 prebuild 流程,我們?cè)俅翁峒皢?wèn)題核心是:第一次啟動(dòng) app1 之后,經(jīng)過(guò) prebuild,我們已經(jīng)產(chǎn)出了編譯后的 packages/*/lib目錄,因此后續(xù)啟動(dòng)的所有應(yīng)用都不需要再次觸發(fā) prebuild。思路如此,對(duì)應(yīng)圖示為:

但是 start 腳本是統(tǒng)一的,我們?cè)撊绾胃脑炷??偽代碼如下:
process.env.NODE_ENV = 'development'
const [app] = process.argv.slice(2)
const config = {
stdio: 'inherit',
env: {
...process.env
}
}
spawn.sync('monstro-scripts', ['clean'], config)
if (prebuild 過(guò)程已經(jīng)成功執(zhí)行 !== true) {
spawn('monstro-scripts', ['prebuild', '--watch'], config)
}
spawn(
'npx',
['lerna', 'exec', 'npm', 'run', 'start', '--scope', `@monstro/app-${app}`],
config
)
我們給 spawn('monstro-scripts', ['prebuild', '--watch'], config) 加上了一個(gè)判斷條件,在已經(jīng)成功 prebuild 后,跳過(guò)后續(xù)所有 prebuild 流程。但是 prebuild 過(guò)程已經(jīng)成功執(zhí)行 這個(gè)變量應(yīng)該如何設(shè)計(jì)呢?
每一個(gè) start 腳本對(duì)應(yīng)一個(gè)不同且獨(dú)立的持續(xù)性進(jìn)程任務(wù),因此 prebuild 過(guò)程已經(jīng)成功執(zhí)行 這個(gè)變量應(yīng)該能夠被不同進(jìn)程都訪問(wèn)到,這是一個(gè)典型的多進(jìn)程間通信問(wèn)題。歷數(shù) IPC 的幾種方式,其實(shí)都并不完全適合我們的場(chǎng)景。其實(shí)針對(duì)我們的問(wèn)題,似乎用一個(gè)文件鎖更好。以開(kāi)源庫(kù) jsonfile 為例,我們把 prebuild 結(jié)果標(biāo)記在一個(gè) json 文件中,似乎是一個(gè)合適的選擇。偽代碼:
const jsonfile = require('jsonfile')
process.env.NODE_ENV = 'development'
const [app] = process.argv.slice(2)
const config = {
stdio: 'inherit',
env: {
...process.env
}
}
spawn.sync('monstro-scripts', ['clean'], config)
if (jsonfile.readFileSync(file).status !== 'success') {
spawn('monstro-scripts', ['prebuild', '--watch'], config)
jsonfile.writeFileSync(file, {status: 'success'})
}
spawn(
'npx',
['lerna', 'exec', 'npm', 'run', 'start', '--scope', `@monstro/app-${app}`],
config
)
此時(shí) start 腳本流程如圖:


這里插一個(gè)細(xì)節(jié),初期設(shè)計(jì)我們認(rèn)為文件鎖狀態(tài)應(yīng)該有 3 種:
{
status: 'success'/'running'/'fail'
}
如果 prebuild 流程正在構(gòu)建或構(gòu)建失敗,仍然要繼續(xù)執(zhí)行 spawn('monstro-scripts', ['prebuild', '--watch'], config)。事實(shí)上,這是完全沒(méi)有必要的,因?yàn)?Babel 的編譯是一個(gè)持續(xù)性進(jìn)程,開(kāi)啟 watch 選項(xiàng),這樣開(kāi)發(fā)者可以始終在編譯進(jìn)行中和編譯失敗中得到信息,進(jìn)行修復(fù)。文件鎖內(nèi)容完全可以樂(lè)觀更新,而后續(xù)樂(lè)觀可行性保障由開(kāi)發(fā)者負(fù)責(zé)。
但卻還有另外一個(gè)重要問(wèn)題需要考慮:在初期架構(gòu)設(shè)計(jì)中,每個(gè)應(yīng)用的啟動(dòng),都使用了 Babel 持續(xù)性編譯進(jìn)程,進(jìn)行 watch 監(jiān)聽(tīng),這樣當(dāng)開(kāi)發(fā)者手動(dòng)殺死(Ctrl + C)一個(gè)終端進(jìn)程后:比如不再需要 app1 的開(kāi)發(fā),殺死 app1 后,就沒(méi)有任何應(yīng)用能夠監(jiān)聽(tīng) packages 的變化了。理想狀態(tài)下,我們需要啟動(dòng)另外一個(gè)應(yīng)用進(jìn)程,去監(jiān)聽(tīng)著 packages 文件夾的變動(dòng),進(jìn)而觸發(fā) packages 的持續(xù)編譯。
如何理解呢?請(qǐng)參考上圖,在我們新改進(jìn)的流程中:app1 的啟動(dòng)中執(zhí)行了 spawn('monstro-scripts', ['prebuild', '--watch'], config),后續(xù)的 appN 不再有 prebuild 過(guò)程,也就不在監(jiān)聽(tīng) packages 文件夾的變動(dòng),此時(shí),如果開(kāi)發(fā)者手動(dòng)殺死(Ctrl + C)第一個(gè)應(yīng)用(即 app1),那么開(kāi)發(fā)者再對(duì) packages 內(nèi)代碼進(jìn)行改動(dòng),就不會(huì)觸發(fā) Babel 編譯,任何應(yīng)用都不在有相應(yīng),此時(shí)狀態(tài)如下:

如何解決這個(gè)問(wèn)題?這就涉及到了 競(jìng)爭(zhēng)鎖的概念。
鎖競(jìng)爭(zhēng)常出現(xiàn)在多線程編程中,熟悉 Java 并發(fā)機(jī)制的讀者可能對(duì)這個(gè)概念并不陌生。簡(jiǎn)單來(lái)說(shuō),同一個(gè)進(jìn)程里線程是數(shù)共享的,當(dāng)各個(gè)線程訪問(wèn)數(shù)據(jù)資源時(shí)會(huì)出現(xiàn)競(jìng)爭(zhēng)狀態(tài),即數(shù)據(jù)幾乎同步會(huì)被多個(gè)線程占用,造成數(shù)據(jù)混論,即所謂的線程不安全。那怎么解決多線程問(wèn)題,就是鎖了。切換到另一種語(yǔ)言,Python 提供的對(duì)線程控制的對(duì)象,其中包括有互斥鎖、可重入鎖、死鎖等?;コ怄i概念,是用來(lái)保證共享數(shù)據(jù)操作的完整性。這個(gè)標(biāo)記用來(lái)保證在任一時(shí)刻,只能有一個(gè)線程訪問(wèn)該對(duì)象。
按照這個(gè)思路,我們進(jìn)行擴(kuò)展,通過(guò)互斥鎖和鎖競(jìng)爭(zhēng),實(shí)現(xiàn)這樣的機(jī)制:第一個(gè)應(yīng)用啟動(dòng)時(shí),在 prebuild 階段對(duì)該進(jìn)程的終止進(jìn)行監(jiān)聽(tīng),在監(jiān)聽(tīng)到 Babel 持續(xù)性進(jìn)程終止時(shí),改寫(xiě)文件鎖內(nèi)容 status 為 available;同時(shí)之后的每一個(gè)應(yīng)用啟動(dòng)時(shí),都加入輪詢(xún)腳本,輪詢(xún)內(nèi)容即為對(duì)文件鎖 status 值的查詢(xún),一旦查詢(xún)到 status === 'available',說(shuō)明相關(guān)監(jiān)聽(tīng) Babel 編譯的進(jìn)程結(jié)束,需要“我”來(lái)接管。具體操作是:將 status 值置為 'success',同時(shí)開(kāi)啟 prebuild 流程(spawn('monstro-scripts', ['prebuild', '--watch'], config))。整個(gè)過(guò)程概括為:
第一個(gè)應(yīng)用負(fù)責(zé) prebuild,負(fù)責(zé) Babel 持續(xù)性進(jìn)程來(lái)監(jiān)聽(tīng) packages 目錄的改動(dòng)。同時(shí)監(jiān)聽(tīng)該進(jìn)行的終止事件
第一個(gè)應(yīng)用一旦監(jiān)聽(tīng)到進(jìn)程終止,則改寫(xiě)文件鎖 status 狀態(tài)為 available,釋放 prebuild 流程控制權(quán)
其他應(yīng)用通過(guò)啟動(dòng)時(shí)的輪詢(xún)機(jī)制,競(jìng)爭(zhēng)被釋放的 prebuild 流程控制權(quán)
其他應(yīng)用誰(shuí)先競(jìng)爭(zhēng)到 prebuild 流程控制權(quán),就通過(guò)改寫(xiě)文件鎖 status 狀態(tài)為 success,進(jìn)行鎖定
流程如下圖:

工程從來(lái)不只是個(gè)技術(shù)問(wèn)題
上述使用「鎖」的方案雖然稍顯復(fù)雜,但似乎能夠從技術(shù)上給出較徹底完備的解法了??墒窃陧?xiàng)目工程上,這真是我想要的么?讓我們回到問(wèn)題的最初始:「start 這個(gè)腳本開(kāi)啟兩個(gè)子進(jìn)程,其中對(duì) packages 目錄進(jìn)行 watch 并增量編譯的腳本會(huì)影響并觸發(fā) create-react-app 進(jìn)程的重新構(gòu)建。在多應(yīng)用同時(shí)開(kāi)發(fā)的情況下,這種影響是指數(shù)疊加的,從而導(dǎo)致了內(nèi)存的重復(fù)消耗」。這是項(xiàng)目已用的設(shè)計(jì),我不禁要想,「將 start 腳本中的 create-react-app 進(jìn)程和 babel 增量編譯進(jìn)程解耦,似乎是很自然而然的做法」。如下圖:

這樣的啟動(dòng)流程排除了 babel 進(jìn)程和 create-react-app 進(jìn)程之間的相互干擾,從根源上解決了問(wèn)題。但是它的「副作用」是:需要開(kāi)發(fā)者在啟動(dòng)應(yīng)用時(shí),先執(zhí)行 yarn prebuild 的 script,再執(zhí)行 yarn start appX。相比于之前的「一鍵無(wú)腦啟動(dòng)」,多了一個(gè)終端 tab 和腳本執(zhí)行過(guò)程,且要求開(kāi)發(fā)者知曉這么做的目的以及意義:當(dāng)修改 packages 目錄下內(nèi)容,并由于各種原因中斷 prebuild babel 進(jìn)程后,開(kāi)發(fā)者要知道需要重啟 yarn prebuild 進(jìn)程。這些「信息量」我們可以通過(guò) README 來(lái)進(jìn)行說(shuō)明和指導(dǎo),并在喪失 prebuild 進(jìn)程持續(xù)執(zhí)行時(shí),進(jìn)行中斷友好提示。相比上述純技術(shù)向的「鎖」方案,這樣的設(shè)計(jì)更「取巧」。我認(rèn)為,工程從來(lái)不只是個(gè)技術(shù)問(wèn)題,不鉆牛角尖,多角度思考,往往有「四兩撥千斤」的效用。
實(shí)際上,當(dāng)初將 prebuild babel watch 進(jìn)程作為 start 進(jìn)程的子進(jìn)程設(shè)計(jì)也是有一定道理的,這里不再展開(kāi)(這是一個(gè)設(shè)計(jì)取舍問(wèn)題)。
總結(jié)
這篇文章討論了兩個(gè)核心問(wèn)題:
多項(xiàng)目應(yīng)用開(kāi)發(fā)架構(gòu)設(shè)計(jì)
多進(jìn)程多應(yīng)用間構(gòu)建流程優(yōu)化設(shè)計(jì)
對(duì)于大型復(fù)雜應(yīng)用的開(kāi)發(fā)和構(gòu)建設(shè)計(jì)——這一話(huà)題,我們結(jié)合實(shí)際生產(chǎn)中的項(xiàng)目,分析并給出了一個(gè)較為“完美”的方案。在這個(gè)方案的基礎(chǔ)上,論證并解決了 Monorepo 化的項(xiàng)目在遇見(jiàn)多進(jìn)程復(fù)雜構(gòu)建流程時(shí)的一個(gè)“小尷尬”。整個(gè)過(guò)程中,為了發(fā)現(xiàn)問(wèn)題,解決問(wèn)題,我們深入剖析了 create-react-app 和 Webpack 的源碼及設(shè)計(jì),同時(shí)討論了持續(xù)化進(jìn)程,最終通過(guò)互斥鎖和鎖競(jìng)爭(zhēng)找到了靈感,實(shí)現(xiàn)了迭代和優(yōu)化。最后我們從流程解耦的角度,也給出了另外一種優(yōu)化方案。
當(dāng)然,這其中也涉及到很多其他有趣的問(wèn)題,大型復(fù)雜應(yīng)用的開(kāi)發(fā)和構(gòu)建關(guān)聯(lián)到基建的方方面面,為此我們會(huì)持續(xù)輸出這方面的技術(shù)經(jīng)驗(yàn)和心得,請(qǐng)大家訂閱內(nèi)容。
分享前端好文,點(diǎn)亮?在看?

