深刻 lerna 發包機制 —— lerna publish

前言:lerna 做爲一個風靡的前端 monorepo 管理工具,在許許多多的大型項目中都獲得的實踐,如今筆者在公司中開發的 monorepo 工具中,monorepo 中子項目的發包能力就是基於 lerna 來完成,所以本篇文章將講解發包中最關鍵的命令即 lerna publish前端

在上一篇文章中介紹完了 lerna version 的運行機制後,那麼在本篇文章中我將繼續介紹一下 lerna 發包機制中最關鍵的一個 command 即 lerna publishnode

如今咱們來繼續介紹 lerna publish 運行機制,做爲發包機制中的最後決定性的一個指令,lerna publish 的作的工做其實很簡單,就是將 monorepo 須要發佈的包,發佈到 npm registry 上面去。git

一樣 lerna publish 也分爲幾種不一樣的場景去運行:npm

lerna publish  
# lerna version + lerna publish from-git
lerna publish from-git 
# 發佈當前 commit 中打上 annoted tag version 的包
lerna publish from-packages 
# 發佈 package 中 pkg.json 上的 version 在 registry(高於 latest version)不存在的包
複製代碼

官方文檔,lerna publish 一共有這樣幾種執行表現形式:json

lerna publish 永遠不會發布 package.json 中 private 設置爲 true 的包api

  • 發佈自上次發佈來有更新的包(這裏的上次發佈也是基於上次執行lerna publish 而言)
  • 發佈在當前 commit 上打上了 annotated tag 的包(即 lerna publish from-git)
  • 發佈在最近 commit 中修改了 package.json 中的 version (且該 version 在 registry 中沒有發佈過)的包(即 lerna publish from-package)
  • 發佈在上一次提交中更新了的 unversioned 的測試版本的包(以及依賴了的包)

lerna publish 自己提供了很多的 options,例如支持發佈測試版本的包即 (lerna version --canary)。數組

在上文 lerna version 源碼解析中,咱們按照 configureProperties -> initialize -> execute 的順序講解了 lerna version 的執行順序,其實在 lerna 中,幾乎全部子命令源碼的執行順序都是按照這樣一個結構在進行,lerna 自己做爲一個 monorepo,主要是使用 core 核心中的執行機制來去分發命令給各個子項目去執行,所以套路都是同樣的。promise

在開始閱讀以前,我先提供一個總體的思惟導圖,可讓讀者在開始閱讀前有個大體的結構,也便於在閱讀過程能夠藉此來進行回顧:安全

image-20210402004932449

設置屬性(configureProperties)

相比較於 lerna version,lerna publish 的這一步就簡單許多,大體就是根據 cli 的 options 對一些參數進行了初始化:bash

configureProperties() {
    const {
      exact,
      gitHead,
      gitReset,
      tagVersionPrefix = "v",
      verifyAccess,
    } = this.options;

   // 這裏的 requiresGit 指的是除了 from-package 的其它發包方式
    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 將暫存區的未提交的代碼都 push 到 git 上
    this.gitReset = gitReset !== false;
  
    // lerna 發包會默認檢查用戶 npm 權限 
    // 設置 --no-verify-access 跳過檢查 
    this.verifyAccess = verifyAccess !== false;

  // npm 發包相關配置
    this.npmSession = crypto.randomBytes(8).toString("hex");
  }
複製代碼

經過註釋就能夠比較清晰的看到一些 options 以及相關參數的初始化,這裏就不詳細介紹。

初始化(initialize)

下面直接進來初始化的流程中來,由於涉及到發包相關的流程,這一步的前面過程涉及到的就是一些關於 npm 相關的 config 初始化,以後再根據不一樣的發包狀況去進行對應的事件註冊,這一步的事件註冊以及執行方式都和 lerna version 源碼解析時比較相似,主要過程能夠分爲三個步驟:

  1. 初始化 npm config 參數
  2. 根據不一樣的發包狀況執行不一樣的方法
  3. 處理上一步返回的結果

這裏不一樣的發包狀況指的便是在文章開頭介紹的 lerna publish 的幾種執行方式,這裏大體梳理一下如下的步驟:

