好家伙,這是從零寫了一個(gè)“l(fā)erna”
點(diǎn)擊上方 前端Q,關(guān)注公眾號(hào)
回復(fù)加群,加入前端Q技術(shù)交流群
背景
相信大家多多少少都接觸過(guò)或聽過(guò) monorepo 這個(gè)東西,為了方便多個(gè)項(xiàng)目進(jìn)行調(diào)試、復(fù)用和管理,采取一種單git倉(cāng)庫(kù)管理多個(gè)項(xiàng)目方式,市面上流行的庫(kù)vue、react、bable等都采用 monorepo 方式進(jìn)行管理。帶來(lái)便利的同時(shí),也帶來(lái)了很多挑戰(zhàn)。
我們應(yīng)該以何種方式統(tǒng)一管理這些包呢?包版本升級(jí)的時(shí)候、發(fā)布的時(shí)候、依賴冗余這些問(wèn)題都擺在我們面前。市面上也有很多成熟的解決方案,我司則是選擇了 yarn + lerna 做 monorepo 管理。
pnpm、yarn、npm
node_modules 一直是前端人的頭痛,一個(gè)依賴黑洞魔物。為了解決依賴黑洞問(wèn)題,yarn 和 npm采取平鋪方式,去復(fù)用部分依賴,但其中還是存在著不少問(wèn)題:依賴非法訪問(wèn)、依賴分身等問(wèn)題,面對(duì)多包管理的場(chǎng)景,這種問(wèn)題尤為突出。
這時(shí)候,pnpm 英雄登場(chǎng),pnpm 下載的文件會(huì)統(tǒng)一放到存儲(chǔ)中心,項(xiàng)目里面node_modules 會(huì)硬鏈接到存儲(chǔ)中心,以一種特殊快捷方式存在,而不是直接在磁盤上生成全新的副本,節(jié)省空間。還會(huì)通過(guò)軟鏈鏈接依賴的依賴,有效解決依賴分身這個(gè)問(wèn)題。而且硬鏈接還有一個(gè)特性,當(dāng)存儲(chǔ)中心建立起硬鏈接的文件鏈接數(shù)為0時(shí),還會(huì)自動(dòng)清除該文件,及時(shí)回收磁盤空間,跟WeakSet這些弱引用效果一樣。
定位
上面說(shuō)到了包管理器,大家也看到了 pnpm 的優(yōu)勢(shì),所以我這邊決定給它來(lái)個(gè)大革新,yarn 換成 pnpm,而 lerna 已經(jīng)不維護(hù)了,而且我個(gè)人更喜歡包管理器就做包管理器的事,monorepo 就做 monorepo 該做的事,大部分功能重合,這并不是什么好事,容易帶來(lái)混亂,同一件事,有的人用a去做,有的人用b去做,也許可以制定規(guī)則去規(guī)范大家,但是從一開始就不存在這些事情的話,不是更好嗎
開展
上面給出的定位是只做本分,所以現(xiàn)在要實(shí)現(xiàn)的功能有兩個(gè):version和publish。但是在實(shí)現(xiàn)這部分功能之前,我們要分析這個(gè)項(xiàng)目的包結(jié)構(gòu),為后續(xù)功能做一個(gè)支撐
分析圖譜
假定一個(gè)項(xiàng)目的結(jié)構(gòu)如下????,接下來(lái)要為項(xiàng)目生成一個(gè)分析圖譜對(duì)象
// 假設(shè)下面的包是有依賴關(guān)系,并且b依賴a,c依賴b跟a
- packages
- a
- package.json
- b
- package.json
- c
- package.json
- package.json
復(fù)制代碼
通過(guò)globby把里面所有的packages路徑拿到手,然后調(diào)用fs-extra讀取packages.json,將這些信息轉(zhuǎn)換成下面的 contextAnalysisDiagram
// 每個(gè)包的具體格式
interface AnalysisBlockObject {
packageJson: IPackageJson // package.json
filePath: string // pacakge.json文件路徑
dir: string // 包的路徑
relyMyDir: string[] // 依賴該包的包路徑
myRelyDir: string[] // 該包依賴的包路徑
}
// 項(xiàng)目整體格式
type ContextAnalysisDiagram = Record<string, AnalysisBlockObject>
// 例子:b包的AnalysisBlockObject
{
packageJson: {
"name": "@test/b"
"version": "0.0.0",
"dependencies": {
"@test/a": "workspace:~0.0.0"
}
},
filePath: "packages/b/package.json",
dir: "packages/b",
relyMyDir: [ “packages/c” ] // c依賴了b
myRelyDir: [ “packages/a” ] // b依賴了a
}
// 然后ContextAnalysisDiagram是以dir作為key,去對(duì)應(yīng)每個(gè)包的AnalysisBlockObject
contextAnalysisDiagram = {
"": 根目錄的AnalysisBlockObject,
"packages/a": a包的AnalysisBlockObject,
"packages/b": b包的AnalysisBlockObject,
"packages/c": c包的AnalysisBlockObject,
}
復(fù)制代碼
其中轉(zhuǎn)換比較特別點(diǎn)的是 relyMyDir 和 myRelyDir
// 讀取項(xiàng)目每個(gè)包的package.json以下屬性,把包依賴關(guān)系都提取出來(lái)
const RELY_KEYS = [
'bundleDependencies',
'bundledDependencies',
'optionalDependencies',
'peerDependencies',
'devDependencies',
'dependencies',
]
復(fù)制代碼
正如上面所說(shuō), relyMyDir 是用來(lái)保存所有依賴該包的包路徑數(shù)組,麻煩的是,它不像 myRelyDir 可以在當(dāng)前package.json里面全部找出來(lái)。
常規(guī)的方法是對(duì)包循環(huán)的時(shí)候,再在里面加一個(gè)循環(huán)來(lái)找當(dāng)前包的依賴,但是這種做法會(huì)使時(shí)間復(fù)雜度達(dá)到O(n ^ 2)。所以我這邊做了一個(gè)優(yōu)化,通過(guò)一個(gè)對(duì)象數(shù)組,以每個(gè)包的name作為key,對(duì)應(yīng)的值為數(shù)組,在每次循環(huán)的時(shí)候都對(duì)當(dāng)前包的依賴搜索一遍,發(fā)現(xiàn)匹配到包name,就將當(dāng)前dir push進(jìn)去,這樣就實(shí)現(xiàn)了一次循環(huán),把所有依賴都保存起來(lái)
// 對(duì)象數(shù)組
const relyMyMap = {
"root": [],
"@test/a": [],
"@test/b": [],
"@test/c": [],
}
// dirs:包的路徑數(shù)組。
dirs.forEach((dir, index) => {
**偽代碼**
// 用來(lái)獲取當(dāng)前dir的packageJson依賴,把命中的依賴放到relyMyMap對(duì)應(yīng)的數(shù)組
setRelyMyDirhMap(dir, packageJson, relyMyMap)
**偽代碼**
this.contextAnalysisDiagram[dir] = {
packageJson,
dir,
filePath: filesPath[index],
// 賦值到relyMyDir,因?yàn)槭且妙愋停罄m(xù)命中的依賴都會(huì)出現(xiàn)在對(duì)應(yīng)的relyMyDir
relyMyDir: relyMyMap[packageJson.name],
myRelyDir,
}
})
復(fù)制代碼
模式
lerna 有固定模式和獨(dú)立模式,而我這邊對(duì)應(yīng)的是sync 模式和diff模式,大同小異。
命令
// lerna的使用方法
lerna version
lerna publish
// 我為當(dāng)前工具起了個(gè)pkgs命令名
pkgs version
pkgs publish
復(fù)制代碼
如何定義一個(gè)命令行工具呢
創(chuàng)建一個(gè)bin目錄,在里面創(chuàng)建一個(gè)index.js。第一句的意思是告訴系統(tǒng)用node去執(zhí)行這個(gè)文件
#!/usr/bin/env node
console.log('abmao')
復(fù)制代碼
在package.json定義bin
// package.json
{
"bin": {
"pkgs": "bin/index.js" // 指向bin目錄
},
}
復(fù)制代碼
屬于你的命令實(shí)現(xiàn)了

