深入 lerna 發(fā)包機制 —— lerna publish

前言:
lerna作為一個風靡的前端monorepo管理工具,在許許多多的大型項目中都得到的實踐,現(xiàn)在筆者在公司中開發(fā)的 monorepo 工具中,monorepo 中子項目的發(fā)包能力就是基于 lerna 來完成,因此本篇文章將講解發(fā)包中最關(guān)鍵的命令即lerna publish。
在上一篇文章中介紹完了 lerna version 的運行機制后,那么在本篇文章中我將繼續(xù)介紹一下 lerna 發(fā)包機制中最關(guān)鍵的一個 command 即 lerna publish。
現(xiàn)在我們來繼續(xù)介紹 lerna publish 運行機制,作為發(fā)包機制中的最后決定性的一個指令,lerna publish 的做的工作其實很簡單,就是將 monorepo 需要發(fā)布的包,發(fā)布到 npm registry 上面去。
同樣 lerna publish 也分為幾種不同的場景去運行:
lerna publish
# lerna version + lerna publish from-git
lerna publish from-git
# 發(fā)布當前 commit 中打上 annoted tag version 的包
lerna publish from-packages
# 發(fā)布 package 中 pkg.json 上的 version 在 registry(高于 latest version)不存在的包
官方文檔,lerna publish 一共有這樣幾種執(zhí)行表現(xiàn)形式:
lerna publish 永遠不會發(fā)布 package.json 中 private 設(shè)置為 true 的包
發(fā)布自上次發(fā)布來有更新的包(這里的上次發(fā)布也是基于上次執(zhí)行
lerna publish而言)發(fā)布在當前 commit 上打上了 annotated tag 的包(即
lerna publish from-git)發(fā)布在最近 commit 中修改了 package.json 中的 version (且該 version 在 registry 中沒有發(fā)布過)的包(即
lerna publish from-package)發(fā)布在上一次提交中更新了的 unversioned 的測試版本的包(以及依賴了的包)
lerna publish 本身提供了不少的 options,例如支持發(fā)布測試版本的包即 (lerna version --canary)。
在上文 lerna version 源碼解析中,我們按照 configureProperties -> initialize -> execute 的順序講解了 lerna version 的執(zhí)行順序,其實在 lerna 中,幾乎所有子命令源碼的執(zhí)行順序都是按照這樣一個結(jié)構(gòu)在進行,lerna 本身作為一個 monorepo,主要是使用 core 核心中的執(zhí)行機制來去分發(fā)命令給各個子項目去執(zhí)行,因此套路都是一樣的。
在開始閱讀之前,我先提供一個整體的思維導圖,可以讓讀者在開始閱讀前有個大致的結(jié)構(gòu),也便于在閱讀過程可以借此來進行回顧:

