Monorepo實(shí)戰(zhàn)

前言

由于網(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、ReactVue 等。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)文章

vue組件打包篇

未完待續(xù)……

后續(xù)有時(shí)間再出踩坑篇

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容