我先攤牌,下面是已經(jīng)實(shí)現(xiàn)了的命令函數(shù),使用commander處理復(fù)雜命令和友好提示
#!/usr/bin/env node
const { program } = require('commander')
const {
executeCommand,
executeCommandTag,
executeCommandInit,
executeCommandRun,
} = require('../dist/pkgs.cjs.min') // 打包好的構(gòu)建物
const pkg = require('../package.json')
function handleExecuteCommand (type, cmd) {
executeCommand(type, {
[type]: cmd,
})
}
program
.version(pkg.version)
.description('Simple monorepo combined with pnpm')
program
.command('version')
.description('version package')
.option('--mode <type>', 'sync | diff')
.option('-m, --message <message>', 'commit message')
.action(cmd => {
// pkgs version
handleExecuteCommand('version', cmd)
})
program
.command('publish')
.description('publish package')
.option('--mode <type>', 'sync | diff')
.option('--tag <type>', 'npm publish --tag <type>')
.option('-m, --message <message>', 'commit message')
.action(cmd => {
// pkgs publish
handleExecuteCommand('publish', cmd)
})
program
.command('tag')
.description('pkgs tag, diff mode: Compare according to tag')
.option('-p', 'publish tag')
.option('-v', 'version tag')
.action(cmd => {
// pkgs tag
executeCommandTag(cmd)
})
program
.command('init')
.description('create pkgs file')
.action(() => {
// pkgs init
executeCommandInit()
})
program
.command('run <cmd> [mode]')
.description('run diff scripts.\n mode: work | stage | repository, default: work')
.option('-r <boolean>', 'Include rootPackage', 'true')
.action((cmd, mode, option) => {
// pkgs run <cmd>
executeCommandRun(cmd, mode, option.r !== 'false')
})
program.parse(process.argv)
復(fù)制代碼
version 命令
version 命令是用來(lái)升級(jí)各個(gè)包的版本。一開始我是用了antfu大佬的bumpp來(lái)做命令行交互,但是這個(gè)庫(kù)每次選擇完版本都會(huì)寫進(jìn)package.json文件,我更想要的是在內(nèi)存里面獲取到newVersion,然后等所有包都選擇完版本后,再一次過(guò)寫進(jìn)去。因此我提交pr的同時(shí)也publish了一個(gè)新庫(kù),今天看了下,pr還沒(méi)人理,估計(jì)沒(méi)怎么關(guān)注這個(gè)庫(kù)了