initialize() {
    // --skip-npm 至關於直接執行 lerna version 
    if (this.options.skipNpm) {
      // 該 api 會在下個 major 被棄用
      this.logger.warn("deprecated", "Instead of --skip-npm, call `lerna version` directly");
     // 這裏咱們能夠看到 lerna 中某個 command 調用其餘 command 都是經過這種鏈式調用的方式
      return versionCommand(this.argv).then(() => false);
    }
    
    // 1. 初始化 npm config 參數

    // session 和 userAgent 都是 npm 發包須要驗證的參數
    this.logger.verbose("session", this.npmSession);
    this.logger.verbose("user-agent", this.userAgent);

    // npm config 相關, 存一些 npm config 相關值
    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 用於 設置發包時候自定義 tag
    // 通常默認 tag 是 latest
    // lerna 中若是沒指定 --dist-tag, 正式包的 tag 會用 latest, --canary 的測試包會用 canary
    const distTag = this.getDistTag();

    // 若是該參數存在 會被注入進 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 的執行過程,不然會去遞歸執行
    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. 根據不一樣的發包狀況執行不一樣的方法
    
    // 經過 promise 構建一個執行鏈, 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. 對方法返回的結果作一個處理
    
    return chain.then(result => {
      // 若是上一步是走了 lerna version 的 bump version 過程
      if (!result) {
        return false;
      }

      // lerna version 返回的結果數組裏面沒有須要更新的 package
      if (!result.updates.length) {
        this.logger.success("No changed packages to publish");
        return false;
      }

      // publish 的時候把 pkg.json 裏面設置private 爲 false 的包忽略掉 
      this.updates = result.updates.filter(node => !node.pkg.private);
      // 須要更新的包以及對應更新到的version 
      this.updatesVersions = new Map(result.updatesVersions);

      // 再篩選一下須要發包的 packages,根據是否存在 pkg.json
      this.packagesToPublish = this.updates.map(node => node.pkg);

      // 用於發佈 lerna 管理的 packages 的一些子目錄例如 dist
      // 參考 --contents 這個 options
      if (this.options.contents) {
        // 把這些目錄寫進須要發包的 pkg.json 中
        for (const pkg of this.packagesToPublish) {
          pkg.contents = this.options.contents;
        }
      }

      // 用於確認上面除了 versioncommand 的其餘三種執行狀況
      // 例如 versionCommand 有本身的 confirm 過程
      if (result.needsConfirmation) {
        return this.confirmPublish();
      }

      return true;
    });
  }
複製代碼

initialize 前面有介紹主要分爲三個步驟來執行,所以 一、3 兩個步驟根據註釋來理解過程仍是比較清晰的,這裏主要介紹一下第二步即 根據不一樣的發包狀況來執行不用的方法,具體代碼:

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));
}
複製代碼

首先根據上面代碼中以及文章開頭介紹,能夠很清晰的知道具體分爲這幾種狀況:

  • from-git 即根據 git commit 上的 annotaed tag 進行發包
  • from-package 即根據 lerna 下的 package 裏面的 pkg.json 的 version 變更來發包
  • --canary 發測試版本的包
  • 剩下不帶參數的狀況就直接走一個 bump version(即執行 lerna version)

下面從這幾種狀況作個介紹:

from-git

這一步的執行入口函數是 detectFromGit ,咱們直接看這個函數的執行過程:

