基于 lerna 實現 Monorepo 項目管理
隨著團隊建設以及相關業務的日益增長,越來越多的 NPM 包需要多人協同維護,各個項目之間有關聯,就不得不在多個編輯器之間切換,以及通過 npm link 來調試,開發的效率受到制約,那有沒有一種方式可以解決現在的痛點?答案就是 Monorepo!
在字節跳動內部的百科詞條中對于 Monorepo 的定義如下:
Monorepo 是一種將多個項目代碼存儲在一個倉庫里的軟件開發策略。
目前來講,Lerna 作為 JavaScript項目的多包管理器,已經是比較成熟,并已被現代企業所驗證,因此接下來將逐步搭建一個基于 Lerna[1] 的 Monorepo 管理環境,希望可以幫助大家在各司業務中落地并實現降本提效。
根據筆者經驗,Monorepo 將顯著提升開發人員的愉悅度,所以趕緊搞起來吧!
本文主要內容結構如下,朋友們可按需食用:
一、為什么選擇 Lerna
Monorepo 能被定義為策略,那么一定是一種能夠解決問題的方案,基于 Lerna 實現的 Monorepo 多包管理方式,能解決的問題(優點)如下:
- 扁平:同一倉庫(項目)下,統一管理維護多個 package
- 集中:在根目錄的 node_modules/ 文件夾下維護所有 package 的三方依賴
- 簡化:根據文件變動統一執行命令,按需發包,自動升級版本并回寫倉庫、打 tag
- 高效:有互相依賴的項目直接可直接關聯,避免開發人員在多倉庫之間切換
當然,Lerna 經過長時間的使用,一些問題也在生產環境中暴露出來,典型的如:
- 無效構建:每次發包前會對所有的 package 進行構建
- 無效依賴:每次發包都會安裝所有 package 的依賴項
- 幽靈依賴:Phantom dependencies[2] 在依賴提升(hoist)后更加明顯
這里將問題羅列出來,不是說 Lerna 就應該被放棄,而是我們應當清楚技術方案的利與弊,并結合項目的實際情況做一些取舍,上述的缺點只是在構建中不那么優雅,但并不影響 Lerna 作為一種可落地 Monorepo 的方案。
二、初始化一個 Monorepo 形式的項目
我們將從 0 到 1 構建一個純凈的、基于 Lerna 的 Monorepo 項目,并將利于團隊協作規范的 ESlint 校驗,Prettier 自動格式化,以及 git commit message 規范一并完善。
2.1 初始化項目結構
首先就是得全局安裝 Lerna:
- yarn global add lerna
- // or
- npm install lerna -g
然后就是新建項目目錄,并使用 Lerna 初始化一個基本結構
- mkdir dyboy-lerna-project
- cd dyboy-lerna-project/
- lerna init --independent
如此之后,便得到了如下的一個文件目錄結構:
- .
- ├── lerna.json // lerna 的配置文件
- ├── package.json // 當前項目的描述文件
- └── packages/ // 存放所有包的文件夾
Lerna 初始化項目的時候,追加了一個 --independent 的參數,其含義是使用獨立模式。
在 Lerna 中,有兩種模式:
- 固定模式:所有 package 的版本號保持一致,每次更新發包都是全量的
- 獨立模式:每個 package 版本號各自獨立,互不影響,每次更新按需發包
一般我們都會選擇獨立模式,來避免多 package 下頻繁發包的情況出現,尤其是在一些業務變化頻繁的項目下,發包壓力恐怖如斯😱。
2.2 Lerna + Yarn Workspaces
Lerna 默認會使用 NPM 作為包管理器,但使用 yarn 作為 Lerna 的默認包管理器是更推薦的方式。
在 Yarn 1.0 版本,就已經支持了 workspaces 功能,其優勢以及和 Lerna 的關系可以參考當時的這篇文章:《Workspaces[3]》
Yarn Workspaces 相結合,使得 Lerna 方案補齊短板,如虎添翼。
首先是修改 lerna.json 配置,改為如下內容:
- {
- "version": "independent",
- "npmClient": "yarn",
- "useWorkspaces": true
- }
然后在 package.json 文件中指明(新增)workspaces(工作空間)字段:
- + "workspaces": ["packages/*"],
意思就是認為 packages/ 目錄下的所有項目都歸 Lerna + Yarn 管理,這之后,無論我們在哪個文件夾下執行 yarn 都將分析 packages/ 目錄下所有項目的依賴,并安裝到根目錄的 node_modules/ 中。
2.3 ESlint + Prettier + Commit Rules
針對項目需要配置上述的規則,在任一項目中來說都是比較統一的,因之前文章中詳述過相關配置流程,此處便不再贅述。
相關配置規則的初始化和詳細流程可參考:《手摸手學會搭建一個 TS+Rollup 的初始開發環境》中第 5~7 步驟。
經過上述配置好之后,我們的項目就算是大致初始化完成了!
2.4 NPM 團隊賬號
因為發包需要賬號,Monorepo 同時管理了數個、數十個包,都需要維護發包。
如果使用個人賬號發包到公司內自建的 Registry 上,萬一該同學離職了,該倉庫會變成“幽靈倉庫”。
當然,我們可以找公司內部 Registry 維護者直接更改對應包,但總歸是比較麻煩的一件事。
為此可以給團隊申請一個公共賬號,通過 npm token create 創建一個權限 token,放到項目根目錄下的 .npmrc 文件中。
之后無論是哪個開發者維護,都將默認使用團隊賬號發包更新。
最后初始化的項目文件結構如下:
三、版本發布
之前說到過,Lerna 可以統一管理所有的包,因此我們可直接在根目錄的 package.json 文件中指定快捷指令,實現按需發包的功能
注意: Lerna 發包時,會默認忽略掉在 package.json 中設置了 "private": true 的私有包。
3.1 項目打包編譯
在發新的包版本之前,一般是需要打包編譯好產物,在 Monorepo 下的多個包發布前,肯定也是需要先打包。
(1). Learn Run
借助 Lerna 提供的 run 命令,可以實現在發包前,讓所有在 package.json -> scripts 中定義了指令的項目執行該命令
例如,執行:lerna exec build
則會遍歷每一個 package,尋找其 package.json -> scripts 中是否定義了 build 命令,有則執行,否則跳過(在所有包含 build 命令的包中運行 npm run build)。
這樣的方式會存在一個問題:每次發包前,都會把所有 pckage 都先 build 了一遍,增加了打包發布的時間。
那有沒有更優雅的方式吶?
(2). NPM Scripts 生命周期
在 package.json 文件中自定義的 scripts 字段,含有默認的兩個生命周期 pre 和 post
通過執行 npm run build,則會先自動執行npm run prebuid,然后是 npm run build,再者是執行 npm run postbuild。
除了 package.json -> scripts 中自定義的命令,還有 npm 自帶的一些 scripts,比如 npm publish。
npm publish 命令的生命周期包含:
- prepublishOnly
- prepare
- prepublish
- publish
- postpublish
prepare 在 npm publish --dry-run 時不會被執行。
注意:npm 6.x 和 7.x 版本的生命周期有不同,上面是 6.x 版本,考慮到 6.x 和 7.x 版本的差異,建議將發包前的動作放到 prepublishOnly 命令中。
更多可以參考:《scripts - NPM 6.x 官方文檔[4]》 & 《scripts - NPM 7.x 官方文檔[5]》
(3). 按需 Build
有了上面對于 NPM Publish 生命周期的基礎,因此我們可以在需要在發包時候構建的項目(packages/目錄下的項目),在其 package.json -> scripts 中定義如下字段:
- "scripts": {
- "build": "rollup -c",
- "prepublishOnly": "yarn build"
- }
如此,在執行 npm publish 的時候,會先執行 prepublishOnly 中的 yarn build,項目編譯打包,然后再發包。
如此,按需發包就可以很優雅、流暢地搞定了!
3.2 項目發包
到了發包階段,我們在根目錄的 package.json 文件中添加內容:
"scripts": {
- "release": "lerna publish",
- "release:beta": "lerna publish --canary --pre-dist-tag=beta --preid beta --yes"
- },
yarn release 用于發布正式版
yarn relase:beta 則是用于發布測試版本,用于給開發聯調時候測試使用
約定大于配置:在根目錄下的 package.json -> name 字段默認為 root,大家可以理解為“工作根目錄”,如果是有作用域的(scope,例如:@dyboy/utils),可以改名為:@dyboy/root,以便于讓其他開發者知道這是一個有作用域的 Monorepo 項目,盡管 name 字段并沒有什么作用。
總結
基于 Lerna 構建的 Monorepo 項目的心智成本不高,但需要我們對于其中的流程、生命周期、NPM Scripts 等知識有一定的認識和把握,需要構建者能在流程、管理中尋找需求共性和約束規范,為團隊的降本提效落地解決方案。
本文從根據搭建流程來簡述 Monorepo 的一種方案,在前端工程化中,構建者還需要思考是否存在優化空間以及斟酌細節?比如,書寫通俗易懂的 README.md 文檔,思考是否能讓新人更容易上手,嘗試解決流程中的問題并積極探索新的 Monorepo 技術解決方案,比如 Rush、PNPM ...
TODO: 能否實現 Monorepo 自動發正式版本的包?感興趣的朋友可以關注后續的分享嗷!
參考資料
[1]Lerna 官方網站: https://lerna.js.org/
[2]Phantom dependencies - 應用級 Monorepo 優化方案: https://github.com/worldzhao/blog/issues/9
[3]Yarn Workspaces: https://classic.yarnpkg.com/lang/en/docs/workspaces/
[4]scripts - NPM 6.x 官方文檔: https://docs.npmjs.com/cli/v6/using-npm/scripts
[5]scripts - NPM 7.x 官方文檔: https://docs.npmjs.com/cli/v7/using-npm/scripts