今年初隨著公司的 repo 越來越多,我們決定把 web 前端部分轉為 monorepo 的形式,一開始花了一段時間研究各個 monorepo 方案的利弊,最後決定基於 Yarn 2 打造一套自用的工具。這篇文章會大概分析一些我試過的 monorepo 方案的優缺點,以及最後用 Yarn 2 的成果。
現有 Monorepo 方案
Lerna
Lerna 是我一開始比較熟悉的方案,在 Kosko 和 kubernetes-models-ts 都有用到,算是 JavaScript monorepo 非常普遍的選擇。
- 👍 支援 npm,也可以善用 Yarn 提供的 workspace 功能。
- 👍 可以偵測檔案變動,只更新並發佈有變動的 npm packages。
- 💩 主要是設計用來「發佈到 npm」的,如果是內部使用的話,並不需要用到這功能,必須得客製
lerna version
才能符合我們的需求。
Yarn
- 👍 本身就內建了 workspace 功能,對於 monorepo 有最基本的支援。
- 👍 效能好,會把共用的 dependencies 抽到最上層的
node_modules
共用避免浪費空間。 - 💩 如果要在 workspace 之間互相引用的話,
yarn workspace @scope/a add @scope/b
總是會試圖從 npm 下載 package,而不是先安裝 local 版本 (yarnpkg/yarn#4878)。
pnpm
- 👍 本身就內建了 workspace 功能,相較於 Yarn 1 來說更強大一點。
- 👍 能夠用
pnpmfile.js
客製pnpm install
的行為,可用來限制 dependencies 版本或是竄改package.json
。 - 💩 相較於 npm 和 Yarn 來說比較小眾,使用前必須先安裝。如果是 Yarn 的話,CI 和 Docker image 均有內建。
Rush
Rush 是微軟推出的 JavaScript monorepo 方案,設計更加嚴謹且繁瑣。
- 👍 可以同時支援 npm、Yarn 和 pnpm,官方建議選用 pnpm。
- 👍 可指定跨 workspace 之間的 dependencies 版本,避免衝突。
- 👍 可以避免漏裝 dependencies。Yarn 會把共用 dependencies 都裝到最上層的
node_modules
,因此所有 workspace 都能直接引用,即便沒有寫在package.json
裡。pnpm 則是會把所有 dependencies 都裝到另外的資料夾,再用 symlink 連結到各個 workspace 的node_modules
。 - 👍 能夠自動偵測 workspaces 之間的相依性,決定編譯順序,並實作平行編譯、增量編譯。
- 💩 必須手動指定所有 workspace 的路徑。
- 💩 有些功能實際上必須依賴於 pnpm,因此得先安裝 pnpm。
Bazel
Bazel 是 Google 推出的跨語言 monorepo 方案,很強大也很複雜,對於我們來說,只是要支援 JavaScript 卻要寫這麼多設定,實在讓人頭痛。
- 👍 能夠快取並增量編譯。
- 👍 能夠處理編譯、測試、部署,可以說是一條龍的方案。
- 💩 有獨特的 DSL 和生態系,學習成本很高,除非像 Angular 有現成的套件,否則設定很花時間。
Yarn 2
在我研究的這段期間,Yarn 2 剛好推出了 RC 版,相較於 Yarn 1 變化非常大,詳細內容可以參考 Introducing Yarn 2。
更完善的 Workspace 支援
現在 yarn workspaces foreach
的功能更完善,有點接近 Lerna。
yarn workspaces foreach --parallel --interlaced --topological run ...
Workspace 之間相互引用時,不再出現上面提到的 yarn add
問題。
yarn workspace @scope/a add @scope/b
限制 dependencies 版本
透過新功能 Constraints,可以限制 dependencies 的版本,在官方的 constraints.pro
可以看到許多有趣的範例。
例如下面這段可以用來確保每個 workspace 所用的 dependencies 版本統一。
gen_enforced_dependency(WorkspaceCwd, DependencyIdent, DependencyRange2, DependencyType) :-
workspace_has_dependency(WorkspaceCwd, DependencyIdent, DependencyRange, DependencyType),
workspace_has_dependency(OtherWorkspaceCwd, DependencyIdent, DependencyRange2, DependencyType2),
DependencyRange \= DependencyRange2.
容易擴充
所有功能幾乎都是以擴充套件的形式實作的,官方本身提供了一些非常好用的擴充套件。我們用到了:
- constraints - 提供了上面提到的 constraint 功能。
- typescript - 在安裝 dependencies 的時候順便安裝對應的
@types/
套件。 - workspace-tools - 提供了上面提到的
yarn workspaces foreach
功能。
如果要自己實作擴充套件也非常簡單,透過 Yarn 2 的 API 可以輕鬆地得到每個 workspace 的狀態。我們自己也實作了一些簡單的擴充套件:
- changed - 偵測有變動的 workspaces。
- tsconfig-references - 在安裝 dependencies 的時候順便更新
tsconfig.json
的references
。
Zero-Installs
Yarn 2 預設會啟用 Zero-Installs (Plug’n’Play),也就是把所有 dependencies 安裝到 .yarn
資料夾,完全消滅了 node_modules
的存在,藉此解決效能和 node_modules
占用太多硬碟空間的問題。
這個功能需要 toolchain 的配合,因為它徹底改寫了 Node.js 的 module resolution 機制,雖然目前很多主流的工具都支援了 PnP,但是 VSCode 目前沒有辦法預覽套件內容,因為 Yarn 2 用 zip 儲存套件,VSCode 雖然能夠解析路徑,但無法讀取 zip 檔的內容 (microsoft/vscode#75559)。
目前的解法是關閉 Zero-Installs 功能,在 .yarnrc.yml
設定 nodeLinker
即可。
nodeLinker: node-modules
速度
Yarn 2 比起 Yarn 1 也並非完全沒有缺點,Yarn 2 在 yarn install
會切分成多個步驟,分別是 Resolution、Fetch、Link。Resolution 和 Fetch 得益於新的設計會把所有 packages 儲存在 .yarn/cache
資料夾所以非常快,但是 Link 階段就慢一些,平均大概需要 30 秒至一分鐘以上,或許開啟 Zero-Installs 會快一些?
Vendorizing
Yarn 2 會在 .yarn
儲存用 Webpack 編過的 Yarn 本體和擴充套件,大約佔 3 MB,這樣的好處是可以確保在不同環境下使用的 Yarn 版本都完全相同,缺點就是在 repo 裡會多了一些額外的檔案。
Yarn 官方更是把整個 .yarn/cache
資料夾都 commit 到 Git 上,這樣的好處或許是能夠直接省去 fetch packages 的時間,但 git clone 的時間應該也會更長。
部署流程
我們把部屬流程分成了三塊:測試→編譯→發佈。
測試
在這個階段會對整個 monorepo 進行 lint 和 unit tests,目前整個過程需時大約不到 3 分鐘,所以沒有拆開來執行。
編譯
這個階段相對來說非常耗時,在執行前會用 yarn changed
來檢查 workspace 以及其依賴的套件有沒有變動,如果沒有的話就會直接跳過不做,藉此可以省下時間和成本。在圖中可以看到有些 job 花的時間特別短,就是因為那些沒有變動的部分都直接跳過了。
檢查的 script 如下,只需要三行,非常簡短。
if ! yarn changed list --git-range "$GIT_COMMIT_RANGE" | grep -q "$WORKSPACE_NAME"; then
circleci-agent step halt
fi
在編譯完成後,會將 Docker image 部屬到測試環境,確保測試環境和 master branch 同步。
發佈
最後要發佈到正式環境時,會利用 semantic-release 更新版號,把測試環境的 Docker image 複製到正式環境上,一切就大功告成了。