前言
由于網(wǎng)上很多關(guān)于monorepo的文章分享跟著做都會(huì)有很多報(bào)錯(cuò), 所以想記錄下來(lái)...
維護(hù)多個(gè)倉(cāng)庫(kù)的公共代碼是一件頭疼的事情,每次對(duì)公共代碼的改動(dòng)都要全量倉(cāng)庫(kù)同步,最后決定用 monorepo 改造一番。
Monorepo
Monorepo(monolithic repository) 是管理項(xiàng)目代碼的一個(gè)方式,指在一個(gè)項(xiàng)目倉(cāng)庫(kù) (repo) 中管理多個(gè)模塊/包 (package),不同于常見(jiàn)的每個(gè)模塊建一個(gè) repo。
目前不少大型開(kāi)源項(xiàng)目采用了這種方式,如 Babel、React、Vue 等。monorepo 管理代碼只要搭建一套腳手架,就能管理(構(gòu)建、測(cè)試、發(fā)布)多個(gè) package。
在項(xiàng)目的第一級(jí)目錄的內(nèi)容以腳手架為主,主要內(nèi)容都在 packages 目錄中、分多個(gè) package 進(jìn)行管理。目錄結(jié)構(gòu)大致如下:
├── packages
| ├── pkg1
| | ├── package.json
| ├── pkg2
| | ├── package.json
├── package.json
此時(shí)會(huì)有一個(gè)問(wèn)題,雖然拆分子 npm 包管理項(xiàng)目簡(jiǎn)單了很多,但是當(dāng)倉(cāng)庫(kù)內(nèi)容有關(guān)聯(lián)時(shí),調(diào)試變得困難。所以理想的開(kāi)發(fā)環(huán)境應(yīng)該是只關(guān)心業(yè)務(wù)代碼,可以直接跨業(yè)務(wù)復(fù)用而不關(guān)心復(fù)用方式,調(diào)試時(shí)所有代碼都在源碼中。
目前最常見(jiàn)的 monorepo 解決方案是 lerna 和 yarn 的 workspaces 特性。用 yarn 處理依賴問(wèn)題,lerna處理發(fā)布問(wèn)題。
Lerna
Lerna是npm模塊的管理工具,為項(xiàng)目提供了集中管理package的目錄模式,如統(tǒng)一的 repo 依賴安裝、package scripts和發(fā)版等特性。
安裝
建議全局安裝
npm i -g lerna
初始化項(xiàng)目
lerna init
初始化后,會(huì)生成 packages 空目錄和 package.json 和 lerna.json 配置文件,配置文件如下:
//package.json
{
"name": "root",
"private": true, // 私有的,不會(huì)被發(fā)布,是管理整個(gè)項(xiàng)目,與要發(fā)布的npm包解耦
"devDependencies": {
"lerna": "^3.22.1"
}
}
//lerna.json
{
"packages": [
"packages/*"
],
"version": "0.0.0"
}
創(chuàng)建npm包
執(zhí)行命令后可修改包信息,這里創(chuàng)建 @monorepo/components 和 @monorepo/utils
lerna create @monorepo/components
安裝依賴
lerna和yarn workspace安裝依賴的方法都雞肋
lerna add lodash // 為所有 package 增加 lodash 模塊
// 為 @monorepo/utils 增加 lodash 模塊(lodash可替換為內(nèi)部模塊,如@monorepo/components)
lerna add lodash --scope @monorepo/utils
lerna add的雞肋之處是一次只能安裝一個(gè)包...
依賴包管理
一般情況下 package 的依賴都是在各自的 node_modules 目錄下,這不僅增加了包的安裝和管理成本,還可能會(huì)出現(xiàn)同一個(gè)依賴有多個(gè)的情況。所以可把所有 package 的依賴包都提升到工程根目錄。
lerna 和 yarn workspace都可以把依賴包提升到 repo 根目錄管理。lerna 在安裝依賴時(shí)(lerna bootstrap)提供了--hoist選項(xiàng),但其雞肋的地方在于,由于 lerna 直接以字符串對(duì)比 dependency 的版本號(hào),同一個(gè)依賴在版本號(hào)完全相同時(shí)才會(huì)提升到根目錄下,舉個(gè)栗子:
A 依賴了 @babel/core@^7.10.0
B 依賴了 @babel/core@^7.11.4
lerna 會(huì)在 A 的 node_modules 目錄下安裝 7.11.4 版本的 babel/core,并在 A 目錄下生成了一份 package-lock.json,這無(wú)疑加大了維護(hù)成本和包的體積。
而yarn workspace在這種情況下只會(huì)在根目錄有一份yarn-lock.json,也不會(huì)重復(fù)在子目錄下安裝依賴。
yarn workspace
搭建環(huán)境
主要是為安裝依賴的配置調(diào)整。
在monorepo管理的項(xiàng)目中,各個(gè)庫(kù)之間存在依賴,如A依賴于B,因此我們通常需要將B link到 A 的node_module里,一旦倉(cāng)庫(kù)很多的話,手動(dòng)的管理這些link操作負(fù)擔(dān)很大,因此需要自動(dòng)化的link操作,按照拓?fù)渑判驅(qū)⒏鱾€(gè)依賴進(jìn)行l(wèi)ink
解決方式:通過(guò)使用 workspace,yarn install 會(huì)自動(dòng)的幫忙解決安裝和 link 問(wèn)題
yarn install # 等價(jià)于 lerna bootstrap --npm-client yarn --use-workspaces
package.json & lerna.json 如下:
//lerna.json
{
"packages": ["packages/*"],
"npmClient": "yarn",
"useWorkspaces": true, // 使用yarn workspaces
"version": "0.0.0"
}
//package.json
{
"name": "root",
"private": true,
"workspaces": [ //指定workspace路徑
"packages/*"
],
"devDependencies": {
"lerna": "^3.22.1"
}
}
清理環(huán)境
在依賴亂掉或者工程混亂的情況下,清理依賴
lerna clean # 清理所有packages的node_modules目錄,不能刪除根目錄的node_modules
yarn workspaces run clean # 執(zhí)行所有package的clean操作(應(yīng)是需自行寫腳本)
大部分資料都說(shuō)lerna clean可以刪除根目錄的依賴,實(shí)際感覺(jué)是不行,官網(wǎng)原話是
lerna clean does not remove modules from the root node_modules directory, even if you have the --hoist option enabled.
安裝/刪除依賴
一般分為三種場(chǎng)景
- 給某個(gè) package 安裝/刪除依賴
- 給 root 安裝/刪除依賴,一般的公用的開(kāi)發(fā)工具都是安裝在 root 里,如 typescript
- 給所有 package 安裝/刪除依賴
關(guān)于最后一種情況,網(wǎng)上均顯示 yarn workspaces add/remove lodash 可以給所有包安裝/刪除依賴,然而用就是報(bào)錯(cuò) - error Invalid subcommand. Try "info, run",yarn 2.0 只能用 yarn workspaces foreach,運(yùn)行前要裝 workspace-tools 插件,但還沒(méi)有時(shí)間嘗試過(guò)...所以這里只說(shuō)前兩種情況。
安裝/刪除依賴對(duì)應(yīng)場(chǎng)景依次如下:
yarn workspace packageA add/remove packageB [packageC -D] //為 packageA 安裝/刪除 packageB、C 依賴
yarn add/remove typescript -W -D // 給 root 安裝/刪除 typescript
??注意:當(dāng)使用 yarn workspace packageA add xxx 安裝時(shí),將會(huì)再次安裝 packageA 的所有依賴且安裝在到 packageA 目錄下。要安裝到根目錄加配置 -W
對(duì)于安裝local dependency,yarn的實(shí)現(xiàn)暫時(shí)有bug,第一次安裝需要指明版本號(hào),否則會(huì)安裝失敗如下
如果ui-button沒(méi)有發(fā)布到 npm 則 yarn workspace ui-form add ui-button 會(huì)安裝失敗,但是 yarn workspace ui-form add ui-button@1.0.0會(huì)成功 ,詳情
安裝完依賴文件結(jié)構(gòu)如下:
提交規(guī)范
在構(gòu)建和發(fā)布之前還需要做一些關(guān)于代碼提交的配置
commitizen && cz-lerna-changelog
commitizen 是用來(lái)格式化 git commit message 的工具,它提供了一種問(wèn)詢式的方式去獲取所需的提交信息。
cz-lerna-changelog 是專門為 Lerna 項(xiàng)目量身定制的提交規(guī)范,在問(wèn)詢的過(guò)程,會(huì)有類似影響哪些 package 的選擇。如下:
我們使用 commitizen 和 cz-lerna-changelog 來(lái)規(guī)范提交,為后面自動(dòng)生成日志作好準(zhǔn)備。
因?yàn)檫@是整個(gè)工程的開(kāi)發(fā)依賴,所以在根目錄安裝:
yarn add commitizen cz-lerna-changelog -D -W
安裝完成后,在 package.json 中增加 config 字段,把 cz-lerna-changelog 配置給 commitizen。同時(shí)因?yàn)閏ommitizen不是全局安全的,所以需要添加 scripts 腳本來(lái)執(zhí)行 git-cz
{
"name": "monorepo",
"private": true,
"workspaces": [
"packages/*"
],
"scripts": {
"commit": "git-cz"
},
"config": {
"commitizen": {
"path": "./node_modules/cz-lerna-changelog"
}
},
"devDependencies": {
"commitizen": "^4.2.1",
"cz-lerna-changelog": "^2.0.3",
"lerna": "^3.22.1"
}
}
之后在常規(guī)的開(kāi)發(fā)中就可以使用 yarn run commit 來(lái)根據(jù)提示一步一步輸入,來(lái)完成代碼的提交。
commitlint && husky
以下配置是強(qiáng)制開(kāi)發(fā)者遵循上述規(guī)范,可暫時(shí)跳過(guò),因?yàn)樘峤黄饋?lái)略久……
上面我們使用了 commitizen 來(lái)規(guī)范提交,但很難靠開(kāi)發(fā)自覺(jué)使用 yarn run commit 。萬(wàn)一忘記了,或者直接使用 git commit 提交怎么辦?所以在提交時(shí)校驗(yàn)提交信息,如果不符合要求就不讓提交,并提示。校驗(yàn)的工作由 commitlint 來(lái)完成,校驗(yàn)的時(shí)機(jī)則由 husky 來(lái)指定。husky 繼承了 Git 下所有的鉤子,在觸發(fā)鉤子的時(shí)候,husky 可以阻止不合法的 commit,push 等等。
安裝 commitlint 以及要遵守的規(guī)范:
yarn add -D -W husky @commitlint/cli @commitlint/config-conventional
在工程根目錄為 commitlint 增加配置文件 commitlint.config.js 為commitlint 指定相應(yīng)的規(guī)范
module.exports = {
extends: ['@commitlint/config-conventional']
}
在 package.json 中增加如下配置
"husky": {
"hooks": {
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
}
}
"commit-msg"是git提交時(shí)校驗(yàn)提交信息的鉤子,當(dāng)觸發(fā)時(shí)便會(huì)使用 commitlint 來(lái)校驗(yàn)。安裝配置完成后,想通過(guò) git commit 或者其它第三方工具提交時(shí),只要提交信息不符合規(guī)范就無(wú)法提交。從而約束開(kāi)發(fā)者使用 yarn run commit 來(lái)提交。
eslint && lint-staged
原本想先跳過(guò)eslint的規(guī)范的……然而如果項(xiàng)目已經(jīng)有eslint,而npm包沒(méi)有,調(diào)試都會(huì)報(bào)錯(cuò),原以為一個(gè).eslintlrc文件和裝eslint插件就能解決問(wèn)題,然而并沒(méi)有這么簡(jiǎn)單,累了……后期補(bǔ)上解決問(wèn)題的過(guò)程
除了規(guī)范提交信息,代碼本身肯定也少了靠規(guī)范來(lái)統(tǒng)一風(fēng)格。
yarn add -D -W standard lint-staged
eslint就是完整的一套 JavaScript 代碼規(guī)范,自帶 linter & 代碼自動(dòng)修正。自動(dòng)格式化代碼并修正,提前發(fā)現(xiàn)風(fēng)格以及程序問(wèn)題, 同時(shí)也支持javascript的代碼規(guī)范校驗(yàn),eslintrc.json:
module.exports = {
env: {
browser: true,
es2020: true
},
extends: ["eslint:recommended", "plugin:vue/essential"],
parserOptions: {
parser: "babel-eslint"
},
plugins: ["vue"],
rules: {
"prettier/prettier": [
"off",
{
quotes: 0
}
]
}
}
lint-staged staged 是 Git 里的概念,表示暫存區(qū),lint-staged 表示只檢查并矯正暫存區(qū)中的文件。一來(lái)提高校驗(yàn)效率,二來(lái)可以為老的項(xiàng)目帶去巨大的方便。
package.json配置
{
"name": "monorepo",
"private": true,
"workspaces": [
"packages/*"
],
"scripts": {
"c": "git-cz"
},
"config": {
"commitizen": {
"path": "./node_modules/cz-lerna-changelog"
}
},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"*.(vue|js)": [
//"eslint --fix",
"prettier --write"
]
},
"devDependencies": {
"commitizen": "^4.2.1",
"cz-lerna-changelog": "^2.0.3",
"lerna": "^3.22.1",
"lint-staged": "^10.2.13",
"standard": "^14.3.4"
}
}
安裝完成后,在 package.json 增加 lint-staged 配置 "prettier --write",校驗(yàn)時(shí)機(jī)定在pre-commit,在husky的配置中增加pre-commit的鉤子用來(lái)執(zhí)行 lint 校驗(yàn)。
eslint --fix 校驗(yàn)并自動(dòng)修復(fù)慎用,分分鐘提交不了……
使用 Lerna 構(gòu)建和發(fā)布
項(xiàng)目構(gòu)建
各個(gè)package之間存在相互依賴,如packageB只有在packageA構(gòu)建完之后才能進(jìn)行構(gòu)建,否則就會(huì)出錯(cuò),這實(shí)際上要求我們以一種拓?fù)渑判虻囊?guī)則進(jìn)行構(gòu)建。
我們可以自己構(gòu)建拓?fù)渑判蛞?guī)則,很不幸的是yarn的workspace暫時(shí)并未支持按照拓?fù)渑判蛞?guī)則執(zhí)行命令,雖然該 rfc已經(jīng)被accepted,但是尚未實(shí)現(xiàn)
幸運(yùn)的是lerna支持按照拓?fù)渑判蛞?guī)則執(zhí)行命令, --sort參數(shù)可以控制以拓?fù)渑判蛞?guī)則執(zhí)行命令
lerna run --stream --sort build
可在根目錄的package.json下配置
"scripts": {
"build": "lerna run --stream --sort build"
},
版本升級(jí)及發(fā)包
歷經(jīng)重重困難終于到了發(fā)布最后一步了
項(xiàng)目測(cè)試完成后,就涉及到版本發(fā)布,版本發(fā)布一般涉及到如下一些步驟
- 條件驗(yàn)證: 如驗(yàn)證測(cè)試是否通過(guò),是否存在未提交的代碼,是否在主分支上進(jìn)行版本發(fā)布操作
- version_bump:發(fā)版的時(shí)候需要更新版本號(hào),這時(shí)候如何更新版本號(hào)就是個(gè)問(wèn)題,一般大家都會(huì)遵循 semVer語(yǔ)義
- 生成changelog: 為了方便查看每個(gè)package每個(gè)版本解決了哪些功能,我們需要給每個(gè)package都生成一份changelog方便用戶查看各個(gè)版本的功能變化。
- 生成git tag:為了方便后續(xù)回滾問(wèn)題及問(wèn)題排查通常需要給每個(gè)版本創(chuàng)建一個(gè)git tag
- git 發(fā)布版本:每次發(fā)版我們都需要單獨(dú)生成一個(gè)commit記錄來(lái)標(biāo)記milestone
- 發(fā)布npm包:發(fā)布完git后我們還需要將更新的版本發(fā)布到npm上,以便外部用戶使用
yarn官方并不打算支持發(fā)布流程,只是想做好包管理工具,因此這部分還是需要通過(guò)lerna支持
lerna提供了publish和version來(lái)支持版本的升級(jí)和發(fā)布, publish的功能可以即包含version的工作,也可以單純的只做發(fā)布操作。
只發(fā)布某個(gè)package
lerna官方不支持僅發(fā)布某個(gè)package,https://github.com/lerna/lerna/issues/1691,如果需要,只能自己手動(dòng)的進(jìn)入package進(jìn)行發(fā)布,這樣lerna自帶的各種功能就需要手動(dòng)完成且可能和lerna的功能相互沖突
由于 lerna 會(huì)自動(dòng)的監(jiān)測(cè) git 提交記錄里是否包含指定 package 的文件修改記錄,來(lái)確定版本更新,這要求設(shè)置好合理的 ignore 規(guī)則(否則會(huì)造成頻繁的,無(wú)意義的某個(gè)版本更新),好處是其可以自動(dòng)的幫助 package 之間更新版本
例如如果 ui-form 依賴了 ui-button,如果 ui-button 發(fā)生了版本變動(dòng),會(huì)自動(dòng)的將 ui-form 的對(duì) ui-button 版本依賴更新為 ui-button 的最新版本。 如果 ui-form 發(fā)生了版本變動(dòng),對(duì) ui-button 并不會(huì)造成影響。
經(jīng)測(cè)試 version_bump 是依賴于文件檢測(cè)和 subject 結(jié)合,并不依賴于 scope,scope 的作用是用來(lái)生成 changelog 的吧,即如果是修改了 ui-form 的文件,但是 commit 記錄寫的是 fix(ui-button),lerna 是會(huì)生成 ui-form 的版本更新,并不會(huì)去更新 ui-button 的版本。
發(fā)布自動(dòng)生成日志
有了之前的規(guī)范提交,自動(dòng)生成日志便水到渠成了。leran publish 時(shí)主要做了以下事情:
lerna version 更新版本
- 找出從上一個(gè)版本發(fā)布以來(lái)有過(guò)變更的 package
- 提示開(kāi)發(fā)者確定要發(fā)布的版本號(hào)
- 將所有更新過(guò)的的 package 中的package.json的version字段更新
- 將依賴更新過(guò)的 package 的 包中的依賴版本號(hào)更新
- 更新 lerna.json 中的 version 字段
- 提交上述修改,并打一個(gè) tag
-
推送到 git 倉(cāng)庫(kù)
lerna publish
版本自動(dòng)更新可使用--conventional-commits 參數(shù)會(huì)自動(dòng)的根據(jù)conventional commit規(guī)范和git commit message記錄幫忙確定更新的版本號(hào):
// lerna.json
{
"packages": ["packages/*"],
"npmClient": "yarn",
"useWorkspaces": true,
"command": {
"version": {
"conventionalCommits": true # 生成changelog文件以及根據(jù)commit來(lái)進(jìn)行版本變動(dòng)
}
},
"ignoreChanges": ["**/*.md"], # md文件更新,不觸發(fā)版本變動(dòng)
"version": "0.0.0"
}
包內(nèi)的 package.json 還需 publishConfig 配置
"publishConfig": {
"access": "publish" // 如果該模塊需要發(fā)布,對(duì)于scope模塊,需要設(shè)置為publish,否則需要權(quán)限驗(yàn)證
}
最后執(zhí)行命令發(fā)布
lerna publish [from-git]
如果第一次沒(méi)發(fā)成功但是卻顯示成功了,則要加from-git才能重新發(fā),發(fā)布也還有一些坑……
完善的測(cè)試用例
monorepo項(xiàng)目:測(cè)試有兩種方式
- 使用統(tǒng)一的 jest 測(cè)試配置這樣方便全局的跑 jest 即可,好處是可以方便統(tǒng)計(jì)所有代碼的測(cè)試覆蓋率,壞處是如果 package 比較異構(gòu)(如小程序,前端,node 服務(wù)端等),統(tǒng)一的測(cè)試配置不太好編寫
- 每個(gè) package 單獨(dú)支持test命令,使用 yarn workspace run test,壞處是不好統(tǒng)一收集所有代碼的測(cè)試覆蓋率
此處附上 typescript 的測(cè)試?yán)?,初始化配?jest.config.js:
module.exports = {
preset: 'ts-jest',
moduleFileExtensions: ['ts'],
testEnvironment: 'node'
}
最后附上 github 地址:https://github.com/moon-bonny/monorepo
readme 還未來(lái)得及完善……
相關(guān)文章
未完待續(xù)……
后續(xù)有時(shí)間再出踩坑篇