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

那我們直接開始來看一下 lerna version 的具體實(shí)現(xiàn),具體源碼地址為: https://github.com/lerna/lerna/tree/main/commands/version
lerna 作為一個 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 中使用的時候提供的一些 標(biāo)準(zhǔn) Options 例如 allow-branch、conventional-commits 、 ignore-changes 等選項(xiàng)。
具體的執(zhí)行邏輯在 index.js 這個目錄中實(shí)現(xiàn)。
順便一提,這里關(guān)于 lerna 的命令是怎么進(jìn)行分發(fā)以及執(zhí)行的,這部分相關(guān)的邏輯可以參考 @lerna/command 這個包即源碼目錄 lerna/core/cli/command 中相關(guān)內(nèi)容。lerna monorepo 一些核心相關(guān)概念都在 core 這個目錄下面,其中會包括一些例如 monorepo 依賴圖的構(gòu)建等內(nèi)容,感興趣可以去研究一下。
因此現(xiàn)在我們開始看 index.js 相關(guān)的內(nèi)容。
這個文件的主要導(dǎo)出了一個叫做 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)容可以看出整個執(zhí)行周期分成三個部分 設(shè)置屬性 -> 初始化 -> 執(zhí)行。
設(shè)置屬性(configureProperties)
首先我們先看設(shè)置屬性這一部分,這部分其實(shí)很簡單,它會檢查一些傳遞進(jìn)來的選項(xiàng)是否符合規(guī)范(例如 --create-release 只有和 --conventional-commits 放在一起才能執(zhí)行,否則會報錯)。然后把一些 git 相關(guān)的options 全部整合到了一個叫做 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 過程中時,會有一個初始化的過程,初始化過程中首先會檢驗(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),給個提示
}
// 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ù)放在一個 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) {
// 這里會把一個檢查函數(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 完成的,因此作為一個 lerna 的 monorepo 項(xiàng)目,是需要使用 git 來管理倉庫的。
這里的 requiresGit 實(shí)際上是 versionCommand 類的一個 get 方法,返回的是一個 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ù)的使用的,里面有一些平時不常用的 git 命令,個人覺得 lerna 這里的工具代碼寫的還是不錯的。
下面就直接去看獲取到更新包這部分的邏輯:
this.updates = collectUpdates(
this.packageGraph.rawPackageList,
// 項(xiàng)目圖
this.packageGraph,
// 執(zhí)行 git 命令的一些 option 有 maxBuffer 和 cwd 兩個參數(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)該是個默認(rèn)行為,這是可以給 lerna 來個 pr 的
return false;
}
// 當(dāng)前包沒有 version 參數(shù) 也會做個 judge
if (!node.version) {
// pkg 的 pkg.json 里面沒設(shè)置 version 參數(shù),但設(shè)置了 private 還是可以跳的
if (node.pkg.private) {
} else {
// 拋錯
}
}
// 把有 version 的篩出去
return !!node.version;
})
這里我們直接去看 collectUpdates 的是怎么拿到需要更新的包的信息的,collectUpdates 之后的 filter 操作寫上了注釋,之后不做具體介紹,可以自行參考對照。
function collectUpdates(filteredPackages, packageGraph, execOpts, commandOptions) {
// ...
// forced 是需要強(qiáng)制發(fā)布的包名的一個 Set 對象
const forced = getPackagesForOption(useConventionalGraduate ? conventionalGraduate : forcePublish);
// 獲取到當(dāng)前 monorepo 下的 packageList 是個以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 [];
}
// 這個是個測試版本的選項(xiàng)
if (commandOptions.canary) {
committish = `${sha}^..${sha}`;
} else if (!committish) {
// 如果這個地方連 tag 都沒有的話, committish 就會成 undefined, 之后就會用初始化的 commit 來算
committish = lastTagName;
}
}
// 用戶使用了 --conventional-commits --conventional-graduate 這個選項(xiàng)
if (useConventionalGraduate) {
// 會把所有預(yù)發(fā)布的包更新成正式包版本,這里只是給個 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");
// 這個 collectPackages 會有三個參數(shù),還有個 isCandidate 用來添加篩選條件判斷哪些包需要更新
// 這里 isCandidate 沒傳意味著傳進(jìn)去的 packages 都要更新
return collectPackges(packages, {
onInclude: name => log.verbose("updated", name),
// 這個 excludeDependents 是由 --no-private 參數(shù)來決定去 exclude 掉一些 private: true 的包
excludeDependents,
})
}
// 下面就是正常的收集情況
// ...
return collectPackages(packages, {
// 這里相比較于上面就多了一個 isCandidate, 只有符合下面 isForced(強(qiáng)制更新), needsBump(跳過一些之前沒有被 prereleased 的包)
// hasDiff(有改動的包,在這里 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)目下需要更新的包的一個 updates 數(shù)組。這里有個細(xì)節(jié)是這里的 lerna 去獲取 tag 的時候,使用的是 git describe 命令,而且它附帶的一些參數(shù)說明(可以去看下這個 describe-ref.js),這里獲取到的 tag 是 annotated tag。
這個 updates 數(shù)據(jù)將后續(xù)作為bump version 的一系列操作。
在獲取完 updates 數(shù)組后,初始化過程這個時候來到了執(zhí)行 runLifeCycle 函數(shù)這一步,這一步就是用于執(zhí)行 lerna.json 里面用戶設(shè)置的一些生命周期函數(shù),這里不做太多的講解。想知道具體的執(zhí)行函數(shù)代碼怎么寫的可以參考 @lerna/run-lifecycle 這個庫函數(shù)。
然后就到了初始化的執(zhí)行的一些步驟,這里會構(gòu)建一個 tasks 隊(duì)列,用來執(zhí)行
// 這里的一些 version 函數(shù)都是根據(jù)之前獲取到的 updates 數(shù)組來進(jìn)行相關(guān)的工作
const tasks = [
// 獲得需要更新的 version
() => this.getVersionsForUpdates(),
// 將 versions 設(shè)置上去,這里執(zhí)行的時候是個 reduce 過程,實(shí)際上是把 上一步函數(shù)返回的 versions 值拿到了返回出來
versions => this.setUpdatesForVersions(versions),
// 確認(rèn)更新
() => this.confirmVersions(),
];
// 中間有個根據(jù)選項(xiàng)在 tasks 隊(duì)列頂端插入 check 函數(shù),檢查一下當(dāng)前 git 的 working tree 是否正常
// ...
// 按照 reduce 去執(zhí)行任務(wù)隊(duì)列中的方法
return pWaterfall(tasks);
這里就是初始化的最后一個步驟來,我們主要看 tasks 隊(duì)列中的三個方法的執(zhí)行細(xì)節(jié)。
這里 getVersionsForupdates 過程就是這個 prompt 過程,最后得到一個以之前 updates 數(shù)組中的包名為 key,用戶選擇的 version 作為 value 的一個 Map 對象。

當(dāng)然這里我列舉的只是一般用戶使用的情況,如果選擇了 conventional-commits 那么這一步就是自動幫用戶生成對應(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)的是非正式版本的包,處于一個 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) {
// 這個就是對正常的 indepent 包進(jìn)行 prompt 的一個過程
predicate = makePromptVersion(resolvePrereleaseId);
} else {
// fixed 包進(jìn)行 prompt
// ...
}
// 上面的 predicate 是個返回 newversion 的函數(shù)
// 會放到 getVersion 即其返回的 newVersion,然后再用 reduceVersions 這個方法去生成上面我提到的 Map 對象
// reduceVersion 也是一個典型的 reduce 應(yīng)用(即上一步的結(jié)果可以用于下一步的函數(shù)執(zhí)行)
return Promise.resolve(predicate).then(getVersion => this.reduceVersions(getVersion));
講完 getVersionsForUpdates() ,下面就到了 setUpdatesForVersions 這個過程,這一步操作就相對簡單,拿到上一次的 versions Map 對象,用來做一次預(yù)處理,這一步會得到一個 updateVersions (本質(zhì)上其實(shí)就是上一次拿到的 versions)和一個 packagesToVersion(本質(zhì)上就是 updates 這個數(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 的值展示出來,讓用戶確定之前的選擇是否正確,然后返回一個 bool 值(即用戶選擇的是否)。之后在執(zhí)行階段再去根據(jù)初始化時的這個結(jié)果去做一些更新之類的操作。
那么當(dāng)這里整個初始化的過程就結(jié)束了,這里吐槽一下其實(shí)初始化這里的很多放到 execute 這個函數(shù)中去會比較好一點(diǎn)。因?yàn)槌跏蓟@里其實(shí)已經(jīng)完成 lerna version 的大部分功能了,最后執(zhí)行其實(shí)本質(zhì)上執(zhí)行的也只是一個更新 pkg 中的 version 的操作。
執(zhí)行(execute)
到這里麻煩讀者可以再回到文章開頭看一下 lerna version 的整體執(zhí)行過程,到現(xiàn)在我們已經(jīng)完成了 version 的更新操作,拿到了一些相關(guān)參數(shù)。最后剩下的幾步就是在需要修改的 pkg 里面對應(yīng)的包的版本,然后將修改 commit 并打上 tag 推送到 git remote。
這幾個步驟就是在執(zhí)行這里完成。
這里的過程比較簡單,和 initialize() 一樣,這里初始化了一個 task 數(shù)組,然后把相關(guān)的操作函數(shù)放到 task 最后用 p-reduce 去 run。
execute(){
// 放入更新包版本的函數(shù)
const tasks = [() => this.updatePackageVersions()];
// 用戶可以通過設(shè)置 --no-git-tag-version 來跳過 tag 和 commit 的過程
// 這個 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");
}
// 這個 createRelease 使用來設(shè)置特定平臺的 release發(fā)布的,配合 --conventional-commits 來一起使用
// 這個不做詳細(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 這個方法就是封裝了一下 p-reduce 這個庫函數(shù)
return pWaterfall(tasks).then(() => {
// 這里的 composed 是用來標(biāo)記當(dāng)前是 lerna version 還是 lerna publish 在執(zhí)行
if (!this.composed) {
// lerna version 就結(jié)束這個過程
this.logger.success("version", "finished");
}
// 如果是 lerna publish 就耀把這些參數(shù)返回出去
return {
updates: this.updates,
updatesVersions: this.updatesVersions,
};
});
}
正常邏輯上會按照 更新包的版本 -> 生成 commit 和打 tag -> 提交到 remote這樣一個過程來執(zhí)行,參考 task 的順序就行。
先看 updatePackageVersions 這個方法,這個方法會幫我們 更新一下包的版本,修改掉文件里面的版本,并且將更新 git add 添加到暫緩區(qū),如果是使用了 --conventional-commits,也會在這一步幫我們生成 CHANGELOG 或者是更新 CHANGELOG。這一步會用一個 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í)也是個 reduce 執(zhí)行過程,即前一步的函數(shù)會把結(jié)果給后面
const actions = [
// .. 里面是一堆和 pkg 更新有關(guān)的函數(shù)
// 在這一步會有個函數(shù)更新掉版本
pkg => {
// 把新版本設(shè)置上
pkg.set("version", this.updatesVersions.get(pkg.name));
// 與 pkg 有關(guān)的依賴(也要跟著一起更新)
// 這里也是從 core 那邊拿到的依賴圖,然后可以得到一個 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 的時候?qū)⑿薷挠涗浵聛碓?changeFiles 中
return Promise.all([updateLockfileVersion(pkg), pkg.serialize()]).then(// ...);
}
// ..
];
// 如果是 --conventional-commits 則會幫我們自動生成 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 是封裝的一個按照 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)目的更新,會多一個 lerna.json 修改
// 因?yàn)橐薷睦锩娴陌姹荆@里也會和上面一樣有 --conventional-commits 的判斷,不做過多講解
if (!independentVersions) {}
// 前面有說這個值默認(rèn)是 true,這里會把修改 git add 到緩存區(qū)
if (this.commitAndTag) {
chain = chain.then(() => gitAdd(Array.from(changedFiles), this.gitOpts, this.execOpts));
}
return chain;
}
然后再讓我們看生成 commit 這個過程,這一步步驟就比較簡單,將前面 add 到緩存區(qū)的修改 commit 并且打上 tag。相關(guān)邏輯在 commitAndTagUpdates 這個方法上,這一步代碼比較簡單,可以直接看一下。
commitAndTagUpdates() {
let chain = Promise.resolve();
// 這一步會完成 git add . 和 git tag 的操作,commit 的 message 可以用戶自行設(shè)置或者 lerna 會幫你自動生成
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 封裝了一個 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)的操作的時候,lerna version 起到了一個很重要的作用,如果單獨(dú)使用 lerna publish,一般選項(xiàng)情況下也是會首先進(jìn)到 lerna version 這邊走完整個 bump version 的操作,最后再去進(jìn)行發(fā)包,因此 lerna version 是 lerna 進(jìn)行 monorepo 發(fā)包的一個基礎(chǔ)。
下一篇文章將講解一下,lerna publish 做了哪一些工作,是怎么完成將相關(guān)的包發(fā)布的操作的。