detectFromGit() {
    
    const matchingPattern = this.project.isIndependent() ? "*@*" : `${this.tagPrefix}*.*.*`;

    let chain = Promise.resolve();

    // 1. 驗證當前的 git 工做區域是否乾淨 經過 git describe 來找
    chain = chain.then(() => this.verifyWorkingTreeClean());

    // 2. 拿到當前 commit 上面的 tag
    chain = chain.then(() => getCurrentTags(this.execOpts, matchingPattern));
  
    // 3. 經過上一步的 tag 拿到須要更新的 pkg 的數組
    chain = chain.then(taggedPackageNames => {
      if (!taggedPackageNames.length) {
        this.logger.notice("from-git", "No tagged release found");

        return [];
      }
      // 獨立發包模式就拿到全部包的數組
      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 中設置了 private 爲 false 的包
    chain = chain.then(updates => updates.filter(node => !node.pkg.private));

    // 5. updateVersions 存須要發佈的包名以及發佈的版本
    return chain.then(updates => {
      const updatesVersions = updates.map(node => [node.name, node.version]);

      return {
        updates,
        updatesVersions,
        needsConfirmation: true,
      };
    });
  }
複製代碼

能夠看到這一步函數的執行過程仍是比較簡單明瞭的,在上面註釋中根據不一樣的方法執行過程分爲了 5 個步驟。主要就是根據當前 commit 拿到 tags 裏面的 packages 而後返回這些 packages 以及其版本信息。

from-package

這一步執行的入口函數是 detectFromPackage ,直接看執行過程:

detectFromPackage() {
    let chain = Promise.resolve();

    // 1. 驗證當前 git 工做區是否乾淨,步驟同上
    chain = chain.then(() => this.verifyWorkingTreeClean());

    // 2. 經過 getUnpublishedPackages 篩除 private 爲 true 的 package && 拿到須要發佈的pkg
    // 這一步用了 npm config 裏面的快照來作對比
    chain = chain.then(() => getUnpublishedPackages(this.packageGraph, this.conf.snapshot));
    
    // 3. 驗證結果符合預期否
    chain = chain.then(unpublished => {
      if (!unpublished.length) {
        this.logger.notice("from-package", "No unpublished release found");
      }

      return unpublished;
    });

    // 4. updateVersions 存須要發佈的包名以及發佈的版本,返回結果
    return chain.then(updates => {
      const updatesVersions = updates.map(node => [node.name, node.version]);

      return {
        updates,
        updatesVersions,
        needsConfirmation: true,
      };
    });
  }
複製代碼

這一步主要是在 getUnpublishedPackages 這一步篩選出須要更新的 packages,這裏 lerna 做者使用了本身封裝的 pacote 庫來去作一些關於版本的比對,從而獲得須要更新的 packages,這裏有想了解的能夠自行去閱讀一下,不作過多贅述。

--canary

這一步執行的入口函數是 detectCanaryVersions ,直接看執行過程:

detectCanaryVersions() {
  // 初始化處理參數
    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 區是否乾淨
    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. 根據不一樣的 mode 計算出包及版本相關參數
    if (this.project.isIndependent()) {
      // 獨立發包模式
      chain = chain.then(updates =>
        // pMap 是個鏈式執行過程,上一步的結果會給到下一步
        pMap(updates, node =>
          // 根據 tag 匹配出須要發佈的包
          describeRef(
            {
              match: `${node.name}@*`,
              cwd,
            },
            includeMergedTags
          )
             // 經過上面的 makeVersion 方法來計算髮布的 canary 版本
            .then(makeVersion(node.version))
             // 返回出去,這裏實際上就是個 updateVerions 數組
            .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. 返回結果
    return chain.then(({ updates, updatesVersions }) => ({
      updates,
      updatesVersions,
      needsConfirmation: true,
    }));
  }
複製代碼

相比較於上面兩步, --canary 的處理過程或許看上去要複雜一些,其實否則,根據上面代碼註釋中的內容能夠比較清晰的看到整個執行流程,不過多了幾種特殊狀況須要去作一些判斷,其中比較複雜的第三步,是須要經過 tag 獲得一些相關的信息,須要更新的包,而後針對這些包現有的版本去作一些計算,能夠參考上面的 makeVersion 方法,這裏就根據 lerna 的 mode 分爲了兩種狀況。

其中這裏第二步還用到了在 lerna version 中收集變動的包的方法:collectUpdates。具體的執行機制能夠參考個人上一篇關於 lerna version 的文章。

bump version

若是不帶參數的話,那麼這一步就會直接執行一個 lerna version 的過程,通常 lerna publish 的預期行爲是這樣:

chain = chain.then(() => versionCommand(this.argv));
複製代碼

lerna version 的具體執行機制能夠參考個人上一篇文章。

看完這幾種狀況以後再回到開頭,再回顧一下 initialize 這一步最後對結果的一個處理過程,大體 initialize 的一個流程就這樣結束了。

最後總結一下 lerna publish 的初始化過程,主要就是根據不一樣的發包狀況,而後計算出須要發佈的包的信息,例如包名稱和更新版本。用於下一步發包的 execute 作準備。

執行(execute)

lerna publish 的最後一步即發包的過程就是在這裏完成,代碼結構爲:

execute() {
    let chain = Promise.resolve();

    // 1. 驗證 npm 源、權限,項目的 License 以內
    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. 發佈包
    chain = chain.then(() => this.publishPacked());

    if (this.gitReset) {
      // 設置了 --no-git-reset 會把 working tree 的版本修改重置
      // lerna 每次發包都會把更新的 package.json 的 version 的修改提交到 git 上去
      // 若是發測試包,這多是不必,所以能夠用這個選項把修改 reset 掉
      chain = chain.then(() => this.resetChanges());
    }

   // 作後續的處理
    return chain.then(() => {
      // 發佈包的數量
      const count = this.packagesToPublish.length;
      // 發佈包的名稱以及版本,用於輸出展現
      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");
    });
  }
複製代碼

executelerna publish 的主要部分了,這一步的相對而言信息量比較巨大,我接下來會將上面的步驟拆一拆,一步一步來說解 execute 這一步是怎麼完成 lerna 發包的整個過程的。

首先能夠看到上面代碼中,我經過註釋將這個步驟分紅了六步:

1. 驗證 npm && 項目license

首先上面能夠看到,這一步分爲兩個方法,一步是作 npm 相關的驗證: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));
      // 根據 username 對要發佈的包作個鑑權
      chain = chain.then(username => {
        if (username) {
          return verifyNpmPackageAccess(this.packagesToPublish, username, this.conf.snapshot);
        }
      });

      // 校驗用戶是否需進行 2fa 的驗證 -- 安全驗證相關
      chain = chain.then(() => getTwoFactorAuthRequired(this.conf.snapshot));
      chain = chain.then(isRequired => {
        // 記錄一下
        this.twoFactorAuthRequired = isRequired;
      });
    }

    return chain;
  }
複製代碼

prepareRegistryActions 執行時會先去校驗 registry,若是是第三方的 registry,會中止校驗,用戶在發包設置了 no-verify-access 就不進行後面校驗,默認會校驗。

校驗過程是首先經過 getNpmUsername 去拿到用戶的 username,這裏是經過 npm 提供的相關接口來獲取,具體流程能夠自行參考。拿到 username 以後根據 username 以及本次 publish 中須要發佈的包的信息去作一個鑑權,判斷用戶是否用該包的讀寫發包權限,沒有就會拋錯,最後一步是個 2fa 的驗證,通常 npm 包都不會開啓,主要是爲了安全做用作二次驗證使用,這裏不作具體講解。

下面在看 license 的校驗過程,方法是 prepareLicenseActions

prepareLicenseActions() {
    return Promise.resolve()
      // 經過 glob 的方式去找到待發布的包中沒有 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;
        }
      });
  }
