深入 lerna 發(fā)包機(jī)制 —— lerna version
最近在公司做的 monorepo 相關(guān)工具開發(fā)的時(shí)候有涉及到這方面的內(nèi)容,于是對這方面進(jìn)行了一些研究。
lerna 官網(wǎng)關(guān)于 lerna 的描述是這樣的:
A tool for managing JavaScript projects with multiple packages.
本質(zhì)上 lerna 是一個(gè)用來管理 monorepo 項(xiàng)目的工具,主要解決了下面的問題:
將大型代碼倉庫分割成多個(gè)獨(dú)立版本化的 軟件包(package)對于代碼共享來說非常有用。但是,如果某些更改 跨越了多個(gè)代碼倉庫的話將變得很 麻煩 并且難以跟蹤,并且, 跨越多個(gè)代碼倉庫的測試將迅速變得非常復(fù)雜。
為了解決這些(以及許多其它)問題,某些項(xiàng)目會將 代碼倉庫分割成多個(gè)軟件包(package),并將每個(gè)軟件包存放到獨(dú)立的代碼倉庫中。但是,例如 Babel、 React、Angular、Ember、Meteor、Jest 等項(xiàng)目以及許多其他項(xiàng)目則是在 一個(gè)代碼倉庫中包含了多個(gè)軟件包(package)并進(jìn)行開發(fā)。
今天我們主要來講解一下關(guān)于 lerna 是怎么去完成一個(gè) monorepo 項(xiàng)目中的發(fā)包操作的。
lerna 發(fā)包設(shè)計(jì)到兩個(gè)比較關(guān)鍵的指令分別為 lerna version 和 lerna publish 這兩個(gè)指令。
因?yàn)槠颍覍⒎譃閮蓚€(gè)系列去分別介紹這兩個(gè)指令,本篇文章將從 lerna version 開始,在下篇中我會介紹一下 lerna publish 的工作原理,相信在我介紹完成之后,大家都會對 lerna 的整個(gè)發(fā)包機(jī)制有個(gè)比較清晰的了解。
lerna version
根據(jù)官方倉庫的介紹, lerna version 主要的工作為標(biāo)識出在上一個(gè) tag 版本以來更新的 monorepo package,然后為這些包 prompt 出版本,在用戶完成選擇之后修改相關(guān)包的版本信息并且將相關(guān)的變動(dòng) commit 然后打上 tag 推送到 git remote。本質(zhì)上是為一些發(fā)生變動(dòng)的包進(jìn)行了一個(gè) bump version 的操作,當(dāng)然這里用戶也可以將這一過程自動(dòng)化掉,利用 --conventional-commits 這個(gè) api 就可以將這一過程自動(dòng)化掉,常見的場景是可以在寫在一些 CI Config 里面來達(dá)到自動(dòng)發(fā)包的目的。
同時(shí)它本身還提供了一些相關(guān)的 api,感興趣可以去倉庫或者官方文檔查詢,這里不做詳細(xì)的介紹。
lerna version 本身在 lerna 中是一個(gè)很重要的指令,有許多其他的指令例如在 lerna 中 two primary commands 之一的 lerna publish 都是基于該指令進(jìn)行相關(guān)的工作。在開始實(shí)現(xiàn)之前先放出一個(gè) lerna version 的整體原理圖。

