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

設(shè)置屬性(configureProperties)
相比較于 lerna version,lerna publish 的這一步就簡(jiǎn)單許多,大致就是根據(jù) cli 的 options 對(duì)一些參數(shù)進(jìn)行了初始化:
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 會(huì)指定一個(gè)具體的 version 版本,而不會(huì)加上 npm 那邊的版本兼容前綴
this.savePrefix = exact ? "" : "^";
// 用于用戶自定義包的版本 tag 前綴,而不是使用默認(rèn)的 v
this.tagPrefix = tagVersionPrefix;
// --no-git-reset 用于避免 lerna publish 將暫存區(qū)的未提交的代碼都 push 到 git 上
this.gitReset = gitReset !== false;
// lerna 發(fā)包會(huì)默認(rèn)檢查用戶 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ù)的初始化,這里就不詳細(xì)介紹。
初始化(initialize)
下面直接進(jìn)來初始化的流程中來,因?yàn)樯婕暗桨l(fā)包相關(guān)的流程,這一步的前面過程涉及到的就是一些關(guān)于 npm 相關(guān)的 config 初始化,之后再根據(jù)不同的發(fā)包情況去進(jìn)行對(duì)應(yīng)的事件注冊(cè),這一步的事件注冊(cè)以及執(zhí)行方式都和 lerna version 源碼解析時(shí)比較類似,主要過程可以分為三個(gè)步驟:
初始化 npm config 參數(shù)
根據(jù)不同的發(fā)包情況執(zhí)行不同的方法
處理上一步返回的結(jié)果
這里不同的發(fā)包情況指的即是在文章開頭介紹的 lerna publish 的幾種執(zhí)行方式,這里大致梳理一下以下的步驟:
initialize() {
// --skip-npm 相當(dāng)于直接執(zhí)行 lerna version
if (this.options.skipNpm) {
// 該 api 會(huì)在下個(gè) major 被棄用
this.logger.warn("deprecated", "Instead of --skip-npm, call `lerna version` directly");
// 這里我們可以看到 lerna 中某個(gè) command 調(diào)用其他 command 都是通過這種鏈?zhǔn)秸{(diào)用的方式
return versionCommand(this.argv).then(() => false);
}
// 1. 初始化 npm config 參數(shù)
// session 和 userAgent 都是 npm 發(fā)包需要驗(yàn)證的參數(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ā)包時(shí)候自定義 tag
// 一般默認(rèn) tag 是 latest
// lerna 中如果沒指定 --dist-tag, 正式包的 tag 會(huì)用 latest, --canary 的測(cè)試包會(huì)用 canary
const distTag = this.getDistTag();
// 如果該參數(shù)存在 會(huì)被注入進(jìn) npm conf 中
if (distTag) {
this.conf.set("tag", distTag.trim(), "cli");
}
// 注冊(cè)運(yùn)行 lerna.json 里面的 script 的 runner
this.runPackageLifecycle = createRunner(this.options);
// 如果 lerna 子 package 里面的 pkg.json 里面有 pre|post publish 這樣的 script
// 會(huì)跳過 lifecycle script 的執(zhí)行過程,否則會(huì)去遞歸執(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)建一個(gè)執(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. 對(duì)方法返回的結(jié)果做一個(gè)處理
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 的時(shí)候把 pkg.json 里面設(shè)置private 為 false 的包忽略掉
this.updates = result.updates.filter(node => !node.pkg.private);
// 需要更新的包以及對(duì)應(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 這個(gè) options
if (this.options.contents) {
// 把這些目錄寫進(jìn)需要發(fā)包的 pkg.json 中
for (const pkg of this.packagesToPublish) {
pkg.contents = this.options.contents;
}
}
// 用于確認(rèn)上面除了 versioncommand 的其他三種執(zhí)行情況
// 例如 versionCommand 有自己的 confirm 過程
if (result.needsConfirmation) {
return this.confirmPublish();
}
return true;
});
}
initialize 前面有介紹主要分為三個(gè)步驟來執(zhí)行,因此 1、3 兩個(gè)步驟根據(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進(jìn)行發(fā)包from-package即根據(jù) lerna 下的 package 里面的 pkg.json 的 version 變動(dòng)來發(fā)包--canary發(fā)測(cè)試版本的包剩下不帶參數(shù)的情況就直接走一個(gè) bump version(即執(zhí)行 lerna version)
下面從這幾種情況做個(gè)介紹:
from-git
這一步的執(zhí)行入口函數(shù)是 detectFromGit ,我們直接看這個(gè)函數(shù)的執(zhí)行過程:
detectFromGit() {
const matchingPattern = this.project.isIndependent() ? "*@*" : `${this.tagPrefix}*.*.*`;
let chain = Promise.resolve();
// 1. 驗(yàn)證當(dāng)前的 git 工作區(qū)域是否干凈 通過 git describe 來找
chain = chain.then(() => this.verifyWorkingTreeClean());
// 2. 拿到當(dāng)前 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 [];
}
// 獨(dú)立發(fā)包模式就拿到所有包的數(shù)組
if (this.project.isIndependent()) {
return taggedPackageNames.map(name => this.packageGraph.get(name));
}
// 固定模式只用拿到一個(gè)版本,所有的包都用一個(gè)版本
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í)行過程還是比較簡(jiǎn)單明了的,在上面注釋中根據(jù)不同的方法執(zhí)行過程分為了 5 個(gè)步驟。主要就是根據(jù)當(dāng)前 commit 拿到 tags 里面的 packages 然后返回這些 packages 以及其版本信息。
from-package
這一步執(zhí)行的入口函數(shù)是 detectFromPackage ,直接看執(zhí)行過程:
detectFromPackage() {
let chain = Promise.resolve();
// 1. 驗(yàn)證當(dāng)前 git 工作區(qū)是否干凈,步驟同上
chain = chain.then(() => this.verifyWorkingTreeClean());
// 2. 通過 getUnpublishedPackages 篩除 private 為 true 的 package && 拿到需要發(fā)布的pkg
// 這一步用了 npm config 里面的快照來做對(duì)比
chain = chain.then(() => getUnpublishedPackages(this.packageGraph, this.conf.snapshot));
// 3. 驗(yàn)證結(jié)果符合預(yù)期否
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)于版本的比對(duì),從而得到需要更新的 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. 驗(yàn)證當(dāng)前 git 區(qū)是否干凈
chain = chain.then(() => this.verifyWorkingTreeClean());
// 2. 找到自上次來修改過的 packages 同時(shí)篩掉 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 會(huì)通過上一次的 version 來計(jì)算出這次的 version
const nextVersion = semver.inc(lastVersion.replace(this.tagPrefix, ""), release.replace("pre", ""));
return `${nextVersion}-${preid}.${Math.max(0, refCount - 1)}+${sha}`;
};
// 3. 根據(jù)不同的 mode 計(jì)算出包及版本相關(guān)參數(shù)
if (this.project.isIndependent()) {
// 獨(dú)立發(fā)包模式
chain = chain.then(updates =>
// pMap 是個(gè)鏈?zhǔn)綀?zhí)行過程,上一步的結(jié)果會(huì)給到下一步
pMap(updates, node =>
// 根據(jù) tag 匹配出需要發(fā)布的包
describeRef(
{
match: `${node.name}@*`,
cwd,
},
includeMergedTags
)
// 通過上面的 makeVersion 方法來計(jì)算發(fā)布的 canary 版本
.then(makeVersion(node.version))
// 返回出去,這里實(shí)際上就是個(gè) updateVerions 數(shù)組
.then(version => [node.name, version])
).then(updatesVersions => ({
updates,
updatesVersions,
}))
);
} else {
// 固定的模式,那么所有的包都會(huì)使用一個(gè)版本(lerna.json 里面的版本)
chain = chain.then(updates =>
describeRef(
{
match: `${this.tagPrefix}*.*.*`,
cwd,
},
includeMergedTags
)
// 只用一個(gè) version 去進(jìn)行計(jì)算
.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 的處理過程或許看上去要復(fù)雜一些,其實(shí)不然,根據(jù)上面代碼注釋中的內(nèi)容可以比較清晰的看到整個(gè)執(zhí)行流程,不過多了幾種特殊情況需要去做一些判斷,其中比較復(fù)雜的第三步,是需要通過 tag 得到一些相關(guān)的信息,需要更新的包,然后針對(duì)這些包現(xiàn)有的版本去做一些計(jì)算,可以參考上面的 makeVersion 方法,這里就根據(jù) lerna 的 mode 分為了兩種情況。
其中這里第二步還用到了在 lerna version 中收集變更的包的方法:collectUpdates。具體的執(zhí)行機(jī)制可以參考我的上一篇關(guān)于 lerna version 的文章。
bump version
如果不帶參數(shù)的話,那么這一步就會(huì)直接執(zhí)行一個(gè) lerna version 的過程,一般 lerna publish 的預(yù)期行為是這樣:
chain = chain.then(() => versionCommand(this.argv));
lerna version 的具體執(zhí)行機(jī)制可以參考我的上一篇文章。
看完這幾種情況之后再回到開頭,再回顧一下 initialize 這一步最后對(duì)結(jié)果的一個(gè)處理過程,大致 initialize 的一個(gè)流程就這樣結(jié)束了。
最后總結(jié)一下 lerna publish 的初始化過程,主要就是根據(jù)不同的發(fā)包情況,然后計(jì)算出需要發(fā)布的包的信息,例如包名稱和更新版本。用于下一步發(fā)包的 execute 做準(zhǔn)備。
執(zhí)行(execute)
lerna publish 的最后一步即發(fā)包的過程就是在這里完成,代碼結(jié)構(gòu)為:
execute() {
let chain = Promise.resolve();
// 1. 驗(yàn)證 npm 源、權(quán)限,項(xiàng)目的 License 之內(nèi)
chain = chain.then(() => this.prepareRegistryActions());
chain = chain.then(() => this.prepareLicenseActions());
if (this.options.canary) {
// 如果是測(cè)試包,更新到測(cè)試包的 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. 對(duì) package pack
chain = chain.then(() => this.packUpdated());
// 5. 發(fā)布包
chain = chain.then(() => this.publishPacked());
if (this.gitReset) {
// 設(shè)置了 --no-git-reset 會(huì)把 working tree 的版本修改重置
// lerna 每次發(fā)包都會(huì)把更新的 package.json 的 version 的修改提交到 git 上去
// 如果發(fā)測(cè)試包,這可能是沒必要,因此可以用這個(gè)選項(xiàng)把修改 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 的主要部分了,這一步的相對(duì)而言信息量比較巨大,我接下來會(huì)將上面的步驟拆一拆,一步一步來講解 execute 這一步是怎么完成 lerna 發(fā)包的整個(gè)過程的。
首先可以看到上面代碼中,我通過注釋將這個(gè)步驟分成了六步:
1. 驗(yàn)證 npm && 項(xiàng)目license
首先上面可以看到,這一步分為兩個(gè)方法,一步是做 npm 相關(guān)的驗(yàn)證:prepareRegistryActions
prepareRegistryActions() {
let chain = Promise.resolve();
if (this.conf.get("registry") !== "https://registry.npmjs.org/") {
// 這里的 registry 如果 url 是三方的例如公司的源,這里會(huì)跳過后面的檢查
return chain;
}
// --no-verify-access 停止校驗(yàn),默認(rèn)會(huì)校驗(yàn)
if (this.verifyAccess) {
// 拿用戶的 npm username,拿不到在 getNpmUsername 會(huì)拋錯(cuò)
chain = chain.then(() => getNpmUsername(this.conf.snapshot));
// 根據(jù) username 對(duì)要發(fā)布的包做個(gè)鑒權(quán)
chain = chain.then(username => {
if (username) {
return verifyNpmPackageAccess(this.packagesToPublish, username, this.conf.snapshot);
}
});
// 校驗(yàn)用戶是否需進(jìn)行 2fa 的驗(yàn)證 -- 安全驗(yàn)證相關(guān)
chain = chain.then(() => getTwoFactorAuthRequired(this.conf.snapshot));
chain = chain.then(isRequired => {
// 記錄一下
this.twoFactorAuthRequired = isRequired;
});
}
return chain;
}
prepareRegistryActions 執(zhí)行時(shí)會(huì)先去校驗(yàn) registry,如果是第三方的 registry,會(huì)停止校驗(yàn),用戶在發(fā)包設(shè)置了 no-verify-access 就不進(jìn)行后面校驗(yàn),默認(rèn)會(huì)校驗(yàn)。
校驗(yàn)過程是首先通過 getNpmUsername 去拿到用戶的 username,這里是通過 npm 提供的相關(guān)接口來獲取,具體流程可以自行參考。拿到 username 之后根據(jù) username 以及本次 publish 中需要發(fā)布的包的信息去做一個(gè)鑒權(quán),判斷用戶是否用該包的讀寫發(fā)包權(quán)限,沒有就會(huì)拋錯(cuò),最后一步是個(gè) 2fa 的驗(yàn)證,一般 npm 包都不會(huì)開啟,主要是為了安全作用做二次驗(yàn)證使用,這里不做具體講解。
下面在看 license 的校驗(yàn)過程,方法是 prepareLicenseActions :
prepareLicenseActions() {
return Promise.resolve()
// 通過 glob 的方式去找到待發(fā)布的包中沒有 licenses 的
.then(() => getPackagesWithoutLicense(this.project, this.packagesToPublish))
.then(packagesWithoutLicense => {
// 對(duì)于沒有 liecense 的包會(huì)打個(gè) 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;
}
});
}
這一步并不會(huì)對(duì)主要流程有什么影響,主要就是找目前待發(fā)布的包中沒有 license 的,然后給個(gè) warnning 提示,這里找的方式使用過 lerna 自己構(gòu)造的 project graph 去篩待發(fā)布包中不存在 liecense 文件的路徑,想了解具體過程參考 getPackagesWithoutLicense。
2. 更新本地依賴版本 && 待發(fā)布包 gitHead
可以你會(huì)對(duì)更新本地依賴版本這一步可能會(huì)有些迷惑,這里舉個(gè)例子來解釋一下,在 lerna 中,如果 workspaces 之前存在依賴的話,在這次發(fā)包中,例如 A 這個(gè)包依賴了 B,B 在這次發(fā)包中版本升級(jí)了,那么這里 A 里面依賴的 B 也要更新到對(duì)應(yīng)的版本。
來看一下這一步:
resolveLocalDependencyLinks() {
// 先找到依賴過本地包的包
// lerna 中 A, B 都是 workspace, A 依賴 B 引入的時(shí)候是通過 symlink 引入的
// 因此這里找 B 的依賴包只用判斷 A 這里 resolved 的是不是個(gè)目錄就行
const updatesWithLocalLinks = this.updates.filter(node =>
Array.from(node.localDependencies.values()).some(resolved => resolved.type === "directory")
);
// 拿到上一步結(jié)果之后,就把對(duì)應(yīng)的更新寫入 A
return pMap(updatesWithLocalLinks, node => {
for (const [depName, resolved] of node.localDependencies) {
// 注意這里 lerna 是不會(huì)處理 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 是一個(gè) hash 值,用戶可以通過 --git-head 來自行指定,如果不指定的話,lerna 這里會(huì)默認(rèn)幫你取當(dāng)前 commit 的 hash 值,即通過 git rev-parse HEAD 來獲取,一般 gitHead 結(jié)合 from-package 來使用,先看看代碼:
annotateGitHead() {
try {
// 用戶如果沒有默認(rèn)指定就使用最近的 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 的方式進(jìn)行發(fā)包的時(shí)候,會(huì)把這個(gè) githead 字段寫在 package.json 里面。
3. 更新寫入本地
這一步就是將第二步的一些更新直接寫到 lerna 中對(duì)應(yīng)項(xiàng)目里面去,即寫到磁盤里面,主要的方法為:
serializeChanges() {
return pMap(this.packagesToPublish, pkg => pkg.serialize());
}
這個(gè) pkg.serialize() 方法,是可以在 lerna 的 core 中找到的,主要作用就是將相關(guān)的更新寫入本地磁盤:
serialize() {
// 這里的 writePkg 封裝了 write-package-json 這個(gè)方法
return writePkg(this.manifestLocation, this[PKG]).then(() => this);
}
4. package pack
在講解之前,我們得先知道 npm pack 這個(gè)操作是干什么的,它會(huì)打包當(dāng)前的文件夾內(nèi)容打包成一個(gè) tar 包,我們?cè)趫?zhí)行 npm publish 的時(shí)候會(huì)經(jīng)常看到這個(gè)操作:

不過 npm publish 幫我們封裝了這個(gè)過程,lerna publish 中也會(huì)有這個(gè)過程,這已經(jīng)是發(fā)包前的最后一個(gè)操作了,具體可參考代碼:
packUpdated() {
let chain = Promise.resolve();
// ...
const opts = this.conf.snapshot;
const mapper = pPipe(
[
// packDirectory 會(huì)給對(duì)應(yīng) pkg 的文件夾打個(gè) tar 包出來
// 類似于上面的 npm pack
pkg =>
pulseTillDone(packDirectory(pkg, pkg.location, opts)).then(packed => {
pkg.packed = packed;
return pkg.refresh();
}),
].filter(Boolean)
);
// 這里會(huì)按照拓?fù)湫蛄腥?duì)要發(fā)布的包進(jìn)行 pack
chain = chain.then(() => this.topoMapPackages(mapper));
return pFinally(chain, () => tracker.finish());
}
這一步首先可以參考 topoMapPackages 這個(gè)方法,他會(huì)按照拓?fù)漤樞蛉?duì)需要更新的包進(jìn)行 pack,這里 publish 因?yàn)樯婕暗桨g的一些依賴關(guān)系,因此只能按照拓?fù)涞捻樞蛉?zhí)行,this.packagesToPublish 里面存的是待發(fā)布的包:
topoMapPackages(mapper) {
// 這里是作者的一個(gè)注釋:
// 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",
});
}
因此這里會(huì)按照拓?fù)漤樞蛉?duì)要發(fā)布的包進(jìn)行打包成 tar 包的操作,具體執(zhí)行方法是 packDirectory 這個(gè)方法,這個(gè)方法我只貼一下打包的那一段邏輯,還有一些其他的預(yù)處理邏輯做了一下刪除:
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 }));
// 對(duì)目錄下面的一些文件夾打包
chain = chain.then(files =>
// 具體參數(shù)去參考 tar 這個(gè) 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 形式寫到一個(gè)臨時(shí)目錄下面
// 發(fā)布完了會(huì)刪除
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 整個(gè)流程的最后一步了!
這一步會(huì)將上一次打包的內(nèi)容直接發(fā)布出去,先來看一下代碼:
publishPacked() {
let chain = Promise.resolve();
// 前面說過的 2fa 2次驗(yàn)證,這里會(huì)驗(yàn)證一下
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 進(jìn)行判斷
const preDistTag = this.getPreDistTag(pkg);
// 取一下 tag,一般這里會(huì)取 opts.tag,針對(duì)于每個(gè)包的情況不同
const tag = !this.options.tempTag && preDistTag ? preDistTag : opts.tag;
// 這里 rewrite 一下 tag
const pkgOpts = Object.assign({}, opts, { tag });
// 發(fā)布包這個(gè)操作通過 npmPublish 這個(gè)過程來完成
return pulseTillDone(npmPublish(pkg, pkg.packed.tarFilePath, pkgOpts, this.otpCache)).then(() => {
return pkg;
});
}
].filter(Boolean)
);
// 這里和上一步 pack 一樣,按照拓?fù)鋱?zhí)行
chain = chain.then(() => this.topoMapPackages(mapper));
return pFinally(chain, () => tracker.finish());
}
上一步講了 topoMapPackages 這個(gè)方法,這里同樣的,它會(huì)按照拓?fù)漤樞蛉グl(fā)布待發(fā)布的 pkg。
在 npmPublish 這個(gè)方法中,會(huì)將前面打包的 pkg 的 tar 包 publish 到 npm 上面去,這里用的是 lerna 作者自己的一個(gè)包,感興趣的可以去 npm 上搜一下:@evocateur/libnpmpublish
這個(gè)包可以不用擔(dān)心 tarball 打包自于哪個(gè) pkg,只要你有個(gè) tarball 它會(huì)幫你直接上傳到 npm 上面去來完成一次發(fā)布,具體的內(nèi)容可以在 npm 中找到。
這里因?yàn)橐肓艘恍┩獠堪由线@里有太多的邊界條件處理,這里就不具體去看 npmPublish 這個(gè)方法了,貼上發(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é)束之后,基本上整個(gè) lerna 的發(fā)包流程都走完了。
后續(xù)的一些收尾工作的處理,可以再拉回 執(zhí)行(execute) 這一節(jié)開頭的代碼分析那里。
總結(jié)
本文從源碼角度剖析了一下 lerna publish 的執(zhí)行機(jī)制,對(duì)于一些邊界的 corner case 有些刪減,按照主線講解了 lerna publish 是怎么完成 lerna monorepo 中的整個(gè)發(fā)包流程操作的。希望本系列的文章能對(duì)你有所幫助。