複製代碼

這一步並不會對主要流程有什麼影響,主要就是找目前待發布的包中沒有 license 的,而後給個 warnning 提示,這裏找的方式使用過 lerna 本身構造的 project graph 去篩待發布包中不存在 liecense 文件的路徑,想了解具體過程參考 getPackagesWithoutLicense

2. 更新本地依賴版本 && 待發布包 gitHead

能夠你會對更新本地依賴版本這一步可能會有些迷惑,這裏舉個例子來解釋一下,在 lerna 中,若是 workspaces 以前存在依賴的話,在此次發包中,例如 A 這個包依賴了 B,B 在此次發包中版本升級了,那麼這裏 A 裏面依賴的 B 也要更新到對應的版本。

來看一下這一步:

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")
    );

    // 拿到上一步結果以後,就把對應的更新寫入 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 就要更新到發包的版本
        node.pkg.updateLocalDependency(resolved, depVersion, this.savePrefix);
      }
    });
}
複製代碼

這裏涉及到的一些操做方法,都是來自於 lerna 構建的 project graph,這部分能夠去參考一下 lerna core 中源碼。

這裏的 gitHead 是一個 hash 值,用戶能夠經過 --git-head 來自行指定,若是不指定的話,lerna 這裏會默認幫你取當前 commit 的 hash 值,即經過 git rev-parse HEAD 來獲取,通常 gitHead 結合 from-package 來使用,先看看代碼:

annotateGitHead() {
    try {
      // 用戶若是沒有默認指定就使用最近的 commit hash 值
      // getCurrentSHA 就是執行了一次 git rev-parse HEAD
      const gitHead = this.options.gitHead || getCurrentSHA(this.execOpts);
      for (const pkg of this.packagesToPublish) {
        // gitHead 是用來關聯 package 和 git 記錄
        // npm publish 正常狀況下須要該字段
        pkg.set("gitHead", gitHead);
      }
    } catch (err) {
    }
  }

複製代碼

在使用 from-package 的方式進行發包的時候,會把這個 githead 字段寫在 package.json 裏面。

3. 更新寫入本地

這一步就是將第二步的一些更新直接寫到 lerna 中對應項目裏面去,即寫到磁盤裏面,主要的方法爲:

serializeChanges() {
   return pMap(this.packagesToPublish, pkg => pkg.serialize());
}
複製代碼

這個 pkg.serialize() 方法,是能夠在 lerna 的 core 中找到的,主要做用就是將相關的更新寫入本地磁盤:

serialize() {
  // 這裏的 writePkg 封裝了 write-package-json 這個方法
  return writePkg(this.manifestLocation, this[PKG]).then(() => this);
}
複製代碼

4. package pack

在講解以前,咱們得先知道 npm pack 這個操做是幹什麼的,它會打包當前的文件夾內容打包成一個 tar 包,咱們在執行 npm publish 的時候會常常看到這個操做:

image-20210401234911209