那我們直接開始來看一下 lerna version 的具體實(shí)現(xiàn),具體源碼地址為: https://github.com/lerna/lerna/tree/main/commands/version
lerna 作為一個(gè) monorepo 工具,其本身里面的包也是采用 monorepo 的形式來管理,因此我們可以直接去看 version 的代碼結(jié)構(gòu)為:
.
├── CHANGELOG.md
├── README.md
├── __tests__ // 測試相關(guān)目錄
├── command.js // cli 的入口文件,lerna version 的一些選項(xiàng)都可以在其中看到
├── index.js // version 執(zhí)行邏輯相關(guān)的函數(shù)文件
├── lib // 相關(guān)的工具函數(shù),根據(jù)名稱可以推斷出其功能
│ ├── __mocks__
│ ├── create-release.js
│ ├── get-current-branch.js
│ ├── git-add.js
│ ├── git-commit.js
│ ├── git-push.js
│ ├── git-tag.js
│ ├── is-anything-committed.js
│ ├── is-behind-upstream.js
│ ├── is-breaking-change.js
│ ├── prompt-version.js
│ ├── remote-branch-exists.js
│ └── update-lockfile-version.js
└── package.json
首先可以在 command.js 文件中看到 lerna version 在 cli 中使用的時(shí)候提供的一些 標(biāo)準(zhǔn) Options 例如 allow-branch、conventional-commits 、 ignore-changes 等選項(xiàng)。
具體的執(zhí)行邏輯在 index.js 這個(gè)目錄中實(shí)現(xiàn)。
順便一提,這里關(guān)于 lerna 的命令是怎么進(jìn)行分發(fā)以及執(zhí)行的,這部分相關(guān)的邏輯可以參考 @lerna/command 這個(gè)包即源碼目錄 lerna/core/cli/command 中相關(guān)內(nèi)容。lerna monorepo 一些核心相關(guān)概念都在 core 這個(gè)目錄下面,其中會包括一些例如 monorepo 依賴圖的構(gòu)建等內(nèi)容,感興趣可以去研究一下。
因此現(xiàn)在我們開始看 index.js 相關(guān)的內(nèi)容。
這個(gè)文件的主要導(dǎo)出了一個(gè)叫做 VersionCommand 類的實(shí)例,相關(guān)的邏輯則在類中實(shí)現(xiàn),大致的結(jié)構(gòu)為:
class VersionCommand extends Command {
// 一些 options 的檢查和整合
configureProperties() {}
// 初始化
initialize() {}
// 執(zhí)行邏輯
execute() {}
// 其他部分就是一些類里面的工具函數(shù)例如 getVersionsForUpdates
// ...
}
因此 lerna version 由上面的內(nèi)容可以看出整個(gè)執(zhí)行周期分成三個(gè)部分 設(shè)置屬性 -> 初始化 -> 執(zhí)行。
設(shè)置屬性(configureProperties)
首先我們先看設(shè)置屬性這一部分,這部分其實(shí)很簡單,它會檢查一些傳遞進(jìn)來的選項(xiàng)是否符合規(guī)范(例如 --create-release 只有和 --conventional-commits 放在一起才能執(zhí)行,否則會報(bào)錯(cuò))。然后把一些 git 相關(guān)的options 全部整合到了一個(gè)叫做 gitOptions 的對象里面去了。這里簡單貼一些代碼參考一下:
// 檢驗(yàn) --create-release 使用場景
if (this.createRelease && this.options.conventionalCommits !== true) {
throw new ValidationError("ERELEASE", "To create a release, you must enable --conventional-commits");
}
if (this.createRelease && this.options.changelog === false) {
throw new ValidationError("ERELEASE", "To create a release, you cannot pass --no-changelog");
}
// 這里是把一些用戶傳進(jìn)來的 git 相關(guān)參數(shù)放在 gitOptions 中
this.gitOpts = {
// amend 用來跳 lerna version 的 git push 的,默認(rèn)為 true, --amend 用戶傳參數(shù)
amend,
commitHooks,
granularPathspec,
signGitCommit,
signGitTag,
forceGitTag,
};
// 用戶傳了 --exact 就不用給 version 設(shè)置 ^ 前綴
// npm 包的 version 前綴相關(guān)可以參考: https://docs.npmjs.com/misc/config#save-prefix
this.savePrefix = this.options.exact ? "" : "^";
初始化(initialize)
在正式進(jìn)入 bump version 過程中時(shí),會有一個(gè)初始化的過程,初始化過程中首先會檢驗(yàn)一些 monorepo 子 packages 一些 git 倉庫相關(guān)的設(shè)置,拿到需要更新包,然后執(zhí)行到確認(rèn)包的版本信息這一步,有一些相對關(guān)鍵的操作都是在一步完成的,等下我們可以詳細(xì)分析一下:
大致的執(zhí)行邏輯有:
initialize() {
// ...
// 1. git 倉庫相關(guān)的檢驗(yàn)
if (this.requiresGit) {
} else {
// 跳過 git 倉庫相關(guān)的校驗(yàn),給個(gè)提示
}
// 2. 獲取到需要更新的包
this.updates = collectUpdates();
// 如果該數(shù)組為空,就停止執(zhí)行
if (!this.updates.length) {}
// 3. 包相關(guān)的生命周期函數(shù)(這些函數(shù)是在 lerna.json 中設(shè)置的,可以在 lerna 文檔中找到相關(guān)設(shè)置)
this.runPackageLifecycle = createRunner(this.options);
// 4. 將 獲得需要更新版本, 更新版本, 確認(rèn)更新函數(shù)放在一個(gè) tasks 數(shù)組中,之后執(zhí)行
const tasks = [
() => this.getVersionsForUpdates(),
// versions 是由上一步的 getVersionsForUpdates
versions => this.setUpdatesForVersions(versions),
() => this.confirmVersions(),
];
// 5. 檢查當(dāng)前 git 的本地工作區(qū)
if (this.commitAndTag && this.gitOpts.amend !== true) {
// 這里會把一個(gè)檢查函數(shù)放在 task 函數(shù)的隊(duì)列頂部
const check = checkUncommittedOnly ? checkWorkingTree.throwIfUncommitted : checkWorkingTree;
tasks.unshift(() => check(this.execOpts));
} else {}
// 6. 執(zhí)行 task 函數(shù)里面內(nèi)容
return pWaterfall(tasks);
}
其實(shí)看這部分邏輯 lerna 這里還是有些功能等著去 TODO 的,感興趣的同學(xué)可以去參加一波貢獻(xiàn)。
首先我們先從 git 倉庫相關(guān)的檢驗(yàn)看起。首先我們要先知道的是,lerna 管理 monorepo 的一些 workflow 都是基于 git 和 npm 完成的,因此作為一個(gè) lerna 的 monorepo 項(xiàng)目,是需要使用 git 來管理倉庫的。
這里的 requiresGit 實(shí)際上是 versionCommand 類的一個(gè) get 方法,返回的是一個(gè) git 相關(guān)的值,存在即可:
// 只要這些參數(shù)存在其中之一就證明目前是需要驗(yàn)證 git 相關(guān)的內(nèi)容
get requiresGit() {
return (
this.commitAndTag || this.pushToRemote || this.options.allowBranch || this.options.conventionalCommits
);
}
requiresGit 內(nèi)部的檢驗(yàn)邏是要發(fā)生在計(jì)算出需要更新的包以及版本選擇 之前的(當(dāng)然這兩步也是在 initialize() 之前完成的)。
具體的檢驗(yàn)邏輯:
校驗(yàn)本地是否有沒有被
commit內(nèi)容判斷當(dāng)前的分支是否正常
判斷當(dāng)前分支是否在
remote存在判斷當(dāng)前分支是否在
lerna.json允許的allowBranch設(shè)置之中判斷當(dāng)前分支提交是否落后于
remote
這里相關(guān)的操作一些判斷操作實(shí)際上都借用了 git 相關(guān)的命令來完成,就不一一去說明這里的 api 是如何使用的了:例如獲取當(dāng)前分支,實(shí)際上就是獲取到 git 命令的執(zhí)行結(jié)果:
function currentBranch(opts) {
log.silly("currentBranch");
const branch = childProcess.execSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], opts);
log.verbose("currentBranch", branch);
return branch;
}
本質(zhì)上就是執(zhí)行了一次 git rev-parse --abbrev-ref HEAD 獲取到當(dāng)前分支名稱。
其他 git 相關(guān)的判斷都是和此類似,這里就不做具體的贅述,這里如果有相關(guān)的需求是可以在這里學(xué)習(xí)一下相關(guān)工具函數(shù)的使用的,里面有一些平時(shí)不常用的 git 命令,個(gè)人覺得 lerna 這里的工具代碼寫的還是不錯(cuò)的。
下面就直接去看獲取到更新包這部分的邏輯:
this.updates = collectUpdates(
this.packageGraph.rawPackageList,
// 項(xiàng)目圖
this.packageGraph,
// 執(zhí)行 git 命令的一些 option 有 maxBuffer 和 cwd 兩個(gè)參數(shù)
this.execOpts,
// cli 的一些 參數(shù) 以及 lerna.json 里面的一些配置和環(huán)境變量等
this.options
).filer(node => {
// 這里會把滿足 pkg.json 里面設(shè)置了 private: true 且傳遞了 --no-private 參數(shù)的子package 跳過更新
if (node.pkg.private && this.options.private === false) {
// 這里 --no-privte 應(yīng)該是個(gè)默認(rèn)行為,這是可以給 lerna 來個(gè) pr 的
return false;
}
// 當(dāng)前包沒有 version 參數(shù) 也會做個(gè) judge
if (!node.version) {
// pkg 的 pkg.json 里面沒設(shè)置 version 參數(shù),但設(shè)置了 private 還是可以跳的
if (node.pkg.private) {
} else {
// 拋錯(cuò)
}
}
// 把有 version 的篩出去
return !!node.version;
})
這里我們直接去看 collectUpdates 的是怎么拿到需要更新的包的信息的,collectUpdates 之后的 filter 操作寫上了注釋,之后不做具體介紹,可以自行參考對照。
function collectUpdates(filteredPackages, packageGraph, execOpts, commandOptions) {
// ...
// forced 是需要強(qiáng)制發(fā)布的包名的一個(gè) Set 對象
const forced = getPackagesForOption(useConventionalGraduate ? conventionalGraduate : forcePublish);
// 獲取到當(dāng)前 monorepo 下的 packageList 是個(gè)以package name為key的map對象
const packages =
filteredPackages.length === packageGraph.size
? packageGraph
: new Map(filteredPackages.map(({ name }) => [name, packageGraph.get(name)]));
// --since <ref> 參數(shù) lerna version 這邊為 undefined
let committish = commandOptions.since;
if (hasTags(execOpts)) {
// 獲取到上次 附注tag(即 git tag -a "v3" -m "xx" 創(chuàng)建的 tag)
const { sha, refCount, lastTagName } = describeRef.sync(execOpts, commandOptions.includeMergedTags);
// 上一次 tagCommit 到當(dāng)前的提交數(shù)量為 refCount
if (refCount === "0" && forced.size === 0 && !committish) {
log.notice("", "Current HEAD is already released, skipping change detection.");
return [];
}
// 這個(gè)是個(gè)測試版本的選項(xiàng)
if (commandOptions.canary) {
committish = `${sha}^..${sha}`;
} else if (!committish) {
// 如果這個(gè)地方連 tag 都沒有的話, committish 就會成 undefined, 之后就會用初始化的 commit 來算
committish = lastTagName;
}
}
// 用戶使用了 --conventional-commits --conventional-graduate 這個(gè)選項(xiàng)
if (useConventionalGraduate) {
// 會把所有預(yù)發(fā)布的包更新成正式包版本,這里只是給個(gè) log
if (forced.has("*")) {
// --force-publish 就發(fā)布所有
log.info("", "Graduating all prereleased packages");
} else {
log.info("", "Graduating prereleased packages");
}
} else if (!committish || forced.has('*')) {
// 使用了 --force-publish 或者 tag 沒有找到(之前沒有使用過lerna發(fā)包),就會當(dāng)成所有包需要更新
log.info("", "Assuming all packages changed");
// 這個(gè) collectPackages 會有三個(gè)參數(shù),還有個(gè) isCandidate 用來添加篩選條件判斷哪些包需要更新
// 這里 isCandidate 沒傳意味著傳進(jìn)去的 packages 都要更新
return collectPackges(packages, {
onInclude: name => log.verbose("updated", name),
// 這個(gè) excludeDependents 是由 --no-private 參數(shù)來決定去 exclude 掉一些 private: true 的包
excludeDependents,
})
}
// 下面就是正常的收集情況
// ...
return collectPackages(packages, {
// 這里相比較于上面就多了一個(gè) isCandidate, 只有符合下面 isForced(強(qiáng)制更新), needsBump(跳過一些之前沒有被 prereleased 的包)
// hasDiff(有改動(dòng)的包,在這里 ignoreChanges 會生效一波)
isCandidate: (node, name) => isForced(node, name) || needsBump(node) || hasDiff(node),
onInclude: name => log.verbose("updated", name),
excludeDependents,
});
}
根據(jù)上面的代碼層次我們其實(shí)可以比較清晰的看到 collectUpdates 是怎么收集到需要更新的 package 的,首先會從 core 那邊拿到當(dāng)前 monorepo 項(xiàng)目下面的一些 package,然后根據(jù) 一些選項(xiàng) (例如 --force-publish 和 --conventional-commit)以及項(xiàng)目本身 commit 的 tag 信息去得到當(dāng)前 monorepo 項(xiàng)目下需要更新的包的一個(gè) updates 數(shù)組。這里有個(gè)細(xì)節(jié)是這里的 lerna 去獲取 tag 的時(shí)候,使用的是 git describe 命令,而且它附帶的一些參數(shù)說明(可以去看下這個(gè) describe-ref.js),這里獲取到的 tag 是 annotated tag。
這個(gè) updates 數(shù)據(jù)將后續(xù)作為bump version 的一系列操作。
在獲取完 updates 數(shù)組后,初始化過程這個(gè)時(shí)候來到了執(zhí)行 runLifeCycle 函數(shù)這一步,這一步就是用于執(zhí)行 lerna.json 里面用戶設(shè)置的一些生命周期函數(shù),這里不做太多的講解。想知道具體的執(zhí)行函數(shù)代碼怎么寫的可以參考 @lerna/run-lifecycle 這個(gè)庫函數(shù)。
然后就到了初始化的執(zhí)行的一些步驟,這里會構(gòu)建一個(gè) tasks 隊(duì)列,用來執(zhí)行
// 這里的一些 version 函數(shù)都是根據(jù)之前獲取到的 updates 數(shù)組來進(jìn)行相關(guān)的工作
const tasks = [
// 獲得需要更新的 version
() => this.getVersionsForUpdates(),
// 將 versions 設(shè)置上去,這里執(zhí)行的時(shí)候是個(gè) reduce 過程,實(shí)際上是把 上一步函數(shù)返回的 versions 值拿到了返回出來
versions => this.setUpdatesForVersions(versions),
// 確認(rèn)更新
() => this.confirmVersions(),
];
// 中間有個(gè)根據(jù)選項(xiàng)在 tasks 隊(duì)列頂端插入 check 函數(shù),檢查一下當(dāng)前 git 的 working tree 是否正常
// ...
// 按照 reduce 去執(zhí)行任務(wù)隊(duì)列中的方法
return pWaterfall(tasks);
這里就是初始化的最后一個(gè)步驟來,我們主要看 tasks 隊(duì)列中的三個(gè)方法的執(zhí)行細(xì)節(jié)。
這里 getVersionsForupdates 過程就是這個(gè) prompt 過程,最后得到一個(gè)以之前 updates 數(shù)組中的包名為 key,用戶選擇的 version 作為 value 的一個(gè) Map 對象。

