對于維護過多個package的同學來說,都會遇到一個選擇題,這些package是放在一個倉庫裡維護還是放在多個倉庫裡單獨維護,本文通過一個示例講述了如何基于Lerna管理多個package,并和其它工具整合,打造高效、完美的工作流,最終形成一個最佳實踐
本文首發于 vivo網際網路技術 微信公衆号 https://mp.weixin.qq.com/s/NlOn7er0ixY1HO40dq5Gag
作者:孔垂亮
目錄
一、背景
二、Monorepo vs Multirepo
三、Lerna
1、Lerna 是什麼
2、開始使用
(1)安裝
(2)項目建構
四、Lerna的最佳實踐
1、優雅的送出
2、自動生成日志
3、編譯、壓縮、調試
五、結語
六、參考文獻
背景
最近在工作中接觸到一個項目,這個項目是維護一套 CLI,發到 npm 上供開發者使用。先看一張圖:
項目倉庫中的根目錄上就三個子子產品的檔案夾,分别對應三個 package,在熟悉了建構和釋出流程後,有點傻了。工作流程如圖中所示:
- 使用webpack、babel和uglifyjs把 pkg-a 的 src 編譯到 dist
- 使用webpack、babel和uglifyjs把 pkg-b 的 src 編譯到 dist
- 使用webpack、babel和uglifyjs把 pkg-main 的 src 編譯到 dist
- 最後使用拷貝檔案的方式,把pkg-main、pkg-a、pkg-b中編譯後的檔案組裝到 pkg-npm 中,最終用于釋出到 npm 上去。
痛點
- **不好調試。**因為最終的包是通過檔案拷貝的方式組裝到一起的,并且都是壓縮過的,無法組建一個自上到下的調試流程(實際工作中隻能加log,然後重新把包編譯組裝一遍看效果)
- **包的依賴關系不清晰。**pkg-a、pkg-b索性沒有版本管理,更像是源碼級别的,但邏輯又比較獨立。pkg-main中的package.json最終會拷貝到 pkg-npm 中,但又依賴pkg-a、pkg-b中的某些包,是以要把pkg-a、pkg-b中的依賴合并到pkg-main中。pkg-main和pkg-npm的package.json耦合在一起,導緻一些本來是工程的開發依賴也會釋出到 npm 上去,變成pkg-npm 的依賴包。
- **依賴的包備援。**可以看到,pkg-a、pkg-b、pkg-main要分别編譯,都依賴了babel、webpack等,要分别 cd 到各個目錄安裝依賴。
- 釋出需要手動修改版本号。 因為最終隻釋出了一個包,但實際邏輯要求這個包即要全局安裝又要本地安裝,業務沒有拆開,導緻要安裝兩遍。耦合一起,即便使用 npm link 也會導緻調試困難,
- 發版沒有 CHANGELOG.md。 因為pkg-a、pkg-b都沒有真正管理版本,是以也沒有完善的CHANGELOG來記錄自上個版本釋出已來的變動。
整個項目像是一個沒有被管理起來的 Monorepo。那什麼又是 Monorepo 呢?
Monorepo vs Multirepo
Monorepo 的全稱是 monolithic repository,即單體式倉庫,與之對應的是 Multirepo(multiple repository),這裡的“單”和“多”是指每個倉庫中所管理的子產品數量。
Multirepo 是比較傳統的做法,即每一個 package 都單獨用一個倉庫來進行管理。例如:Rollup, ...
Monorep 是把所有相關的 package 都放在一個倉庫裡進行管理,每個 package 獨立釋出。例如:React, Angular, Babel, Jest, Umijs, Vue ...
一圖勝千言:
當然到底哪一種管理方式更好,仁者見仁,智者見智。前者允許多元化發展(各項目可以有自己的建構工具、依賴管理政策、單元測試方法),後者希望集中管理,減少項目間的差異帶來的溝通成本。
雖然拆分子倉庫、拆分子 npm 包是進行項目隔離的天然方案,但當倉庫内容出現關聯時,沒有任何一種調試方式比源碼放在一起更高效。
結合我們項目的實際場景和業務需要,天然的 MonoRepo ! 因為工程化的最終目的是讓業務開發可以 100% 聚焦在業務邏輯上,那麼這不僅僅是腳手架、架構需要從自動化、設計上解決的問題,這涉及到倉庫管理的設計。
一個理想的開發環境可以抽象成這樣:
“隻關心業務代碼,可以直接跨業務複用而不關心複用方式,調試時所有代碼都在源碼中。”
在前端開發環境中,多 Git Repo,多 npm 則是這個理想的阻力,它們導緻複用要關心版本号,調試需要 npm link。而這些是 MonoRepo 最大的優勢。
上圖中提到的利用相關工具就是今天的主角 Lerna ! Lerna是業界知名度最高的 Monorepo 管理工具,功能完整。
Lerna
一、Lerna 是什麼
A tool for managing JavaScript projects with multiple packages.
Lerna is a tool that optimizes the workflow around managing multi-package repositories with git and npm.
Lerna 是一個管理多個 npm 子產品的工具,是 Babel 自己用來維護自己的 Monorepo 并開源出的一個項目。優化維護多包的工作流,解決多個包互相依賴,且釋出需要手動維護多個包的問題。
Lerna 現在已經被很多著名的項目組織使用,如:Babel, React, Vue, Angular, Ember, Meteor, Jest 。
一個基本的 Lerna 管理的倉庫結構如下:
安裝
推薦全局安裝,因為會經常用到 lerna 指令
npm i -g lerna
項目建構
1.初始化
lerna init
init 指令詳情 請參考 lerna init
其中 package.json & lerna.json 如下:
// package.json
{
"name": "root",
"private": true, // 私有的,不會被釋出,是管理整個項目,與要釋出到npm的解耦
"devDependencies": {
"lerna": "^3.15.0"
}
}
// lerna.json
{
"packages": [
"packages/*"
],
"version": "0.0.0"
}
2.增加兩個 packages
lerna create @mo-demo/cli
lerna create @mo-demo/cli-shared-utils
create 指令詳情 請參考 lerna create
3.分别給相應的 package 增加依賴子產品
lerna add chalk // 為所有 package 增加 chalk 子產品
lerna add semver --scope @mo-demo/cli-shared-utils // 為 @mo-demo/cli-shared-utils 增加 semver 子產品
lerna add @mo-demo/cli-shared-utils --scope @mo-demo/cli // 增加内部子產品之間的依賴
add 指令詳情 請參考 lerna add
4.釋出
lerna publish
publish 指令詳情 請參考 lerna publish
如下是釋出的情況,lerna會讓你選擇要釋出的版本号,我發了@0.0.1-alpha.0 的版本。
釋出 npm 包需要登陸 npm 賬号
5.安裝依賴包 & 清理依賴包
上述1-4步已經包含了 Lerna 整個生命周期的過程了,但當我們維護這個項目時,新拉下來倉庫的代碼後,需要為各個 package 安裝依賴包。
我們在第4步 lerna add 時也發現了,為某個 package 安裝的包被放到了這個 package 目錄下的 node_modules 目錄下。這樣對于多個 package 都依賴的包,會被多個 package 安裝多次,并且每個 package 下都維護 node_modules ,也不清爽。于是我們使用 --hoist 來把每個 package 下的依賴包都提升到工程根目錄,來降低安裝以及管理的成本
lerna bootstrap --hoist
bootstrap 指令詳情 請參考 lerna bootstrap
為了省去每次都輸入 --hoist 參數的麻煩,可以在 lerna.json 配置:
{
"packages": [
"packages/*"
],
"command": {
"bootstrap": {
"hoist": true
}
},
"version": "0.0.1-alpha.0"
}
配置好後,對于之前依賴包已經被安裝到各個 package 下的情況,我們隻需要清理一下安裝的依賴即可:
lerna clean
然後執行 lerna bootstrap 即可看到 package 的依賴都被安裝到根目錄下的 node_modules 中了。
Lerna的最佳實踐
lerna不負責建構,測試等任務,它提出了一種集中管理package的目錄模式,提供了一套自動化管理程式,讓開發者不必再深耕到具體的元件裡維護内容,在項目根目錄就可以全局掌控,基于 npm scripts,使用者可以很好地完成元件建構,代碼格式化等操作。接下來我們就來看看,如果基于 Lerna,并結合其它工具來搭建 Monorepo 項目的最佳實踐。
一、優雅的送出
1.commitizen && cz-lerna-changelog
commitizen 是用來格式化 git commit message 的工具,它提供了一種問詢式的方式去擷取所需的送出資訊。
cz-lerna-changelog 是專門為 Lerna 項目量身定制的送出規範,在問詢的過程,會有類似影響哪些 package 的選擇。如下:
我們使用 commitizen 和 cz-lerna-changelog 來規範送出,為後面自動生成日志作好準備。
因為這是整個工程的開發依賴,是以在根目錄安裝:
npm i -D commitizen
npm i -D cz-lerna-changelog
安裝完成後,在 package.json 中增加 config 字段,把 cz-lerna-changelog 配置給 commitizen。同時因為commitizen不是全局安全的,是以需要添加 scripts 腳本來執行 git-cz
{
"name": "root",
"private": true,
"scripts": {
"c": "git-cz"
},
"config": {
"commitizen": {
"path": "./node_modules/cz-lerna-changelog"
}
},
"devDependencies": {
"commitizen": "^3.1.1",
"cz-lerna-changelog": "^2.0.2",
"lerna": "^3.15.0"
}
}
之後在正常的開發中就可以使用 npm run c 來根據提示一步一步輸入,來完成代碼的送出。
2.commitlint && husky
上面我們使用了 commitizen 來規範送出,但這個要靠開發自覺使用 npm run c 。萬一忘記了,或者直接使用 git commit 送出怎麼辦?答案就是在送出時對送出資訊進行校驗,如果不符合要求就不讓送出,并提示。校驗的工作由 commitlint 來完成,校驗的時機則由 husky 來指定。husky 繼承了 Git 下所有的鈎子,在觸發鈎子的時候,husky 可以阻止不合法的 commit,push 等等。
// 安裝 commitlint 以及要遵守的規範
npm i -D @commitlint/cli @commitlint/config-conventional
// 在工程根目錄為 commitlint 增加配置檔案 commitlint.config.js 為commitlint 指定相應的規範
module.exports = { extends: ['@commitlint/config-conventional'] }
// 安裝 husky
npm i -D husky
// 在 package.json 中增加如下配置
"husky": {
"hooks": {
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
}
}
"commit-msg"是git送出時校驗送出資訊的鈎子,當觸發時便會使用 commitlit 來校驗。安裝配置完成後,想通過 git commit 或者其它第三方工具送出時,隻要送出資訊不符合規範就無法送出。進而限制開發者使用 npm run c 來送出。
3.standardjs && lint-staged
除了規範送出資訊,代碼本身肯定也少了靠規範來統一風格。
standardjs就是完整的一套 JavaScript 代碼規範,自帶 linter & 代碼自動修正。它無需配置,自動格式化代碼并修正,提前發現風格以及程式問題。
lint-staged staged 是 Git 裡的概念,表示暫存區,lint-staged 表示隻檢查并矯正暫存區中的檔案。一來提高校驗效率,二來可以為老的項目帶去巨大的友善。
// 安裝
npm i -D standard lint-staged
// package.json
{
"name": "root",
"private": true,
"scripts": {
"c": "git-cz"
},
"config": {
"commitizen": {
"path": "./node_modules/cz-lerna-changelog"
}
},
"husky": {
"hooks": {
"pre-commit": "lint-staged",
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
}
},
"lint-staged": {
"*.js": [
"standard --fix",
"git add"
]
},
"devDependencies": {
"@commitlint/cli": "^8.1.0",
"@commitlint/config-conventional": "^8.1.0",
"commitizen": "^3.1.1",
"cz-lerna-changelog": "^2.0.2",
"husky": "^3.0.0",
"lerna": "^3.15.0",
"lint-staged": "^9.2.0",
"standard": "^13.0.2"
}
}
安裝完成後,在 package.json 增加 lint-staged 配置,如上所示表示對暫存區中的 js 檔案執行 standard --fix 校驗并自動修複。那什麼時候去校驗呢,就又用到了上面安裝的 husky ,husky的配置中增加'pre-commit'的鈎子用來執行 lint-staged 的校驗操作,如上所示。
此時送出 js 檔案時,便會自動修正并校驗錯誤。即保證了代碼風格統一,又能提高代碼品質。
二、自動生成日志
有了之前的規範送出,自動生成日志便水到渠成了。再詳細看下 lerna publish 時做了哪些事情:
1.調用 lerna version
- 找出從上一個版本釋出以來有過變更的 package
- 提示開發者确定要釋出的版本号
- 将所有更新過的的 package 中的package.json的version字段更新
- 将依賴更新過的 package 的 包中的依賴版本号更新
- 更新 lerna.json 中的 version 字段
- 送出上述修改,并打一個 tag
- 推送到 git 倉庫
2.使用 npm publish 将新版本推送到 npm
CHANGELOG 很明顯是和 version 一一對應的,是以需要在 lerna version 中想辦法,檢視 lerna version 指令的詳細說明後,會看到一個配置參數 --conventional-commits。沒錯,隻要我們按規範送出後,在 lerna version 的過程中會便會自動生成目前這個版本的 CHANGELOG。為了友善,不用每次輸入參數,可以配置在 lerna.json中,如下:
{
"packages": [
"packages/*"
],
"command": {
"bootstrap": {
"hoist": true
},
"version": {
"conventionalCommits": true
}
},
"ignoreChanges": [
"**/*.md"
],
"version": "0.0.1-alpha.1"
}
lerna version 會檢測從上一個版本釋出以來的變動,但有一些檔案的送出,我們不希望觸發版本的變動,譬如 .md 檔案的修改,并沒有實際引起 package 邏輯的變化,不應該觸發版本的變更。可以通過 ignoreChanges 配置排除。如上。
實際 lerna version 很少直接使用,因為它包含在 lerna publish 中了,直接使用 lerna publish就好了。
Lerna 在管理 package 的版本号上,提供了兩種模式供選擇 Fixed or Independent。預設是 Fixed,更多細節,以及 Lerna 的更多玩法,請參考官網文檔:
https://github.com/lerna/lerna/blob/master/README.md
三、編譯、壓縮、調試
采用 Monorepo 結構的項目,各個 package 的結構最好保持統一。
根據目前的項目狀況,設計如下:
- 各 package 入口統一為 index.js
- 各 package 源碼入口統一為 src/index.js
- 各 package 編譯入口統一為 dist/index.js
- 各 package 統一使用 ES6 文法、使用 Babel 編譯、壓縮并輸出到 dist
- 各 package 釋出時隻釋出 dist 目錄,不釋出 src 目錄
- 各 package 注入 LOCAL_DEBUG 環境變量, 在index.js 中區分是調試還是釋出環境,調試環境 ruquire(./src/index.js) 保證所有源碼可調試。釋出環境 ruquire(./dist/index.js) 保證所有源碼不被釋出。
因為 dist 是 Babel 編譯後的目錄,我們在搜尋時不希望搜尋它的内容,是以在工程的設定中把 dist 目錄排除在搜尋的範圍之外。
接下來,我們按上面的規範,搭建 package 的結構。
首先安裝依賴
npm i -D @babel/cli @babel/core @babel/preset-env // 使用 Babel 必備 詳見官網用法
npm i -D @babel/node // 用于調試 因為用了 import&export 等 ES6 的文法
npm i -D babel-preset-minify // 用于壓縮代碼
由于各 package 的結構統一,是以類似 Babel 這樣的工具,隻在根目錄安裝就好了,不需要在各 package 中安裝,簡直是清爽的要死了。
增加 Babel 配置
// 根目錄建立 babel.config.js
module.exports = function (api) {
api.cache(true)
const presets = [
[
'@babel/env',
{
targets: {
node: '8.9'
}
}
]
]
// 非本地調試模式才壓縮代碼,不然調試看不到實際變量名
if (!process.env['LOCAL_DEBUG']) {
presets.push([
'minify'
])
}
const plugins = []
return {
presets,
plugins,
ignore: ['node_modules']
}
}
修改各 package 的代碼
// @mo-demo/cli/index.js
if (process.env.LOCAL_DEBUG) {
require('./src/index') // 如果是調試模式,加載src中的源碼
} else {
require('./dist/index') // dist會發到npm
}
// @mo-demo/cli/src/index.js
import { log } from '@mo-demo/cli-shared-utils' // 從 utils 子產品引入依賴并使用 log 函數
log('cli/index.js as cli entry exec!')
// @mo-demo/cli/package.json
{
"main": "index.js",
"files": [
"dist" // 釋出 dist
]
}
// @mo-demo/cli-shared-utils/index.js
if (process.env.LOCAL_DEBUG) {
module.exports = require('./src/index') // 如果是調試模式,加載src中的源碼
} else {
module.exports = require('./dist/index') // dist會發到npm
}
// @mo-demo/cli-shared-utils/src/index.js
const log = function (str) {
console.log(str)
}
export { //導出 log 接口
log
}
// @mo-demo/cli-shared-utils/package.json
{
"main": "index.js",
"files": [
"dist"
]
}
修改釋出的腳本
npm run b 用來對各 pacakge 執行 babel 的編譯,從 src 目錄輸出出 dist 目錄,使用根目錄的配置檔案 babel.config.js。
npm run p 用來取代 lerna publish,在 publish 前先執行 npm run b來編譯。
其它常用的 lerna 指令也添加到 scripts 中來,友善使用。
// 工程根目錄 package.json
"scripts": {
"c": "git-cz",
"i": "lerna bootstrap",
"u": "lerna clean",
"p": "npm run b && lerna publish",
"b": "lerna exec -- babel src -d dist --config-file ../../babel.config.js"
}
調試
我們使用vscode自帶的調試功能調試,也可以使用 Node + Chrome 調試,看開發者習慣。
我們就 vscode 為例,請參考 https://code.visualstudio.com/docs/editor/debugging。
增加如下調試配置檔案:
// .vscode/launch.json
{
// 使用 IntelliSense 了解相關屬性。
// 懸停以檢視現有屬性的描述。
// 欲了解更多資訊,請通路: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "debug cli",
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/babel-node",
"runtimeArgs": [
"${workspaceRoot}/packages/cli/src/index.js"
],
"env": {
"LOCAL_DEBUG": "true"
},
"console": "integratedTerminal"
}
]
}
因為 src 的代碼是 ES6 的,是以要使用 babel-node去跑調試,@babel/node 已經在前面安裝過了。
**最棒的是,可以直接使用單步調試,調到依賴的子產品中去,**如上圖,我們要執行 @mo-demo/cli-shared-utils 子產品中的 log 方法,單步進入,會直接跳到 @mo-demo/cli-shared-utils src 源碼中去執行。如下圖
結語
到這裡,基本上已經建構了基于 Lerna 管理 packages 的 Monorepo 項目的最佳實踐了,該有的功能都有:
- 完善的工作流
- 流暢的調試體驗
- 風格統一的編碼
- 一鍵式的釋出機制
- 完美的更新日志
- ……
當然,Lerna 還有更多的功能等待着你去發掘,還有很多可以結合 Lerna 一起使用的工具。建構一套完善的倉庫管理機制,可能它的收益不是一些量化的名額可以衡量出來的,也沒有直接的價值輸出,但它能在日常的工作中極大的提高工作效率,解放生産力,節省大量的人力成本。
——— 參考文獻 ———
- 手摸手教你玩轉 Lerna http://www.uedlinker.com/2018/08/17/lerna-trainning/
- 精讀《Monorepo 的優勢》https://mp.weixin.qq.com/s/f2ehHTNK9rx8jNBUyhSwAA
- 使用lerna優雅地管理多個package https://zhuanlan.zhihu.com/p/35237759
- 用 husky 和 lint-staged 建構超溜的代碼檢查工作流 https://segmentfault.com/a/1190000009546913
更多内容敬請關注 vivo 網際網路技術 微信公衆号
注:轉載文章請先與微信号:labs2020 聯系。
分享 vivo 網際網路技術幹貨與沙龍活動,推薦最新行業動态與熱門會議。