不過 npm publish 幫咱們封裝了這個過程,lerna publish 中也會有這個過程,這已是發包前的最後一個操做了,具體可參考代碼:

packUpdated() {
    let chain = Promise.resolve();
  
    // ... 
    const opts = this.conf.snapshot;
    const mapper = pPipe(
      [
        // packDirectory 會給對應 pkg 的文件夾打個 tar 包出來
        // 相似於上面的 npm pack
        pkg =>
          pulseTillDone(packDirectory(pkg, pkg.location, opts)).then(packed => {
            pkg.packed = packed;
            return pkg.refresh();
          }),
      ].filter(Boolean)
    );

   // 這裏會按照拓撲序列去對要發佈的包進行 pack
    chain = chain.then(() => this.topoMapPackages(mapper));

    return pFinally(chain, () => tracker.finish());
  }
複製代碼

這一步首先能夠參考 topoMapPackages 這個方法,他會按照拓撲順序去對須要更新的包進行 pack,這裏 publish 由於涉及到包之間的一些依賴關係,所以只能按照拓撲的順序去執行,this.packagesToPublish 裏面存的是待發布的包:

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",
    });
  }
複製代碼

所以這裏會按照拓撲順序去對要發佈的包進行打包成 tar 包的操做,具體執行方法是 packDirectory 這個方法,這個方法我只貼一下打包的那一段邏輯,還有一些其餘的預處理邏輯作了一下刪除:

const tar = require("tar");
const packlist = require("npm-packlist");

function packDirectory(_pkg, dir, _opts) {
  // ...
  let chain = Promise.resolve();
  // 拿到待發布 pkg 的 目錄信息
  chain = chain.then(() => packlist({ path: pkg.contents }));
  // 對目錄下面的一些文件夾打包
  chain = chain.then(files =>
    // 具體參數去參考 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 形式寫到一個臨時目錄下面
  // 發佈完了會刪除
  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

在上一步完成了待發布包的打包操做以後,這一步就是 lerna publish 整個流程的最後一步了!

這一步會將上一次打包的內容直接發佈出去,先來看一下代碼:

publishPacked() {
    let chain = Promise.resolve();

    // 前面說過的 2fa 2次驗證,這裏會驗證一下
    if (this.twoFactorAuthRequired) {
      chain = chain.then(() => this.requestOneTimePassword());
    }

    const opts = Object.assign(this.conf.snapshot, {
      // 設置了 tempTag 就先用 lerna-temp
      tag: this.options.tempTag ? "lerna-temp" : this.conf.get("tag"),
    });

    const mapper = pPipe(
      [
        pkg => {
          // 拿到 pkg 上一次發佈的 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 });

          // 發佈包這個操做經過 npmPublish 這個過程來完成
          return pulseTillDone(npmPublish(pkg, pkg.packed.tarFilePath, pkgOpts, this.otpCache)).then(() => {
            return pkg;
          });
        }
      ].filter(Boolean)
    );

    // 這裏和上一步 pack 同樣,按照拓撲執行
    chain = chain.then(() => this.topoMapPackages(mapper));

    return pFinally(chain, () => tracker.finish());
  }
複製代碼

上一步講了 topoMapPackages 這個方法,這裏一樣的,它會按照拓撲順序去發佈待發布的 pkg。

npmPublish 這個方法中,會將前面打包的 pkg 的 tar 包 publish 到 npm 上面去,這裏用的是 lerna 做者本身的一個包,感興趣的能夠去 npm 上搜一下:@evocateur/libnpmpublish

這個包能夠不用擔憂 tarball 打包自於哪一個 pkg,只要你有個 tarball 它會幫你直接上傳到 npm 上面去來完成一次發佈,具體的內容能夠在 npm 中找到。

這裏由於引入了一些外部包加上這裏有太多的邊界條件處理,這裏就不具體去看 npmPublish 這個方法了,貼上發佈的那部分代碼,能夠參考一下:

const { publish } = require("@evocateur/libnpmpublish");

return otplease(innerOpts => publish(manifest, tarData, innerOpts), opts, otpCache).catch(err => {
  // re-throw to break chain upstream
  throw err;
});
複製代碼

那麼再走到這一步結束以後,基本上整個 lerna 的發包流程都走完了。

後續的一些收尾工做的處理,能夠再拉回 執行(execute) 這一節開頭的代碼分析那裏。

總結

本文從源碼角度剖析了一下 lerna publish 的執行機制,對於一些邊界的 corner case 有些刪減,按照主線講解了 lerna publish 是怎麼完成 lerna monorepo 中的整個發包流程操做的。但願本系列的文章能對你有所幫助。

相關文章
相關標籤/搜索