sync 模式
sync模式是提供給組織性質(zhì)比較強(qiáng)的項(xiàng)目使用,同時(shí)迭代,同時(shí)發(fā)布。調(diào)用命令的時(shí)候,只會(huì)選擇一次版本,然后同步所有包的package.json,最后git commit并打上git tab
pkgs version
// 默認(rèn)是sync,可以省略后面這段
pkgs version --mode sync
復(fù)制代碼
diff 模式
diff模式只會(huì)對(duì)修改過(guò)和被修改影響到的包觸發(fā),對(duì)每個(gè)需要版本升級(jí)的包都單獨(dú)選擇一次版本,然后分別寫到對(duì)應(yīng)的package.json
pkgs version --mode diff
復(fù)制代碼
問(wèn):那么它是如何找到上述包進(jìn)行版本升級(jí)呢?
答:首先它會(huì)根據(jù)git提交的最新記錄和最近的一個(gè)gitTab,拿到之間修改過(guò)的文件,再通過(guò)bump當(dāng)前包獲取到newVersion,再與contextAnalysisDiagram分析relyMyDir里面的依賴關(guān)系,根據(jù)版本語(yǔ)義化*、^、~等進(jìn)行判斷是否要升級(jí)該依賴,是的話,會(huì)調(diào)用bump,如此類推。當(dāng)然,還要把已經(jīng)升級(jí)過(guò)的包儲(chǔ)存到一個(gè)Set里面,防止多次調(diào)用bump。最后git commit并打上git tab
publish 命令
對(duì)于需要發(fā)布到npm上的包,提供了publish命令,用于包發(fā)布
pkgs publish
// 添加npm標(biāo)簽
pkgs publish --tag beta
復(fù)制代碼
對(duì)@開頭的組織包,還會(huì)在末尾自動(dòng)添加上 --access public如果是 0.0.8-beta.1這種帶標(biāo)簽的版本號(hào),會(huì)自動(dòng)為其添加標(biāo)簽。當(dāng)然你可以通過(guò)--tag手動(dòng)添加
同時(shí)publish命令也是有sync模式和diff模式,sync模式對(duì)于publish來(lái)說(shuō)比較雞肋,所以一般都是diff,或者是配合sync的version一起使用。而publish的diff沒(méi)有version那么復(fù)雜,只需要拿到最新commit和第一個(gè)gittag就可以,并不需要計(jì)算影響這一步。
插曲
了解過(guò)lerna的小伙伴可能意識(shí)到,這不就是lerna的實(shí)現(xiàn)原理嗎?雀實(shí),我有個(gè)壞習(xí)慣,對(duì)于不熟悉的領(lǐng)域我都喜歡擼了再說(shuō),我比較喜歡那種兩倍痛苦帶來(lái)的兩倍快樂(lè),我事先是閉門造車的思考了這套方案,等我實(shí)現(xiàn)了的時(shí)候,發(fā)現(xiàn)跟 lerna 一模一樣,是壞消息的同時(shí)也是好消息。其實(shí)一不一樣我都是可以接受,不一樣,可以得到兩套思想的碰撞??,你可以非常非常地深入到兩種思想中,探討其中的優(yōu)劣,如果能得出更好的方案,更是一種思考上的升華。一樣的話,是不是可以證明,已經(jīng)達(dá)到了一定的水平了呢?(自吹自擂ing
tag 命令
問(wèn):為什么會(huì)有 tag 這個(gè)命令呢?
答:上述的 version 和 publish 命令的 diff 模式都是基于git tag打上跟 pkgs 定義好的tag實(shí)現(xiàn)的,為了讓 lerna 之類的項(xiàng)目無(wú)痛遷移到 pkgs,保證正確的 diff,放出了 tag 命令,先對(duì)項(xiàng)目使用 tag 命令,就可以保證后面正確的 diff
git tag // 打上publish標(biāo)簽和version標(biāo)簽
git tag -p // 打上pbulish標(biāo)簽
git tag -v // 打上version標(biāo)簽
復(fù)制代碼
init 命令
創(chuàng)建pkgs相關(guān)模板,如下。當(dāng)然,創(chuàng)建的時(shí)候先檢察當(dāng)前文件目錄是否已經(jīng)存在,存在會(huì)跳過(guò)當(dāng)前文件目錄的創(chuàng)建
pkgs init
復(fù)制代碼
- packages
- package.json
- pkgs.json
復(fù)制代碼
自定義命令
一個(gè)經(jīng)常有的場(chǎng)景,修改了包后,其他很多包依賴著這個(gè)包,運(yùn)行測(cè)試的時(shí)候有兩種
全部運(yùn)行npm run test 指定某些包npm run test
這兩種方法都不好,浪費(fèi)、繁雜、并且人工指定容易出錯(cuò)。
如果有一個(gè)功能,繼承了上面說(shuō)的 diff 功能,并且可以對(duì)工作區(qū)、暫存區(qū)和版本區(qū)進(jìn)行分析,只對(duì)修改過(guò)和被影響的包運(yùn)行npm命令,這聽起來(lái)是不是很棒(??????)??
有沒(méi)有一種可能,pkgs 已經(jīng)實(shí)現(xiàn)了。是的,已經(jīng)提供了該功能啦~??
// 可以看成是monorepo版本的`npm run`
pkgs run <cmd>
// 測(cè)試
pkgs run test
// 默認(rèn)是工作區(qū),可以忽略wokr
pkgs run test work
// 暫存區(qū)
pkgs run test stage
// 版本區(qū)
pkgs run test repository
復(fù)制代碼
優(yōu)先排序
還有一種場(chǎng)景,a包依賴b包,那么build的時(shí)候,正確的順序是等b包完成構(gòu)建才到a包構(gòu)建,為了實(shí)現(xiàn)這個(gè)功能,要對(duì)contextAnalysisDiagram的myRelyDir進(jìn)行分析:
找到當(dāng)前包依賴的包,然后將當(dāng)前包塞進(jìn)去stack,同時(shí)還要?jiǎng)?chuàng)建一個(gè)result數(shù)組,用來(lái)保存包的順序 對(duì)依賴包循環(huán),找到該依賴包是否也有依賴,如此類推,有依賴,則繼續(xù)遞歸 那么如何才停止遞歸呢?只需要判斷該依賴是否在stack或者result里面,如果是在stack里面,則彈出一個(gè),放到result里面,如果result已經(jīng)保存有,則忽略。同時(shí)會(huì)跳過(guò)對(duì)該依賴的搜索。
這樣就拿到所有包的運(yùn)行順序,解決了上述場(chǎng)景的問(wèn)題
配置
為了減少命令行輸入的參數(shù),一般都會(huì)有一個(gè)對(duì)應(yīng)的配置文件,把參數(shù)都預(yù)先都寫上去
pkgs.json
放到根目錄即可
{
rootPackage: true, // 自定義命令是否包括根目錄
mode: 'sync', // sync | diff 模式
version: {
mode: undefined, // sync | diff 模式,更高的優(yōu)先級(jí)
message: 'chore: version', // version命令后的commit message
},
publish: {
mode: undefined, // sync | diff 模式,更高的優(yōu)先級(jí)
tag: '', // npm包標(biāo)簽
},
}
復(fù)制代碼
pnpm-workspace.yaml
pkgs會(huì)讀取pnpm-workspace.yaml文件的工作區(qū),默認(rèn)是packages/**
構(gòu)建
使用rollup進(jìn)行打包,因?yàn)橛玫氖莟s,所以一開始是使用了esno進(jìn)行運(yùn)行
依賴循環(huán)
在構(gòu)建過(guò)程中發(fā)現(xiàn)很多cjs的老牌包存在依賴循環(huán)的包,因?yàn)樯婕暗膱?chǎng)景太多了,提pr改的話是不會(huì)采納的,我是直接fork出來(lái),調(diào)整重新發(fā)包
esbuild
用了esbuild是真的快,而且還可以運(yùn)行ts。上手成本非常低,但是效果杠杠的
測(cè)試
其實(shí)一開始測(cè)試這種類型我是懵逼的,如何測(cè)試git?如何測(cè)試命令行?在我觀摩了多個(gè)相關(guān)開源項(xiàng)目的測(cè)試后總結(jié)的一種方式:
測(cè)試git:為了保證不污染環(huán)境,使用node的臨時(shí)目錄,在其創(chuàng)建項(xiàng)目模板,使用 process.chdir切換工作目錄到臨時(shí)目錄上,就ok了測(cè)試命令行:總不能測(cè)試的時(shí)候,要命令行交互選擇版本號(hào)吧,給npm那邊publish吧。查看了 bumpp測(cè)試代碼,發(fā)現(xiàn)可以直接輸入版本號(hào),繞過(guò)命令行交互形式。publish的話,我則是直接修改源碼,判斷當(dāng)前環(huán)境是否測(cè)試,是的話,就不走命令,而是返回命令的字符串,我再進(jìn)行對(duì)比。
測(cè)試工具
一開始用的是老牌測(cè)試框架jest,但是對(duì)ts的支持還是試驗(yàn)性,用起來(lái)非常的麻煩。正好最近關(guān)注vitest,就直接拿來(lái)用,因?yàn)檎Z(yǔ)法一樣,切換成本也是非常的低,而且開箱就支持了ts,妙啊
除了那些不必要的分支,基本都覆蓋到所有功能了。
發(fā)布
pkgs的版本管理和發(fā)布,都是用了本身的version和publish邏輯去管理,而pkgs并不是一個(gè)多包項(xiàng)目,所以這個(gè)庫(kù)是無(wú)論你是多包還是單包,都可以使用。
// release.ts
import { execSync } from 'child_process'
import colors from 'colors'
import { executeCommand } from '../index'
console.log(`${colors.cyan.bold('release: start')} ??`);
(async function () {
// 運(yùn)行測(cè)試
execSync('npm run test', { stdio: 'inherit' })
// 打包
execSync('npm run build', { stdio: 'inherit' })
// 版本選擇,相當(dāng)于pkgs version
await executeCommand('version')
// 發(fā)布,相當(dāng)于pkgs publish
await executeCommand('publish')
})()
console.log(`${colors.cyan.bold('release: success')} ??????????`)
復(fù)制代碼
// 這樣就可以開始發(fā)布啦
npm run release
// 安裝
npm i -g @abmao/pkgs
復(fù)制代碼
TODO
接下來(lái)記錄下想解決的事,或者說(shuō)大伙有啥想法的不?
構(gòu)建物過(guò)大
真的非常大,而且還存在著依賴循環(huán)問(wèn)題,愁啊,有空看下解決,依賴大估計(jì)是fs-extra的問(wèn)題
狀態(tài)分析
后面想要實(shí)現(xiàn)一個(gè)state的功能,查看包之間的狀態(tài)
pkgs state
復(fù)制代碼
謝幕
也許你可以來(lái)個(gè)贊或者評(píng)論或者星,沒(méi)有的話,下次再問(wèn)
pkgs-github:https://github.com/hengshanMWC/pkgs
關(guān)于本文
來(lái)自:科目三后吃飯
https://juejin.cn/post/7084596115177209886

往期推薦



最后
歡迎加我微信,拉你進(jìn)技術(shù)群,長(zhǎng)期交流學(xué)習(xí)...
歡迎關(guān)注「前端Q」,認(rèn)真學(xué)前端,做個(gè)專業(yè)的技術(shù)人...


