在最近的項目開發中,出現了一個令我困擾的情況。我正在開發的項目 A,依賴了已經線上發佈的項目 B,可是隨着項目 A 的不斷開發,又須要不時修改項目 B 的代碼(這些修改暫時沒必要發佈線上),如何可以在修改項目 B 代碼後及時將改動後在項目 A 中同步? 在項目 A 發佈上線後,如何以一種優雅的方式解決項目 A,B 版本升級後的版本同步問題? 通過一番調研,我發現解決這些問題的最佳方案即是本篇要介紹的 monorepo 策略。前端
monorepo 是一種將多個項目代碼存儲在一個倉庫裏的軟件開發策略("mono" 來源於希臘語 μόνος 意味單個的,而 "repo",顯而易見地,是 repository 的縮寫)。將不一樣的項目的代碼放在同一個代碼倉庫中,這種「把雞蛋放在同一個籃子裏」的作法可能乍看之下有些奇怪,但實際上,這種代碼管理方式有不少好處,不管是世界一流的互聯網企業 Google,Facebook,仍是社區知名的開源項目團隊 Babel (以下圖)都使用了 monorepo 策略管理他們的代碼。node
<p style="text-align: center; color: #999;">babel 使用 monorepo 策略管理代碼</p>git
使用 monorepo 策略究竟會給代碼管理者和程序開發者帶來哪些好處? 咱們又該如何在工做中嘗試實踐 monorepo 策略?這正是本文想要探討的話題。但願經過個人一番介紹,您可以對 monorepo 策略有更完整的認知,文章中介紹的工具和思想能夠切實幫助到您和您所在的團隊。github
經過 monorepo 策略組織代碼,您代碼倉庫的目錄結構看起來會是這樣:shell
. ├── lerna.json ├── package.json └── packages/ # 這裏將存放全部子 repo 目錄 ├── project_1/ │ ├── index.js │ ├── node_modules/ │ └── package.json ├── project_2/ │ ├── index.js │ ├── node_module/ │ └── package.json ...
乍看起來,所謂的 monorepo 策略就只是將不一樣項目的目錄聚集到一個目錄之下,但實際上操做起來所要考慮的事情則遠比看起來要複雜得多。經過分析使用 monorepo 策略的優劣,咱們能夠更直觀的感覺到這裏面所隱晦涉及的知識點。npm
沒錯,軟件開發領域歷來沒有「銀彈」。monorepo 策略也並不完美,而且,我在實踐中發現,要想完美在組織中運用 monorepo 策略,所須要的不只是出色的編程技巧和耐心。團隊日程,組織文化和我的影響力相互碰撞的最終結果才決定了想法最終是否能被實現。編程
可是請別灰心的太早,由於雖然讓組織做出改變,統一施行 monorepo 策略困難重重,但這卻並不意味着咱們須要完全跟 monorepo 策略說再見(不然我這篇文章就該到此爲止了)。咱們還能夠把 monorepo 策略實踐在「項目」這個級別,即從邏輯上肯定項目與項目之間的關聯性,而後把相關聯的項目整合在同一個倉庫下,一般狀況下,咱們不會有太多相互關聯的項目,這意味着咱們可以免費獲得 monorepo 策略的全部好處,而且能夠拒絕支付大型 monorepo 架構的利息。json
本文的剩餘篇幅就是對「項目級別 monorepo 實踐」的一些總結,即便您最終沒有選擇 monorepo 策略組織您的代碼,相信文章中提供的一些工程化工具或思路也同樣會對您產生幫助。bootstrap
Volta 是一個 JavaScript 工具管理器,它可讓咱們輕鬆地在項目中鎖定 node,npm 和 yarn 的版本。你只需在安裝完 Volta 後,在項目的根目錄中執行 volta pin 命令,那麼不管您當前使用的 node 或 npm(yarn)版本是什麼,volta 都會自動切換爲您指定的版本。babel
所以,除了使用 Docker 和顯示在文檔中聲明 node 和 npm(yarn)的版本以外,您就有了另外一個鎖定環境的強力工具。
並且相較於 nvm,Volta 還具備一個誘人的特性:當您項目的 CLI 工具與全局 CLI 工具不一致時,Volta 能夠作到在項目根目錄下自動識別,切換到項目指定的版本,這一切都是由 Volta 默默作到的,開發者沒必要關心任何事情。
使用 monorepo 策略後,收益最大的兩點是:
這兩項好處所有均可以由一個成熟的包管理工具來完成,對前端開發而言,便是 yarn(1.0 以上)或 npm(7.0 以上)經過名爲 workspaces 的特性實現的(⚠️ 注意,支持 workspaces 特性的 npm 目前依舊不是 TLS 版本)。
爲了實現前面提到的兩點收益,您須要在代碼中作三件事:
通過修改,您的項目目錄看起來應該是這樣:
. ├── package.json └── packages/ ├── @mono/project_1/ # 推薦使用 `@<項目名>/<子項目名>` 的方式命名 │ ├── index.js │ └── package.json └── @mono/project_2/ ├── index.js └── package.json
而當您在項目根目錄中執行 npm install 或 yarn install 後,您會發如今項目根目錄中出現了 node_modules 目錄,而且該目錄不只擁有全部子項目共用的 npm 包,還包含了咱們的子項目。所以,咱們能夠在子項目中經過各類模塊引入機制,像引入通常的 npm 模塊同樣引入其餘子項目的代碼。
請注意咱們對子項目的命名,統一以 @<repo_name>/ 開頭,這是一種社區最佳實踐,不只可讓用戶更容易瞭解整個應用的架構,也方便您在項目中更快捷的找到所需的子項目。
至此,咱們已經完成了 monorepo 策略的核心部分,實在是很容易不是嗎?可是老話說「行百里者半九十」,距離優雅的搭建一個 monorepo 項目,咱們還有一些路要走。
您必定贊成,編寫代碼要遵循 DRY 原則(Don't Repeat Yourself 的縮寫)。那麼,理所固然地,咱們應該儘可能避免在多個子項目中放置重複的 eslintrc,tsconfig 等配置文件。幸運的是,Babel,Eslint 和 Typescript 都提供了相應的功能讓咱們減小自我重複。
咱們能夠在 packages 目錄中放置 tsconfig.settting.json 文件,並在文件中定義通用的 ts 配置,而後,在每一個子項目中,咱們能夠經過 extends 屬性,引入通用配置,並設置 compilerOptions.composite 的值爲 true,理想狀況下,子項目中的 tsconfig 文件應該僅包含下述內容:
{ "extends": "../tsconfig.setting.json", // 繼承 packages 目錄下通用配置 "compilerOptions": { "composite": true, // 用於幫助 TypeScript 快速肯定引用工程的輸出文件位置 "outDir": "dist", "rootDir": "src" }, "include": ["src"] }
對於 Eslint 配置文件,咱們也能夠如法炮製,這樣定義子項目的 .eslintrc 文件內容:
{ "extends": "../../.eslintrc", // 注意這裏的不一樣 "parserOptions": { "project": "tsconfig.json" } }
注意到了嗎,對於通用的 eslint 配置,咱們並無將其放置在 packages 目錄中,而是放在整個項目的根目錄下,這樣作是由於一些編輯器插件只會在項目根目錄尋找 .eslintrc 文件,所以爲了咱們的項目可以保持良好的「開發環境一致性」,請務必將通用配置文件放置在項目的根目錄中。
Babel 配置文件合併的方式與 TypeScript 一模一樣,甚至更加簡單,咱們只需在子項目中的 .babelrc 文件中這樣聲明便可:
{ "extends": "../.babelrc" }
當一切準備就緒後,咱們的項目目錄應該大體呈以下所示的結構:
. ├── package.json ├── .eslintrc └── packages/ │ ├── tsconfig.settings.json │ ├── .babelrc ├── @mono/project_1/ │ ├── index.js │ ├── .eslintrc │ ├── .babelrc │ ├── tsconfig.json │ └── package.json └───@mono/project_2/ ├── index.js ├── .eslintrc ├── .babelrc ├── tsconfig.json └── package.json
在上一步中,咱們儘量的將全部配置文件進行抽象,從而精簡了代碼,並提升了整個項目的一致性。咱們的整個倉庫也所以有了「更濃郁的 monorepo 風味 ☕️」。但若是仔細審視咱們的整個工程文件,還有一處存在着明顯的瑕疵和一些惱人的壞味道,當您仔細審視您的衆多 package.json 文件時,您就知道我在說什麼了 -- scripts 腳本。
若是您的子項目足夠多,您可能會發現,每一個 package.json 文件中的 scripts 屬性都大同小異,而且一些 scripts 充斥着各類 Linux 語法,例如管道操做符,重定向或目錄生成。重複帶來低效,複雜則令人難以理解,這都是須要咱們解決的問題。
這裏給出的解決方案是,使用 scripty 管理您的腳本命令,簡單來講,scripty 容許您將腳本命令定義在文件中,並在 package.json 文件中直接經過文件名來引用。這使咱們能夠實現以下目的:
經過使用 scripty 管理咱們的 monorepo 應用,目錄結構看起來將會是這樣:
. ├── package.json ├── .eslintrc ├── scirpts/ # 這裏存放全部的腳本 │ │ ├── packages/ # 包級別腳本 │ │ │ ├── build.sh │ │ │ └── test.sh │ └───└── workspaces/ # 全局腳本 │ ├── build.sh │ └── test.sh └── packages/ │ ├── tsconfig.settings.json │ ├── .babelrc ├── @mono/project_1/ │ ├── index.js │ ├── .eslintrc │ ├── .babelrc │ ├── tsconfig.json │ └── package.json └── @mono/project_2/ ├── index.js ├── .eslintrc ├── .babelrc ├── tsconfig.json └── package.json
注意,咱們腳本分爲兩類「package 級別」與「workspace 級別」,而且分別放在兩個文件夾內。這樣作的好處在於,咱們既能夠在項目根目錄執行全局腳本,也能夠針對單個項目執行特定的腳本。
經過使用 scripty,子項目的 package.json 文件中的 scripts 屬性將變得很是精簡:
{ ... "scripts": { "test": "scripty", "lint": "scripty", "build": "scripty" }, "scripty": { "path": "../../scripts/packages" // 注意這裏咱們指定了 scripty 的路徑 }, ... }
大功告成!???? 至此,咱們盡己所能地刪除了整個項目中的重複代碼,讓整個項目變得乾淨,清爽而且有極強的複用性。
???? 小貼士:
別忘了使用 chmod -R u+x scripts 命令使全部的 shell 腳本具有可執行權限,也千萬別忘了把這條貼士寫在您的 README.md 文件中!
<p style="text-align: center; color: #999;">圖片來源:https://github.com/lerna/lerna</p>
我有時會感慨本身的靈感匱乏,怎麼就想不到 Lerna 這樣既有神話色彩又能自我釋義的好名字。您能夠大膽想象,九頭龍的每隻龍頭都在幫您管理着一個子項目,而您只須要騎在龍身上發號施令的場景,這基本上就是咱們使用 Lerna 時的直觀感覺。
這也是爲何當咱們提起 monorepo 策略,就幾乎不得不提到 Lerna 的緣由了,它的確提供了一種很是便捷的方式供咱們管理 monorepo 項目。當子項目越多時,Lerna 就越能顯示其威力。
當多個子項目放在一個代碼倉庫,而且子項目之間又相互依賴時,咱們面臨的棘手問題有兩個:
經過使用 Lerna,這些棘手的問題都將不復存在。
當在項目根目錄使用 npx lerna init 初始化後,咱們的根目錄會新增一個 lerna.json 文件,默認內容爲:
{ "packages": ["packages/*"], "version": "0.0.0" }
讓咱們稍稍改動這個文件,使其變爲:
{ "packages": ["packages/*"], "npmClient": "yarn", "version": "independent", "useWorkspaces": true, }
能夠注意到,咱們顯示聲明瞭咱們的包客戶端(npmClient)爲 yarn,而且讓 Lerna 追蹤咱們 workspaces 設置的目錄,這樣咱們就依舊保留了以前 workspaces 的全部特性(子項目引用和通用包提高)。
除此以外一個有趣的改動在於咱們將 version 屬性指定爲一個關鍵字 independent,這將告訴 lerna 應該將每一個子項目的版本號看做是相互獨立的。當某個子項目代碼更新後,運行 lerna publish 時,Lerna 將監聽到代碼變化的子項目並以交互式 CLI 方式讓開發者決定須要升級的版本號,關聯的子項目版本號不會自動升級,反之,當咱們填入固定的版本號時,則任一子項目的代碼變更,都會致使全部子項目的版本號基於當前指定的版本號升級。
Lerna 提供了不少 CLI 命令以知足咱們的各類需求,但根據 2/8 法則,您應該首先關注如下這些命令:
# 向 @mono/project2 和 @mono/project3 中添加 @mono/project1 lerna add @mono/project1 '@mono/project{2,3}'
除了上面介紹到的經常使用命令外,Lerna 還提供了一些參數知足咱們更靈活的需求,例如:
看到這裏,您可能想要親自體驗一把使用 Lerna 管理/發佈 monorepo 項目的感受。但是很快您會發現,將示例代碼發佈到真實世界的 npm 倉庫並不是一個好主意,這多少有些使人沮喪,可是別擔憂,您可使用 Verdaccio 在本地建立一個 npm 倉庫做爲代理,而後盡情體驗 Lerna 的種種強大之處。
安裝運行 Verdaccio 很是簡單,您只需運行:
npm install --global verdaccio
在全局安裝 Verdaccio 應用,而後在 shell 中輸入:
verdaccio
便可經過 localhost:4837 訪問您的本地代理 npm 倉庫,別忘了在您的項目根目錄建立 .npmrc 文件,並在文件中將 npm 倉庫地址改寫爲您的本地代理地址:
registry="http://localhost:4873/"
大功告成 ????!每當您執行 lerna publish 時,子項目所構建成的 package 將會發布在本地 npm 倉庫中,而當您執行 lerna bootstrap 時,Verdaccio 將會放行,讓您成功從遠程 npm 倉庫中拉取相應的代碼。
至此,咱們已經掌握了組織一個項目級 monorepo 倉庫的全部前沿技巧,最後,讓咱們看看最後一個能夠優化的地方:代碼提交時,約束 commit 信息。
一個 monorepo 倉庫可能被不一樣的開發者提交不一樣子項目的代碼,若是沒有規範化的 commit 信息,在故障排查或版本回滾時毫無心外會遭遇災難。所以,千萬不要小看 commit 信息格式化的重要性(固然,一樣重要的還有代碼註釋!)。
爲了咱們可以一目瞭然的追蹤每次代碼變動的信息,咱們使用 commitlint 工具做爲格式化 commit 信息的不二之選。
顧名思義,commitlint 能夠幫助咱們檢查提交的 commit 信息,它強制約束咱們的 commit 信息必須在開頭附加指定類型,用於標示本次提交的大體意圖,支持的類型關鍵字有:
我強烈建議您遵循該規範編寫您的 commit 信息,不要偷懶,堅持下去,您的 git 日誌將會顯得整齊,有條理,富有表現力,同時,您也會收到同行的交口稱讚,人人都會以和您這樣優雅的工程師合做爲榮。
除了限定 commit 信息類型外,commitlint 還支持(雖然不是必須的)顯示指定咱們本次提交所對應的子項目名稱。假如咱們有一個名爲 @mono/project1 的子項目,咱們針對該項目提交的 commit 信息能夠寫爲:
git commit -m "feat(project1): add a attractive button" # 注意,咱們省略了 @mono 的項目前綴
毫無疑問,這將會使咱們的 commit 信息更具表現力。
咱們能夠經過下面的命令安裝 commitlint 以及周邊依賴:
npm i -D @commitlint/cli @commitlint/config-conventional @commitlint/config-lerna-scopes commitlint husky lerna-changelog
注意到了嗎?我偷偷安裝了 husky,它可以幫助咱們在提交 commit 信息時自動運行 commitlint 進行檢查,但在這以前,咱們須要再在根目錄下的 package.json 文件里加點料,像這樣:
{ ... "husky": { "hooks": { "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" } } ... }
爲了可以讓 commitlint 感知咱們的子項目名稱,咱們還需在項目根目錄中增長 commitlint.config.js 文件,並設置文件內容爲:
module.exports = { extends: [ "@commitlint/config-conventional", "@commitlint/config-lerna-scopes", ], };
至此,咱們統一併規範化了 monorepo 項目的 commit 信息,終於整個 monorepo 工程化的最後一塊拼圖被咱們拼上了!
(順便一提,您能夠經過在命令行執行 echo "build(project1): change something" | npx commitlint 命令便可驗證您的 commit 信息是否經過 commitlint 的檢查。)
至此,咱們學會了如何採用 monorepo 策略組織項目代碼的最佳實踐,或許您已經開始躍躍欲試想要嘗試前文提到的種種技巧。從 0 搭建一個 monorepo 項目,固然沒問題!但是若是要基於已有的項目,將其轉化爲一個使用 monorepo 策略的項目呢?
還記得嗎?成百里者半九十,您還有一些坑要踩。不過好在您在這裏還可以獲得個人幫助,沒必要客氣!
或許您注意到了,Lerna 爲咱們提供了 lerna import 命令,用來將咱們已有的包導入到 monorepo 倉庫,而且還會保留該倉庫的全部 commit 信息。然而實際上,該命令僅支持導入本地項目,而且不支持導入項目的分支和標籤 ????。
那麼若是咱們想要導入遠程倉庫,或是要獲取某個分支或標籤該怎麼作呢?答案是使用 tomono,其內容是一個 shell 腳本。
使用 tomono 導入遠程倉庫,您所須要作的只有兩件事:
repo 文件內容示例以下:
// 1. Git倉庫地址 2. 子項目名稱 3. 遷移後的路徑 git@github.com/backend.git @mono/backend packages/backend git@github.com/frontend.git @mono/frontend packages/frontend git@github.com/mobile.git @mono/mobile packages/mobile
至此,咱們也掌握了將現有項目遷移至 monorepo 項目的方法。到這時候,您已絕非再是 monorepo 界的門外漢!
恭喜您 !!????
在本篇文章中,咱們共同瞭解了「什麼是 monorepo 策略」以及「monorepo 策略的優劣」,而且一塊兒學習實踐了 monorepo 策略的一些最佳實踐。您必定也意識到,即便您的工做場景暫時沒法實踐 monorepo 策略,閱讀本篇文章所學習到的種種方法,工具和思想也能夠運用到您當下的工做之中。
固然,本文所介紹的這些方法和思想總有過期的一天,而且社區也從未中止對更好地實踐 monorepo 策略的探索,說不定您過一陣子就會有更好的想法 ,填補某個領域的空白。但願到時候您也能總結出一篇文章,爲 JavaScript 社區貢獻一份力量。到時候請千萬別忘了回到個人評論區留言,讓我分享您的成就。
關於 monorepo 這個主題,我就暫且帶您探索到這裏,後會有期:)
阿里巴巴淘系用戶增加團隊正在如飢似渴的尋找志同道合的夥伴,若是您準備好迎接適度的挑戰,在讓更多人喜歡手淘的同時,也讓本身快速成長,歡迎您發送簡歷至個人郵箱:kongtang.lb@alibaba-inc.com,我十分期待收到您的訊息。