這樣用 lerna 也太爽了吧!
Hi,大家好。我是 yxchan🧑💻。不知道大家團隊內的 npm 包代碼是「分開管理」還是「合併管理」的呢?
關於代碼庫管理,一般是兩種常見方案:
-
multirepo: 把項目按模塊拆分爲 多 個倉庫; -
monorepo: 把所有項目放在 單 個倉庫中;
multirepo
-
優點:每個同學都擁有自己的倉庫,可以用自己擅長的「語言」、「工具」、「workflow」等,效率 “高”;
-
缺點:代碼質量得不到保證,如果項目之間存在依賴關係,修復某個項目的
bug需要同步到其他項目,增加溝通成本;
monorepo
-
優點:能夠「統一管理代碼」、「代碼風格」以及「質量」能得到保障;
-
缺點:讓所有同學都走 “同一條路”,效率可能會降 “低”;
以上兩種方式,孰優孰劣是個 “哲學” 問題。但對於團隊內的「公共組件」而言,我認爲monorepo模式更加合理 (效率不一定會更高)。
爲什麼呢?
因爲同一個團隊內,組件之間避免不了會出現相互依賴的情況。設想一下:
有兩個模塊,module-a和module-b,module-b依賴於module-a。 這時發現,module-a有個bug,需要發bugfix版本:
-
multirepo:module-a發佈後,需要手動在module-b上升級module-a的版本。如果有多個module依賴module-a,又或者module-b被module-other所依賴,則會變得非常難維護,很容易遺漏; -
monorepo: 當module-a發佈新版本時,藉助一些工具,就可以根據module間的引用關係,同時發佈依賴於module-a的相關module;
沒錯,一些「工具」指的就是lerna。
lerna
⚠注意:以下內容並不是
lerna的使用教程,👉 「lerna 使用教程 [1]」👈。
是什麼?
The Original Tool forJavaScript Monorepos.
用於 TypeScript/JavaScript
monorepo的原始工具。
怎麼用?
自己去看文檔 [2]。
同學 A:我想發個 npm 包,要怎麼搞?
我:首先clone倉庫下來,然後在packages目錄下創建個文件夾A,然後在A裏npm init,然後配置相關的打包工具..... 開發完後就參照lerna文檔進行發佈......
同學 A:比新建個倉庫還麻煩,npm publish不能發佈嗎?
我:......
確實是,這麼一套下來,比新建倉庫發佈更麻煩,還要去查閱lerna的文檔來怎麼進行構建和發佈。再說,程序員🧑💻怎麼能容忍手動創建文件夾這種事情發生!所以,得想辦法解決掉這些 “麻煩”。最後的解決方案,就是封裝。
封裝
更通俗的說,就是「化繁爲簡」。
封裝的思路主要有 六 部分:
- 創建
$ npm run create
複製代碼
- 安裝
$ npm run link
複製代碼
- 測試
$ npm run test
複製代碼
- 構建
$ npm run build
複製代碼
- 版本
$ npm run version
複製代碼
- 發佈
$ npm run release
複製代碼
這樣,只要知道自己當前的步驟,就不用查看文檔,直接運行 ⬇️ :
$ npm run 「步驟」
複製代碼
一步到位!
演示
接來下,將會演示 lernanpm-yxutils 和 lernanpm-yxtest 的「創建」、「安裝」、「開發」、「測試」、「構建」、「發佈」、「依賴連接」、「同步構建發佈」。
創建
運行 ⬇️ ,創建項目:
$ npm run create
複製代碼
可以看到在packages下,新增了yxtest和yxutils兩個項目。
安裝
運行 ⬇️ ,安裝項目所需的依賴:
$ npm run link
複製代碼
選擇「默認」安裝方式,這樣所有項目的依賴都會被安裝。
開發
添加say方法到yxutils中,⬇️ :
lerna-npm/packages/yxutils/src/features/index.ts
/**
* 打印
* @return {string} val 打印內容
*/
interface Isay {
(val: string): void
}
export let say: Isay
say = (val: string) => {
console.log(val)
}
複製代碼
添加printName方法到yxtest中,⬇️ :
lerna-npm/packages/yxtest/src/features/index.ts
/**
* 打印名字
* @param { string } sFirstName
* @param { string } sFirstName
* @returns { void }
*/
interface IprintName {
(sFirstName: string, sLastName: string): void
}
export let printName: IprintName
printName = (sFirstName: string, sLastName: string) => {
console.log(sFirstName + sLastName)
}
複製代碼
測試
編寫測試代碼
lerna-npm/packages/yxutils/test/index.spec.ts
import { say } from '../src/index'
describe('utils ', () => {
test('This is say test', () => {
const consoleSpy = jest.spyOn(console, 'log')
say('hello')
expect(consoleSpy).toHaveBeenCalledWith('hello')
})
})
複製代碼
lerna-npm/packages/yxtest/test/index.spec.ts
import { printName } from '../src/index'
describe('test ', () => {
test('This is printName test', () => {
const consoleSpy = jest.spyOn(console, 'log')
printName('yx', 'chan')
expect(consoleSpy).toHaveBeenCalledWith('yxchan')
})
})
複製代碼
運行 ⬇️ ,進行jest測試:
$ npm run test
複製代碼
構建
運行 ⬇️ ,進行項目構建:
$ npm run build
複製代碼
選擇「默認」構建方式,這樣所有「未被構建過」 or 「有代碼變動」的項目都會被構建(也可以選擇「自定義」模式,選擇構建「一個」or「多個」指定項目)。
可以看到的yxutils和yxtest目錄下都多了個代碼打包後生成的dist目錄。
版本
緊接着,來到「更新版本號」。在進行這一步之前,要先確保「提交代碼」。
此時,會自動打上tag,並且推送到「遠程倉庫」:
$ git tag
// lernanpm-yxtest@0.0.1
// lernanpm-yxutils@0.0.1
複製代碼
發佈
運行 ⬇️ ,一鍵發佈。
$ npm run release
複製代碼
至此,已經完成發佈一個獨立的「npm 包」的完整流程。
依賴連接
yxtest中的printName方法內其實可以調用yxutils已經封裝好了say方法。
因此,可以在yxtest中連接yxutils,然後使用say方法。
運行 ⬇️ :
$ npm run link
複製代碼
yxtest 爲「目標 Module」,lernanpm-yxutils爲「依賴名稱」,dependencies爲「依賴位置」。
此時,可以在yxtest的package.json中的dependencies看到,lernanpm-yxutils已添加成功。
修改代碼:
lerna-npm/packages/yxtest/src/features/index.ts
import { say } from 'lernanpm-yxutils'
/**
* 打印名字
* @param { string } sFirstName
* @param { string } sFirstName
* @returns { void }
*/
interface IprintName {
(sFirstName: string, sLastName: string): void
}
export let printName: IprintName
printName = (sFirstName: string, sLastName: string) => {
say(sFirstName + sLastName)
}
複製代碼
然後,再跑一遍「測試」、「構建」、「版本」、「發佈」流程。
同步構建發佈
在上一步驟,已經讓yxtest與yxutils建立了連接,接下來嘗試修改yxutils。
lerna-npm/packages/yxutils/src/features/index.ts
/**
* 打印
* @return {string} val 打印內容
*/
interface Isay {
(val: string): void
}
export let say: Isay
say = (val: string) => {
console.log(val.split('').join(''))
}
複製代碼
可以看到,因爲yxtest依賴了yxutils,即使yxtest沒有代碼改動,也被同步「打包」、「構建」、「發佈」了。
實現
項目地址:lerna-npm[3]
創建
主要是爲了解決「在packages目錄下創建個文件夾A,然後在A裏npm init,然後配置相關的打包工具」這個繁瑣的過程。
lerna-npm/scripts/create/index.ts
entry
/**
* 程序入口
* @param {object} payload sModule(模塊名)、sDescription(模塊描述)、sName(作者名稱)
* @returns {void}
*/
interface Ientry {
(payload: { sModule: string; sDescription: string; sName: string }): void
}
let entry: Ientry
entry = ({ sModule, sDescription, sName }) => {
if (!sModule) {
console.log(chalk.red(`[ERROR] The package name can not be empty!`))
return
}
console.log(chalk.blue(`[INFO] Start creating "${sModule}"...`))
const foldPath = createFold(sModule)
if (!foldPath) return
pullLocalTemp(foldPath, sModule, sDescription, sName)
.then(() => {
console.log(
chalk.green(
`[SUCCESS] Congratulations! The "${sModule}" create successfully!`
)
)
})
.catch(() => {
console.log(chalk.red(`[ERROR] Sorry! The "${sModule}" create failed!`))
// 刪除創建失敗的項目
rimraf(foldPath, () => {
console.log(chalk.blue(`[INFO] Delete "${sModule}" package fold!`))
})
})
}
複製代碼
-
通過 inquirer[4] 工具,拿到命令行交互後的數據
sModule、sDescription和sName; -
把相關參數傳入
entry函數; -
創建名爲
sModule的文件夾; -
通過
pullLocalTemp函數寫入模板內容; -
如果項目創建失敗就刪除已創建的文件夾;
接下來看看pullLocalTemp幹了什麼:
pullLocalTemp
/**
* 拉取模板,生成目標項目
* @param {string} sDestpath 文件夾路徑
* @param {string} sModule 模塊名
* @param {string} sDescription 模塊描述
* @param {string} sName 作者名稱
* @returns {Promise<boolean>}
*/
interface IpullLocalTemp {
(
sDestpath: string,
sModule: string,
sDescription: string,
sName: string
): Promise<boolean>
}
let pullLocalTemp: IpullLocalTemp
pullLocalTemp = (
sDestpath: string,
sModule: string,
sDescription: string,
sName: string
) => {
return new Promise((resolve, reject) => {
const metadata = {
pkgName: sModule,
pkgCamelName: toCamel(sModule),
description: sDescription,
name: sName
}
// 把文件轉換爲js對象
Metalsmith(__dirname)
.metadata(metadata) // 需要替換的數據
.source(sTempPath) // 模板位置
.destination(sDestpath) // 目標位置
.use((files, metalsmith, done) => {
// 遍歷需要替換模板
Object.keys(files).forEach(fileName => {
// 需先轉換爲字符串
const fileContentsString = files[fileName].contents.toString()
// 重寫文件內容
files[fileName].contents = Buffer.from(
// 使用定義的metaData取代模板變量
Handlebars.compile(fileContentsString)(metalsmith.metadata())
)
})
done(null, files, metalsmith)
})
.build(function (err) {
if (err) {
console.log(chalk.red(`[ERROR] Metalsmith build error!`))
reject(false)
throw err
}
resolve(true)
})
})
}
複製代碼
這個函數的功能很簡單,就是使用 metalsmith[5] 把相關參數傳入template中,替換掉對應坑位中的內容,然後輸出模板。
目前支持的模板:
-
[x] rollup
-
[ ] glup
-
[ ] webpack
安裝
同學 A: npm i 不能安裝依賴?
該封裝主要是解決依賴「安裝位置」問題以及「鏈接依賴」問題。
設想一下,假如packages下有兩個模塊,module-a和module-b:
-
該模塊都引用了第三方模塊
lodash。如果正常install,則在module-a和module-b的node_modules下都包含lodash,這樣就會造成空間的浪費。針對這種情況,應該把多次引用的第三方模塊提升至頂層的node_modules; -
module-b依賴了module-a,在module-b的代碼中引用module-a暴露的方法。這種情況就比較麻煩了 (npm link),雖然有解決方案,但並不完美;
很幸運,lerna提供了lerna run build,能完美解決以上兩種情況。
所以,要做的就是對lerna run build命令的封裝。很簡單,通過「問答」,拿到「安裝模式」、「依賴名稱」、「項目名稱」、「安裝位置」,再構造lerna命令。
scripts/link/link.ts
/**
* 安裝依賴
* @param {Object} payload sInstallType(安裝模式)、sInstallModule(依賴名稱)、sTargetModule(項目名稱)、sOption(安裝位置)
* @returns {void}
*/
interface Iinstall {
(payload: {
sInstallType: string
sInstallModule?: string
sTargetModule?: string
sOption?: string
}): void
}
let install: Iinstall
install = ({ sInstallType, sInstallModule, sTargetModule, sOption }) => {
// 一鍵安裝
if (sInstallType === 'all') {
run('lerna', ['bootstrap', '--hoist'])
// 自定義安裝
} else {
run(
'lerna',
['add', sInstallModule || '', `--scope=lernanpm-${sTargetModule}`].concat(
sOption === 'normal' ? [] : [`--${sOption}`]
)
)
}
}
複製代碼
有「安裝』,當然也有「卸載」。同理也是通過「問答」的模式,拿到「項目名稱」(需要卸載依賴的項目)、「依賴名稱」,再構造lerna命令。
scripts/link/unlink.ts
/**
* 卸載依賴
* @param {Object} payload sTargetModule(目標項目)、sDelModule(卸載依賴名稱)
* @returns {void}
*/
interface Iuninstall {
(payload: { sTargetModule: string; sDelModule: string }): void
}
let uninstall: Iuninstall
uninstall = ({ sTargetModule, sDelModule }) => {
run('lerna', [
'exec',
`--scope=lernanpm-${sTargetModule}`,
`npm uninstall ${sDelModule}`
])
}
複製代碼
測試
同理。
有兩種測試模式:
-
全量:運行「全部」項目的測試
-
自定義:運行「特定」項目的測試
scripts/test/index.ts
/**
* 測試項目
* @param {Object} payload sTestType(測試模式)、sTargetModule(目標項目)
* @returns {void}
*/
interface Itest {
(payload: { sTestType: string; sTargetModule?: string }): void
}
let test: Itest
test = ({ sTestType, sTargetModule }) => {
// 默認測試方式
if (sTestType === 'all') {
run('lerna', ['run', 'test', '--no-sort'])
// 自定義測試方式
} else {
run('lerna', ['run', 'test', `--scope=lernanpm-${sTargetModule}`])
}
}
複製代碼
構建
同理。
有兩種構建模式:
-
全量:構建「所有」項目
-
自定義:構建「特定」項目
scripts/build/index.ts
/**
* 構建項目
* @param {Object} payload sBuildType(構建模式)、vPackages(項目名稱)
* @returns {void}
*/
interface Ibuild {
(payload: { sBuildType: string; vPackages?: Array<string> }): void
}
let build: Ibuild
build = ({ sBuildType, vPackages }) => {
// 默認構建方式
if (sBuildType === 'all') {
run('lerna', ['run', 'build'])
// 自定義構建方式
} else {
vPackages &&
vPackages.forEach(async pkg => {
await run('lerna', ['run', 'build', `--scope=lernanpm-${pkg}`])
})
}
}
複製代碼
版本
lerna提供的版本號構建命令,可供選擇的參數不多,而且自帶「問答」模式,固毋需對命令再封裝。
"version": "lerna version"
複製代碼
⚠️注意:得益於 nx[6],「有代碼改動」& 與其有「依賴關係」的項目都會被重新構建新的版本號。
發佈
lerna提供的發佈命令,可供的選擇也是不多,也是自帶「問答」模式,亦毋需對命令再封裝。
"release": "lerna publish from-package"
複製代碼
⚠️注意:所有被「新構建過版本號」的項目,都會被髮布。
注意
-
使用
npm run link連接packages的其它模塊時,要確保該模塊與當前模塊是高度耦合的,並且穩定、可靠; -
確保在開發某個模塊時,只改動「當前模塊」的代碼!因爲,任何模塊的代碼改動都會被識別,即使被改動模塊沒有被重新構建,版本亦會被更新發布;
-
若要卸載某個模塊的依賴,可以運行
npm run unlink; -
單獨執行某個模塊的測試,可以運行
npm run test; -
npm run version失敗的情況一般分兩種:
-
沒有提交當前代碼改動:提交當前代碼後即可正常升級版本;
-
手動修改過版本:在
package.json裏修改成「目標版本」,然後刪除「本地」和「遠程」有衝突的tag;
-
刪除某個模塊的特定版本,可以運行
npm unpublish moduleName@version(不建議,直接發個新版本覆蓋更合理),然後刪除本地和遠程相關的tag; -
查看
packages下模塊之間的引用關係,可以npx nx graph;
最後
由於時間關係,項目還有很多可以優化的地方,好比如:支持多模板、豐富命令參數等等。目前只是對lerna最基礎的參數進行封裝,基於「簡單」的原則,很多參數比較少用到,所以並沒有封裝在裏面。但可以直接使用lerna的命令運行。
關於本文
作者:JustCarryOn
https://juejin.cn/post/7134646424083365924
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/4iPOT25vqQCuUWciIJmyzw