設(shè)置屬性(configureProperties)
相比較于 lerna version,lerna publish 的這一步就簡單許多,大致就是根據(jù) cli 的 options 對一些參數(shù)進行了初始化:
configureProperties() {
const {
exact,
gitHead,
gitReset,
tagVersionPrefix = "v",
verifyAccess,
} = this.options;
// 這里的 requiresGit 指的是除了 from-package 的其它發(fā)包方式
if (this.requiresGit && gitHead) {
throw new ValidationError("EGITHEAD", "--git-head is only allowed with 'from-package' positional");
}
// --exact 會指定一個具體的 version 版本,而不會加上 npm 那邊的版本兼容前綴
this.savePrefix = exact ? "" : "^";
// 用于用戶自定義包的版本 tag 前綴,而不是使用默認的 v
this.tagPrefix = tagVersionPrefix;
// --no-git-reset 用于避免 lerna publish 將暫存區(qū)的未提交的代碼都 push 到 git 上
this.gitReset = gitReset !== false;
// lerna 發(fā)包會默認檢查用戶 npm 權(quán)限
// 設(shè)置 --no-verify-access 跳過檢查
this.verifyAccess = verifyAccess !== false;
// npm 發(fā)包相關(guān)配置
this.npmSession = crypto.randomBytes(8).toString("hex");
}
通過注釋就可以比較清晰的看到一些 options 以及相關(guān)參數(shù)的初始化,這里就不詳細介紹。
初始化(initialize)
下面直接進來初始化的流程中來,因為涉及到發(fā)包相關(guān)的流程,這一步的前面過程涉及到的就是一些關(guān)于 npm 相關(guān)的 config 初始化,之后再根據(jù)不同的發(fā)包情況去進行對應(yīng)的事件注冊,這一步的事件注冊以及執(zhí)行方式都和 lerna version 源碼解析時比較類似,主要過程可以分為三個步驟:
初始化 npm config 參數(shù)
根據(jù)不同的發(fā)包情況執(zhí)行不同的方法
處理上一步返回的結(jié)果
這里不同的發(fā)包情況指的即是在文章開頭介紹的 lerna publish 的幾種執(zhí)行方式,這里大致梳理一下以下的步驟:
initialize() {
// --skip-npm 相當于直接執(zhí)行 lerna version
if (this.options.skipNpm) {
// 該 api 會在下個 major 被棄用
this.logger.warn("deprecated", "Instead of --skip-npm, call `lerna version` directly");
// 這里我們可以看到 lerna 中某個 command 調(diào)用其他 command 都是通過這種鏈式調(diào)用的方式
return versionCommand(this.argv).then(() => false);
}
// 1. 初始化 npm config 參數(shù)
// session 和 userAgent 都是 npm 發(fā)包需要驗證的參數(shù)
this.logger.verbose("session", this.npmSession);
this.logger.verbose("user-agent", this.userAgent);
// npm config 相關(guān), 存一些 npm config 相關(guān)值
this.conf = npmConf({
lernaCommand: "publish",
_auth: this.options.legacyAuth,
npmSession: this.npmSession,
npmVersion: this.userAgent,
otp: this.options.otp,
registry: this.options.registry,
"ignore-prepublish": this.options.ignorePrepublish,
"ignore-scripts": this.options.ignoreScripts,
});
// --dist-tag 用于 設(shè)置發(fā)包時候自定義 tag
// 一般默認 tag 是 latest
// lerna 中如果沒指定 --dist-tag, 正式包的 tag 會用 latest, --canary 的測試包會用 canary
const distTag = this.getDistTag();
// 如果該參數(shù)存在 會被注入進 npm conf 中
if (distTag) {
this.conf.set("tag", distTag.trim(), "cli");
}
// 注冊運行 lerna.json 里面的 script 的 runner
this.runPackageLifecycle = createRunner(this.options);
// 如果 lerna 子 package 里面的 pkg.json 里面有 pre|post publish 這樣的 script
// 會跳過 lifecycle script 的執(zhí)行過程,否則會去遞歸執(zhí)行
this.runRootLifecycle = /^(pre|post)?publish$/.test(process.env.npm_lifecycle_event)
? stage => {
this.logger.warn("lifecycle", "Skipping root %j because it has already been called", stage);
}
: stage => this.runPackageLifecycle(this.project.manifest, stage);
// 2. 根據(jù)不同的發(fā)包情況執(zhí)行不同的方法
// 通過 promise 構(gòu)建一個執(zhí)行鏈, lerna version 里面講過
let chain = Promise.resolve();
if (this.options.bump === "from-git") {
chain = chain.then(() => this.detectFromGit());
} else if (this.options.bump === "from-package") {
chain = chain.then(() => this.detectFromPackage());
} else if (this.options.canary) {
chain = chain.then(() => this.detectCanaryVersions());
} else {
chain = chain.then(() => versionCommand(this.argv));
}
// 3. 對方法返回的結(jié)果做一個處理
return chain.then(result => {
// 如果上一步是走了 lerna version 的 bump version 過程
if (!result) {
return false;
}
// lerna version 返回的結(jié)果數(shù)組里面沒有需要更新的 package
if (!result.updates.length) {
this.logger.success("No changed packages to publish");
return false;
}
// publish 的時候把 pkg.json 里面設(shè)置private 為 false 的包忽略掉
this.updates = result.updates.filter(node => !node.pkg.private);
// 需要更新的包以及對應(yīng)更新到的version
this.updatesVersions = new Map(result.updatesVersions);
// 再篩選一下需要發(fā)包的 packages,根據(jù)是否存在 pkg.json
this.packagesToPublish = this.updates.map(node => node.pkg);
// 用于發(fā)布 lerna 管理的 packages 的一些子目錄例如 dist
// 參考 --contents 這個 options
if (this.options.contents) {
// 把這些目錄寫進需要發(fā)包的 pkg.json 中
for (const pkg of this.packagesToPublish) {
pkg.contents = this.options.contents;
}
}
// 用于確認上面除了 versioncommand 的其他三種執(zhí)行情況
// 例如 versionCommand 有自己的 confirm 過程
if (result.needsConfirmation) {
return this.confirmPublish();
}
return true;
});
}
initialize 前面有介紹主要分為三個步驟來執(zhí)行,因此 1、3 兩個步驟根據(jù)注釋來理解過程還是比較清晰的,這里主要介紹一下第二步即 根據(jù)不同的發(fā)包情況來執(zhí)行不用的方法,具體代碼:
if (this.options.bump === "from-git") {
chain = chain.then(() => this.detectFromGit());
} else if (this.options.bump === "from-package") {
chain = chain.then(() => this.detectFromPackage());
} else if (this.options.canary) {
chain = chain.then(() => this.detectCanaryVersions());
} else {
chain = chain.then(() => versionCommand(this.argv));
}
首先根據(jù)上面代碼中以及文章開頭介紹,可以很清晰的知道具體分為這幾種情況:
from-git即根據(jù)git commit上的annotaed tag進行發(fā)包from-package即根據(jù) lerna 下的 package 里面的 pkg.json 的 version 變動來發(fā)包--canary發(fā)測試版本的包剩下不帶參數(shù)的情況就直接走一個 bump version(即執(zhí)行 lerna version)
下面從這幾種情況做個介紹:
from-git
這一步的執(zhí)行入口函數(shù)是 detectFromGit ,我們直接看這個函數(shù)的執(zhí)行過程:
detectFromGit() {
const matchingPattern = this.project.isIndependent() ? "*@*" : `${this.tagPrefix}*.*.*`;
let chain = Promise.resolve();
// 1. 驗證當前的 git 工作區(qū)域是否干凈 通過 git describe 來找
chain = chain.then(() => this.verifyWorkingTreeClean());
// 2. 拿到當前 commit 上面的 tag
chain = chain.then(() => getCurrentTags(this.execOpts, matchingPattern));
// 3. 通過上一步的 tag 拿到需要更新的 pkg 的數(shù)組
chain = chain.then(taggedPackageNames => {
if (!taggedPackageNames.length) {
this.logger.notice("from-git", "No tagged release found");
return [];
}
// 獨立發(fā)包模式就拿到所有包的數(shù)組
if (this.project.isIndependent()) {
return taggedPackageNames.map(name => this.packageGraph.get(name));
}
// 固定模式只用拿到一個版本,所有的包都用一個版本
return getTaggedPackages(this.packageGraph, this.project.rootPath, this.execOpts);
});
// 4. 清除掉更新 packages 里面 pkg.json 中設(shè)置了 private 為 false 的包
chain = chain.then(updates => updates.filter(node => !node.pkg.private));
// 5. updateVersions 存需要發(fā)布的包名以及發(fā)布的版本
return chain.then(updates => {
const updatesVersions = updates.map(node => [node.name, node.version]);
return {
updates,
updatesVersions,
needsConfirmation: true,
};
});
}
可以看到這一步函數(shù)的執(zhí)行過程還是比較簡單明了的,在上面注釋中根據(jù)不同的方法執(zhí)行過程分為了 5 個步驟。主要就是根據(jù)當前 commit 拿到 tags 里面的 packages 然后返回這些 packages 以及其版本信息。
from-package
這一步執(zhí)行的入口函數(shù)是 detectFromPackage ,直接看執(zhí)行過程:
detectFromPackage() {
let chain = Promise.resolve();
// 1. 驗證當前 git 工作區(qū)是否干凈,步驟同上
chain = chain.then(() => this.verifyWorkingTreeClean());
// 2. 通過 getUnpublishedPackages 篩除 private 為 true 的 package && 拿到需要發(fā)布的pkg
// 這一步用了 npm config 里面的快照來做對比
chain = chain.then(() => getUnpublishedPackages(this.packageGraph, this.conf.snapshot));
// 3. 驗證結(jié)果符合預期否
chain = chain.then(unpublished => {
if (!unpublished.length) {
this.logger.notice("from-package", "No unpublished release found");
}
return unpublished;
});
// 4. updateVersions 存需要發(fā)布的包名以及發(fā)布的版本,返回結(jié)果
return chain.then(updates => {
const updatesVersions = updates.map(node => [node.name, node.version]);
return {
updates,
updatesVersions,
needsConfirmation: true,
};
});
}
這一步主要是在 getUnpublishedPackages 這一步篩選出需要更新的 packages,這里 lerna 作者使用了自己封裝的 pacote 庫來去做一些關(guān)于版本的比對,從而得到需要更新的 packages,這里有想了解的可以自行去閱讀一下,不做過多贅述。
--canary
這一步執(zhí)行的入口函數(shù)是 detectCanaryVersions ,直接看執(zhí)行過程:
detectCanaryVersions() {
// 初始化處理參數(shù)
const { cwd } = this.execOpts;
const {
bump = "prepatch",
preid = "alpha",
ignoreChanges,
forcePublish,
includeMergedTags,
} = this.options;
const release = bump.startsWith("pre") ? bump.replace("release", "patch") : `pre${bump}`;
let chain = Promise.resolve();
// 1. 驗證當前 git 區(qū)是否干凈
chain = chain.then(() => this.verifyWorkingTreeClean());
// 2. 找到自上次來修改過的 packages 同時篩掉 private 為 false 的 pkg
chain = chain.then(() =>
collectUpdates(this.packageGraph.rawPackageList, this.packageGraph, this.execOpts, {
bump: "prerelease",
canary: true,
ignoreChanges,
forcePublish,
includeMergedTags,
}).filter(node => !node.pkg.private)
);
const makeVersion = fallback => ({ lastVersion = fallback, refCount, sha }) => {
// --canary 會通過上一次的 version 來計算出這次的 version
const nextVersion = semver.inc(lastVersion.replace(this.tagPrefix, ""), release.replace("pre", ""));
return `${nextVersion}-${preid}.${Math.max(0, refCount - 1)}+${sha}`;
};
// 3. 根據(jù)不同的 mode 計算出包及版本相關(guān)參數(shù)
if (this.project.isIndependent()) {
// 獨立發(fā)包模式
chain = chain.then(updates =>
// pMap 是個鏈式執(zhí)行過程,上一步的結(jié)果會給到下一步
pMap(updates, node =>
// 根據(jù) tag 匹配出需要發(fā)布的包
describeRef(
{
match: `${node.name}@*`,
cwd,
},
includeMergedTags
)
// 通過上面的 makeVersion 方法來計算發(fā)布的 canary 版本
.then(makeVersion(node.version))
// 返回出去,這里實際上就是個 updateVerions 數(shù)組
.then(version => [node.name, version])
).then(updatesVersions => ({
updates,
updatesVersions,
}))
);
} else {
// 固定的模式,那么所有的包都會使用一個版本(lerna.json 里面的版本)
chain = chain.then(updates =>
describeRef(
{
match: `${this.tagPrefix}*.*.*`,
cwd,
},
includeMergedTags
)
// 只用一個 version 去進行計算
.then(makeVersion(this.project.version))
.then(version => updates.map(node => [node.name, version]))
.then(updatesVersions => ({
updates,
updatesVersions,
}))
);
}
// 4. 返回結(jié)果
return chain.then(({ updates, updatesVersions }) => ({
updates,
updatesVersions,
needsConfirmation: true,
}));
}
相比較于上面兩步, --canary 的處理過程或許看上去要復雜一些,其實不然,根據(jù)上面代碼注釋中的內(nèi)容可以比較清晰的看到整個執(zhí)行流程,不過多了幾種特殊情況需要去做一些判斷,其中比較復雜的第三步,是需要通過 tag 得到一些相關(guān)的信息,需要更新的包,然后針對這些包現(xiàn)有的版本去做一些計算,可以參考上面的 makeVersion 方法,這里就根據(jù) lerna 的 mode 分為了兩種情況。
其中這里第二步還用到了在 lerna version 中收集變更的包的方法:collectUpdates。具體的執(zhí)行機制可以參考我的上一篇關(guān)于 lerna version 的文章。
bump version
如果不帶參數(shù)的話,那么這一步就會直接執(zhí)行一個 lerna version 的過程,一般 lerna publish 的預期行為是這樣:
chain = chain.then(() => versionCommand(this.argv));
lerna version 的具體執(zhí)行機制可以參考我的上一篇文章。
看完這幾種情況之后再回到開頭,再回顧一下 initialize 這一步最后對結(jié)果的一個處理過程,大致 initialize 的一個流程就這樣結(jié)束了。
最后總結(jié)一下 lerna publish 的初始化過程,主要就是根據(jù)不同的發(fā)包情況,然后計算出需要發(fā)布的包的信息,例如包名稱和更新版本。用于下一步發(fā)包的 execute 做準備。
執(zhí)行(execute)
lerna publish 的最后一步即發(fā)包的過程就是在這里完成,代碼結(jié)構(gòu)為:
execute() {
let chain = Promise.resolve();
// 1. 驗證 npm 源、權(quán)限,項目的 License 之內(nèi)
chain = chain.then(() => this.prepareRegistryActions());
chain = chain.then(() => this.prepareLicenseActions());
if (this.options.canary) {
// 如果是測試包,更新到測試包的 version
chain = chain.then(() => this.updateCanaryVersions());
}
// 2. 更新本地依賴包版本 && gitHead
chain = chain.then(() => this.resolveLocalDependencyLinks());
chain = chain.then(() => this.annotateGitHead());
// 3. 更新寫入本地
chain = chain.then(() => this.serializeChanges());
// 4. 對 package pack
chain = chain.then(() => this.packUpdated());
// 5. 發(fā)布包
chain = chain.then(() => this.publishPacked());
if (this.gitReset) {
// 設(shè)置了 --no-git-reset 會把 working tree 的版本修改重置
// lerna 每次發(fā)包都會把更新的 package.json 的 version 的修改提交到 git 上去
// 如果發(fā)測試包,這可能是沒必要,因此可以用這個選項把修改 reset 掉
chain = chain.then(() => this.resetChanges());
}
// 做后續(xù)的處理
return chain.then(() => {
// 發(fā)布包的數(shù)量
const count = this.packagesToPublish.length;
// 發(fā)布包的名稱以及版本,用于輸出展示
const message = this.packagesToPublish.map(pkg => ` - ${pkg.name}@${pkg.version}`);
output("Successfully published:");
output(message.join(os.EOL));
this.logger.success("published", "%d %s", count, count === 1 ? "package" : "packages");
});
}
execute 是 lerna publish 的主要部分了,這一步的相對而言信息量比較巨大,我接下來會將上面的步驟拆一拆,一步一步來講解 execute 這一步是怎么完成 lerna 發(fā)包的整個過程的。
首先可以看到上面代碼中,我通過注釋將這個步驟分成了六步:
1. 驗證 npm && 項目license
首先上面可以看到,這一步分為兩個方法,一步是做 npm 相關(guān)的驗證:prepareRegistryActions
prepareRegistryActions() {
let chain = Promise.resolve();
if (this.conf.get("registry") !== "https://registry.npmjs.org/") {
// 這里的 registry 如果 url 是三方的例如公司的源,這里會跳過后面的檢查
return chain;
}
// --no-verify-access 停止校驗,默認會校驗
if (this.verifyAccess) {
// 拿用戶的 npm username,拿不到在 getNpmUsername 會拋錯
chain = chain.then(() => getNpmUsername(this.conf.snapshot));
// 根據(jù) username 對要發(fā)布的包做個鑒權(quán)
chain = chain.then(username => {
if (username) {
return verifyNpmPackageAccess(this.packagesToPublish, username, this.conf.snapshot);
}
});
// 校驗用戶是否需進行 2fa 的驗證 -- 安全驗證相關(guān)
chain = chain.then(() => getTwoFactorAuthRequired(this.conf.snapshot));
chain = chain.then(isRequired => {
// 記錄一下
this.twoFactorAuthRequired = isRequired;
});
}
return chain;
}
prepareRegistryActions 執(zhí)行時會先去校驗 registry,如果是第三方的 registry,會停止校驗,用戶在發(fā)包設(shè)置了 no-verify-access 就不進行后面校驗,默認會校驗。
校驗過程是首先通過 getNpmUsername 去拿到用戶的 username,這里是通過 npm 提供的相關(guān)接口來獲取,具體流程可以自行參考。拿到 username 之后根據(jù) username 以及本次 publish 中需要發(fā)布的包的信息去做一個鑒權(quán),判斷用戶是否用該包的讀寫發(fā)包權(quán)限,沒有就會拋錯,最后一步是個 2fa 的驗證,一般 npm 包都不會開啟,主要是為了安全作用做二次驗證使用,這里不做具體講解。
下面在看 license 的校驗過程,方法是 prepareLicenseActions :
prepareLicenseActions() {
return Promise.resolve()
// 通過 glob 的方式去找到待發(fā)布的包中沒有 licenses 的
.then(() => getPackagesWithoutLicense(this.project, this.packagesToPublish))
.then(packagesWithoutLicense => {
// 對于沒有 liecense 的包會打個 warnning 出來
if (packagesWithoutLicense.length && !this.project.licensePath) {
this.packagesToBeLicensed = [];
const names = packagesWithoutLicense.map(pkg => pkg.name);
const noun = names.length > 1 ? "Packages" : "Package";
const verb = names.length > 1 ? "are" : "is";
const list =
names.length > 1
? `${names.slice(0, -1).join(", ")}${names.length > 2 ? "," : ""} and ${
names[names.length - 1] /* oxford commas _are_ that important */
}`
: names[0];
this.logger.warn(
"ENOLICENSE",
"%s %s %s missing a license.\n%s\n%s",
noun,
list,
verb,
"One way to fix this is to add a LICENSE.md file to the root of this repository.",
"See https://choosealicense.com for additional guidance."
);
} else {
// 記錄一下
this.packagesToBeLicensed = packagesWithoutLicense;
}
});
}
這一步并不會對主要流程有什么影響,主要就是找目前待發(fā)布的包中沒有 license 的,然后給個 warnning 提示,這里找的方式使用過 lerna 自己構(gòu)造的 project graph 去篩待發(fā)布包中不存在 liecense 文件的路徑,想了解具體過程參考 getPackagesWithoutLicense。
2. 更新本地依賴版本 && 待發(fā)布包 gitHead
可以你會對更新本地依賴版本這一步可能會有些迷惑,這里舉個例子來解釋一下,在 lerna 中,如果 workspaces 之前存在依賴的話,在這次發(fā)包中,例如 A 這個包依賴了 B,B 在這次發(fā)包中版本升級了,那么這里 A 里面依賴的 B 也要更新到對應(yīng)的版本。
來看一下這一步:
resolveLocalDependencyLinks() {
// 先找到依賴過本地包的包
// lerna 中 A, B 都是 workspace, A 依賴 B 引入的時候是通過 symlink 引入的
// 因此這里找 B 的依賴包只用判斷 A 這里 resolved 的是不是個目錄就行
const updatesWithLocalLinks = this.updates.filter(node =>
Array.from(node.localDependencies.values()).some(resolved => resolved.type === "directory")
);
// 拿到上一步結(jié)果之后,就把對應(yīng)的更新寫入 A
return pMap(updatesWithLocalLinks, node => {
for (const [depName, resolved] of node.localDependencies) {
// 注意這里 lerna 是不會處理 B: ../../file/xxx 這種引入情況的
const depVersion = this.updatesVersions.get(depName) || this.packageGraph.get(depName).pkg.version;
// 以 A 為例子,這里 A 的 pkg.json 中的B 就要更新到發(fā)包的版本
node.pkg.updateLocalDependency(resolved, depVersion, this.savePrefix);
}
});
}
這里涉及到的一些操作方法,都是來自于 lerna 構(gòu)建的 project graph,這部分可以去參考一下 lerna core 中源碼。
這里的 gitHead 是一個 hash 值,用戶可以通過 --git-head 來自行指定,如果不指定的話,lerna 這里會默認幫你取當前 commit 的 hash 值,即通過 git rev-parse HEAD 來獲取,一般 gitHead 結(jié)合 from-package 來使用,先看看代碼:
annotateGitHead() {
try {
// 用戶如果沒有默認指定就使用最近的 commit hash 值
// getCurrentSHA 就是執(zhí)行了一次 git rev-parse HEAD
const gitHead = this.options.gitHead || getCurrentSHA(this.execOpts);
for (const pkg of this.packagesToPublish) {
// gitHead 是用來關(guān)聯(lián) package 和 git 記錄
// npm publish 正常情況下需要該字段
pkg.set("gitHead", gitHead);
}
} catch (err) {
}
}
在使用 from-package 的方式進行發(fā)包的時候,會把這個 githead 字段寫在 package.json 里面。
3. 更新寫入本地
這一步就是將第二步的一些更新直接寫到 lerna 中對應(yīng)項目里面去,即寫到磁盤里面,主要的方法為:
serializeChanges() {
return pMap(this.packagesToPublish, pkg => pkg.serialize());
}
這個 pkg.serialize() 方法,是可以在 lerna 的 core 中找到的,主要作用就是將相關(guān)的更新寫入本地磁盤:
serialize() {
// 這里的 writePkg 封裝了 write-package-json 這個方法
return writePkg(this.manifestLocation, this[PKG]).then(() => this);
}
4. package pack
在講解之前,我們得先知道 npm pack 這個操作是干什么的,它會打包當前的文件夾內(nèi)容打包成一個 tar 包,我們在執(zhí)行 npm publish 的時候會經(jīng)常看到這個操作:

不過 npm publish 幫我們封裝了這個過程,lerna publish 中也會有這個過程,這已經(jīng)是發(fā)包前的最后一個操作了,具體可參考代碼:
packUpdated() {
let chain = Promise.resolve();
// ...
const opts = this.conf.snapshot;
const mapper = pPipe(
[
// packDirectory 會給對應(yīng) pkg 的文件夾打個 tar 包出來
// 類似于上面的 npm pack
pkg =>
pulseTillDone(packDirectory(pkg, pkg.location, opts)).then(packed => {
pkg.packed = packed;
return pkg.refresh();
}),
].filter(Boolean)
);
// 這里會按照拓撲序列去對要發(fā)布的包進行 pack
chain = chain.then(() => this.topoMapPackages(mapper));
return pFinally(chain, () => tracker.finish());
}
這一步首先可以參考 topoMapPackages 這個方法,他會按照拓撲順序去對需要更新的包進行 pack,這里 publish 因為涉及到包之間的一些依賴關(guān)系,因此只能按照拓撲的順序去執(zhí)行,this.packagesToPublish 里面存的是待發(fā)布的包:
topoMapPackages(mapper) {
// 這里是作者的一個注釋:
// we don't respect --no-sort here, sorry
return runTopologically(this.packagesToPublish, mapper, {
concurrency: this.concurrency,
rejectCycles: this.options.rejectCycles,
graphType: this.options.graphType === "all" ? "allDependencies" : "dependencies",
});
}
因此這里會按照拓撲順序去對要發(fā)布的包進行打包成 tar 包的操作,具體執(zhí)行方法是 packDirectory 這個方法,這個方法我只貼一下打包的那一段邏輯,還有一些其他的預處理邏輯做了一下刪除:
const tar = require("tar");
const packlist = require("npm-packlist");
function packDirectory(_pkg, dir, _opts) {
// ...
let chain = Promise.resolve();
// 拿到待發(fā)布 pkg 的 目錄信息
chain = chain.then(() => packlist({ path: pkg.contents }));
// 對目錄下面的一些文件夾打包
chain = chain.then(files =>
// 具體參數(shù)去參考 tar 這個 npm 包
tar.create(
{
cwd: pkg.contents,
prefix: "package/",
portable: true,
mtime: new Date("1985-10-26T08:15:00.000Z"),
gzip: true,
},
files.map(f => `./${f}`)
)
);
// 將文件處理成 stream 形式寫到一個臨時目錄下面
// 發(fā)布完了會刪除
chain = chain.then(stream => tempWrite(stream, getTarballName(pkg)));
chain = chain.then(tarFilePath =>
getPacked(pkg, tarFilePath).then(packed =>
Promise.resolve()
.then(() => runLifecycle(pkg, "postpack", opts))
.then(() => packed)
)
);
return chain;
}
5. Package publish
在上一步完成了待發(fā)布包的打包操作之后,這一步就是 lerna publish 整個流程的最后一步了!
這一步會將上一次打包的內(nèi)容直接發(fā)布出去,先來看一下代碼:
publishPacked() {
let chain = Promise.resolve();
// 前面說過的 2fa 2次驗證,這里會驗證一下
if (this.twoFactorAuthRequired) {
chain = chain.then(() => this.requestOneTimePassword());
}
const opts = Object.assign(this.conf.snapshot, {
// 設(shè)置了 tempTag 就先用 lerna-temp
tag: this.options.tempTag ? "lerna-temp" : this.conf.get("tag"),
});
const mapper = pPipe(
[
pkg => {
// 拿到 pkg 上一次發(fā)布的 tag,通過 semver.prerelease 進行判斷
const preDistTag = this.getPreDistTag(pkg);
// 取一下 tag,一般這里會取 opts.tag,針對于每個包的情況不同
const tag = !this.options.tempTag && preDistTag ? preDistTag : opts.tag;
// 這里 rewrite 一下 tag
const pkgOpts = Object.assign({}, opts, { tag });
// 發(fā)布包這個操作通過 npmPublish 這個過程來完成
return pulseTillDone(npmPublish(pkg, pkg.packed.tarFilePath, pkgOpts, this.otpCache)).then(() => {
return pkg;
});
}
].filter(Boolean)
);
// 這里和上一步 pack 一樣,按照拓撲執(zhí)行
chain = chain.then(() => this.topoMapPackages(mapper));
return pFinally(chain, () => tracker.finish());
}
上一步講了 topoMapPackages 這個方法,這里同樣的,它會按照拓撲順序去發(fā)布待發(fā)布的 pkg。
在 npmPublish 這個方法中,會將前面打包的 pkg 的 tar 包 publish 到 npm 上面去,這里用的是 lerna 作者自己的一個包,感興趣的可以去 npm 上搜一下:@evocateur/libnpmpublish
這個包可以不用擔心 tarball 打包自于哪個 pkg,只要你有個 tarball 它會幫你直接上傳到 npm 上面去來完成一次發(fā)布,具體的內(nèi)容可以在 npm 中找到。
這里因為引入了一些外部包加上這里有太多的邊界條件處理,這里就不具體去看 npmPublish 這個方法了,貼上發(fā)布的那部分代碼,可以參考一下:
const { publish } = require("@evocateur/libnpmpublish");
return otplease(innerOpts => publish(manifest, tarData, innerOpts), opts, otpCache).catch(err => {
// re-throw to break chain upstream
throw err;
});
那么再走到這一步結(jié)束之后,基本上整個 lerna 的發(fā)包流程都走完了。
后續(xù)的一些收尾工作的處理,可以再拉回 執(zhí)行(execute) 這一節(jié)開頭的代碼分析那里。
總結(jié)
本文從源碼角度剖析了一下 lerna publish 的執(zhí)行機制,對于一些邊界的 corner case 有些刪減,按照主線講解了 lerna publish 是怎么完成 lerna monorepo 中的整個發(fā)包流程操作的。希望本系列的文章能對你有所幫助。
END


“分享、點贊、在看” 支持一波