當(dāng)然這里我列舉的只是一般用戶使用的情況,如果選擇了 conventional-commits 那么這一步就是自動(dòng)幫用戶生成對應(yīng)對應(yīng)的包版本了,具體的執(zhí)行細(xì)節(jié)可以參考(下面會涉及到一些 options,這里建議可以結(jié)合 lerna version 文檔中的一些 api 介紹來閱讀)。
if (repoVersion) {
// 這里是用戶傳了 lerna version [] 后面數(shù)組里面的 semver bump 參數(shù),可以直接跳掉 prompt 過程,然后對對應(yīng)的版本去直接 bump
// 這里對應(yīng)的是正式的 released 版本
predicate = makeGlobalVersionPredicate(repoVersion);
} else if (increment && independentVersions) {
// increament 相較于 repoVersion 的區(qū)別在于這里對應(yīng)的是非正式版本的包,處于一個(gè) prereleased 階段
// 過程和上面是差不多的,這里對應(yīng)的是所有的 indepent 包(即用戶在 lerna.json 里面設(shè)置了 indepent)
predicate = node => semver.inc(node.version, increment, resolvePrereleaseId(node.prereleaseId));
} else if (increment) {
// 這里對應(yīng)的是 fixed 的包 prereleased 版本更新
} else if (conventionalCommits) {
// 如果是 --conventional-commits 這里就直接幫用戶生成了,不用prompt了
return this.recommendVersions(resolvePrereleaseId);
} else if (independentVersions) {
// 這個(gè)就是對正常的 indepent 包進(jìn)行 prompt 的一個(gè)過程
predicate = makePromptVersion(resolvePrereleaseId);
} else {
// fixed 包進(jìn)行 prompt
// ...
}
// 上面的 predicate 是個(gè)返回 newversion 的函數(shù)
// 會放到 getVersion 即其返回的 newVersion,然后再用 reduceVersions 這個(gè)方法去生成上面我提到的 Map 對象
// reduceVersion 也是一個(gè)典型的 reduce 應(yīng)用(即上一步的結(jié)果可以用于下一步的函數(shù)執(zhí)行)
return Promise.resolve(predicate).then(getVersion => this.reduceVersions(getVersion));
講完 getVersionsForUpdates() ,下面就到了 setUpdatesForVersions 這個(gè)過程,這一步操作就相對簡單,拿到上一次的 versions Map 對象,用來做一次預(yù)處理,這一步會得到一個(gè) updateVersions (本質(zhì)上其實(shí)就是上一次拿到的 versions)和一個(gè) packagesToVersion(本質(zhì)上就是 updates 這個(gè)數(shù)組),這里相當(dāng)于是做了一下整合。
setUpdatesForVersions(versions) {
if (this.project.isIndependent() || versions.size === this.packageGraph.size) {
// 只需要檢查部分固定的版本
this.updatesVersions = versions;
} else {
let hasBreakingChange;
for (const [name, bump] of versions) {
// 這里直接看代碼知道 breakChange 的判斷比我介紹要直觀多,代碼在下面
hasBreakingChange = hasBreakingChange || isBreakingChange(this.packageGraph.get(name).version, bump);
}
if (hasBreakingChange) {
this.updates = Array.from(this.packageGraph.values());
// 把 private 設(shè)成 true 的包篩出去
if (this.options.private === false) {
this.updates = this.updates.filter(node => !node.pkg.private);
}
this.updatesVersions = new Map(this.updates.map(node => [node.name, this.globalVersion]));
} else {
this.updatesVersions = versions;
}
}
this.packagesToVersion = this.updates.map(node => node.pkg);
}
上面判斷 breackChange 的方法為:
// releaseType 能判斷是包的哪一位發(fā)生了變化,這里的判斷其實(shí)有些迷惑行為.jpg
const releaseType = semver.diff(currentVersion, nextVersion);
let breaking;
if (releaseType === "major") {
// self-evidently
breaking = true;
} else if (releaseType === "minor") {
// 0.1.9 => 0.2.0 is breaking
// lt 是小于
breaking = semver.lt(currentVersion, "1.0.0");
} else if (releaseType === "patch") {
// 0.0.1 => 0.0.2 is breaking(?)
breaking = semver.lt(currentVersion, "0.1.0");
} else {
// versions are equal, or any prerelease
breaking = false;
}
最后一步 confirmVersion 其實(shí)就是把上一步 setUpdatesForVersions 生成的 packagesToVersion 的值展示出來,讓用戶確定之前的選擇是否正確,然后返回一個(gè) bool 值(即用戶選擇的是否)。之后在執(zhí)行階段再去根據(jù)初始化時(shí)的這個(gè)結(jié)果去做一些更新之類的操作。
那么當(dāng)這里整個(gè)初始化的過程就結(jié)束了,這里吐槽一下其實(shí)初始化這里的很多放到 execute 這個(gè)函數(shù)中去會比較好一點(diǎn)。因?yàn)槌跏蓟@里其實(shí)已經(jīng)完成 lerna version 的大部分功能了,最后執(zhí)行其實(shí)本質(zhì)上執(zhí)行的也只是一個(gè)更新 pkg 中的 version 的操作。
執(zhí)行(execute)
到這里麻煩讀者可以再回到文章開頭看一下 lerna version 的整體執(zhí)行過程,到現(xiàn)在我們已經(jīng)完成了 version 的更新操作,拿到了一些相關(guān)參數(shù)。最后剩下的幾步就是在需要修改的 pkg 里面對應(yīng)的包的版本,然后將修改 commit 并打上 tag 推送到 git remote。
這幾個(gè)步驟就是在執(zhí)行這里完成。
這里的過程比較簡單,和 initialize() 一樣,這里初始化了一個(gè) task 數(shù)組,然后把相關(guān)的操作函數(shù)放到 task 最后用 p-reduce 去 run。
execute(){
// 放入更新包版本的函數(shù)
const tasks = [() => this.updatePackageVersions()];
// 用戶可以通過設(shè)置 --no-git-tag-version 來跳過 tag 和 commit 的過程
// 這個(gè) commitAndTag 默認(rèn)為 true
if (this.commitAndTag) {
tasks.push(() => this.commitAndTagUpdates());
} else {
this.logger.info("execute", "Skipping git tag/commit");
}
// pushToRemote 受到 commitAndTag 和 ammend 參數(shù)的共同影響
// 用戶可以單獨(dú)通過 --amend 來讓 lerna version 最后不 push
if (this.pushToRemote) {
tasks.push(() => this.gitPushToRemote());
} else {
this.logger.info("execute", "Skipping git push");
}
// 這個(gè) createRelease 使用來設(shè)置特定平臺的 release發(fā)布的,配合 --conventional-commits 來一起使用
// 這個(gè)不做詳細(xì)的介紹
if (this.createRelease) {
this.logger.info("execute", "Creating releases...");
tasks.push(() =>
createRelease(
this.options.createRelease,
{ tags: this.tags, releaseNotes: this.releaseNotes },
{ gitRemote: this.options.gitRemote, execOpts: this.execOpts }
)
);
} else {
this.logger.info("execute", "Skipping releases");
}
// pWaterfall 這個(gè)方法就是封裝了一下 p-reduce 這個(gè)庫函數(shù)
return pWaterfall(tasks).then(() => {
// 這里的 composed 是用來標(biāo)記當(dāng)前是 lerna version 還是 lerna publish 在執(zhí)行
if (!this.composed) {
// lerna version 就結(jié)束這個(gè)過程
this.logger.success("version", "finished");
}
// 如果是 lerna publish 就耀把這些參數(shù)返回出去
return {
updates: this.updates,
updatesVersions: this.updatesVersions,
};
});
}
正常邏輯上會按照 更新包的版本 -> 生成 commit 和打 tag -> 提交到 remote這樣一個(gè)過程來執(zhí)行,參考 task 的順序就行。
先看 updatePackageVersions 這個(gè)方法,這個(gè)方法會幫我們 更新一下包的版本,修改掉文件里面的版本,并且將更新 git add 添加到暫緩區(qū),如果是使用了 --conventional-commits,也會在這一步幫我們生成 CHANGELOG 或者是更新 CHANGELOG。這一步會用一個(gè) changeFiles 來記錄修改文件的 path。
大致的代碼結(jié)構(gòu)是這樣的:
updatePackageVersions() {
const { conventionalCommits, changelogPreset, changelog = true } = this.options;
const independentVersions = this.project.isIndependent();
const rootPath = this.project.manifest.location;
// 記錄修改的文件
const changedFiles = new Set();
// 這里的一系列鏈?zhǔn)秸{(diào)用以及異步方法都是用的 promise,作者甚至在源碼這里吐槽想用 async/await...
let chain = Promise.resolve();
// 這里的 action 其實(shí)也是個(gè) reduce 執(zhí)行過程,即前一步的函數(shù)會把結(jié)果給后面
const actions = [
// .. 里面是一堆和 pkg 更新有關(guān)的函數(shù)
// 在這一步會有個(gè)函數(shù)更新掉版本
pkg => {
// 把新版本設(shè)置上
pkg.set("version", this.updatesVersions.get(pkg.name));
// 與 pkg 有關(guān)的依賴(也要跟著一起更新)
// 這里也是從 core 那邊拿到的依賴圖,然后可以得到一個(gè) pkg 依賴了哪些 lerna 內(nèi)部的庫,那些內(nèi)部庫也要跟著更新
for (const [depName, resolved] of this.packageGraph.get(pkg.name).localDependencies) {
const depVersion = this.updatesVersions.get(depName);
if (depVersion && resolved.type !== "directory") {
// don't overwrite local file: specifiers, they only change during publish
pkg.updateLocalDependency(resolved, depVersion, this.savePrefix);
}
}
// 更新 pkg-lock.json 中版本,并且將修改反映到 pkg 文件中(pkg.serialize())
// 然后 then 的時(shí)候?qū)⑿薷挠涗浵聛碓?changeFiles 中
return Promise.all([updateLockfileVersion(pkg), pkg.serialize()]).then(// ...);
}
// ..
];
// 如果是 --conventional-commits 則會幫我們自動(dòng)生成 CHANGELOG
// 下面的 updateChangelog 方法也是可以看到的
if (conventionalCommits && changelog) {
// we can now generate the Changelog, based on the
// the updated version that we're about to release.
const type = independentVersions ? "independent" : "fixed";
actions.push(pkg =>
ConventionalCommitUtilities.updateChangelog(pkg, type, {
changelogPreset,
rootPath,
tagPrefix: this.tagPrefix,
}).then(({ logPath, newEntry }) => {
// commit the updated changelog
changedFiles.add(logPath);
// 添加 released notes
if (independentVersions) {
this.releaseNotes.push({
name: pkg.name,
notes: newEntry,
});
}
return pkg;
})
);
}
// pPipe 是封裝的一個(gè)按照 reduce 順序執(zhí)行異步函數(shù)的方法
// 這里會把前面的 action 一一執(zhí)行,得到最后一步返回的結(jié)果
const mapUpdate = pPipe(actions);
// 按照拓?fù)漤樞蛉?zhí)行前面拿到的一些需要更新的包,以及需要更新的 pathFile
chain = chain.then(() =>
runTopologically(this.packagesToVersion, mapUpdate, {
concurrency: this.concurrency,
rejectCycles: this.options.rejectCycles,
})
);
// fixed 類型的 monorepo 項(xiàng)目的更新,會多一個(gè) lerna.json 修改
// 因?yàn)橐薷睦锩娴陌姹荆@里也會和上面一樣有 --conventional-commits 的判斷,不做過多講解
if (!independentVersions) {}
// 前面有說這個(gè)值默認(rèn)是 true,這里會把修改 git add 到緩存區(qū)
if (this.commitAndTag) {
chain = chain.then(() => gitAdd(Array.from(changedFiles), this.gitOpts, this.execOpts));
}
return chain;
}
然后再讓我們看生成 commit 這個(gè)過程,這一步步驟就比較簡單,將前面 add 到緩存區(qū)的修改 commit 并且打上 tag。相關(guān)邏輯在 commitAndTagUpdates 這個(gè)方法上,這一步代碼比較簡單,可以直接看一下。
commitAndTagUpdates() {
let chain = Promise.resolve();
// 這一步會完成 git add . 和 git tag 的操作,commit 的 message 可以用戶自行設(shè)置或者 lerna 會幫你自動(dòng)生成
if (this.project.isIndependent()) {
chain = chain.then(() => this.gitCommitAndTagVersionForUpdates());
} else {
chain = chain.then(() => this.gitCommitAndTagVersion());
}
chain = chain.then(tags => {
this.tags = tags;
});
// ...
return chain;
}
然后最后一步就是將 commit 以及 tag push 到 remote,這一步更簡單,lerna 封裝了一個(gè) git push 方法,推送到當(dāng)前的分支的 remote 上去。
gitPushToRemote() {
this.logger.info("git", "Pushing tags...");
// gitPush 簡單封裝了一下 git push 命令
return gitPush(this.gitRemote, this.currentBranch, this.execOpts);
}
總結(jié)
本文主要從源碼的角度介紹了一下 lerna version 的工作原理,在使用 lerna 進(jìn)行發(fā)包相關(guān)的操作的時(shí)候,lerna version 起到了一個(gè)很重要的作用,如果單獨(dú)使用 lerna publish,一般選項(xiàng)情況下也是會首先進(jìn)到 lerna version 這邊走完整個(gè) bump version 的操作,最后再去進(jìn)行發(fā)包,因此 lerna version 是 lerna 進(jìn)行 monorepo 發(fā)包的一個(gè)基礎(chǔ)。
下一篇文章將講解一下,lerna publish 做了哪一些工作,是怎么完成將相關(guān)的包發(fā)布的操作的。
